Build EVM blockchain data indexers using Ponder (ponder.sh) - an open-source TypeScript framework for indexing smart contract events, transactions, and traces into custom database schemas with type-safe APIs. Use when the user mentions ponder, blockchain/EVM indexing, onchain data pipelines, subgraph replacement, or wants to index smart contract events into a queryable database.
98
99%
Does it follow best practices?
Impact
98%
1.25xAverage score across 5 eval scenarios
Passed
No known issues
API routes are defined in src/api/index.ts. The file must export default a Hono app.
import { Hono } from "hono";
import { db, publicClients } from "ponder:api";
import * as schema from "ponder:schema";
import { graphql, client as sqlClient } from "ponder";
const app = new Hono();
// Add middleware and routes...
export default app;Key imports:
db from ponder:api - Read-only database access (NOT the same as context.db in indexing)publicClients from ponder:api - Viem public clients per chain (e.g., publicClients.mainnet)schema from ponder:schema - Table definitions for queriesgraphql from ponder - GraphQL middleware factoryclient from ponder - SQL over HTTP middleware factoryimport { graphql } from "ponder";
app.use("/graphql", graphql({ db, schema }));This auto-generates a GraphQL API with singular and plural queries for every table.
For a table named transfers:
transfers (plural) - List with filtering, sorting, paginationtransfer (singular) - Single record by primary keyAvailable filter suffixes for each column:
| Suffix | Description | Example |
|---|---|---|
| (none) | Exact match | { from: "0x..." } |
_gt | Greater than | { amount_gt: "1000" } |
_lt | Less than | { amount_lt: "1000" } |
_gte | Greater or equal | { timestamp_gte: "1700000000" } |
_lte | Less or equal | { timestamp_lte: "1700000000" } |
_in | In array | { from_in: ["0x...", "0x..."] } |
_not_in | Not in array | { from_not_in: ["0x..."] } |
_contains | String contains | { name_contains: "token" } |
_starts_with | String starts with | { name_starts_with: "USD" } |
_ends_with | String ends with | { name_ends_with: "coin" } |
_has | Array contains | { tags_has: "defi" } |
Combine with AND / OR:
{
transfers(
where: {
AND: [
{ amount_gt: "1000000" },
{ OR: [{ from: "0x..." }, { to: "0x..." }] }
]
}
) {
items { id from to amount }
}
}{
transfers(orderBy: "timestamp", orderDirection: "desc") {
items { id from to amount timestamp }
}
}{
transfers(limit: 10, after: "cursor_string") {
items { id from to amount }
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
}
totalCount
}
}{
transfers(limit: 10, offset: 20) {
items { id from to amount }
totalCount
}
}Relations defined in the schema are automatically available:
{
account(address: "0x...") {
address
balance
sentTransfers {
items { id to amount }
}
receivedTransfers {
items { id from amount }
}
}
}Exposes a SQL-compatible endpoint for direct database queries from @ponder/client:
import { client } from "ponder";
app.use("/sql/*", client({ db, schema }));This is used by @ponder/client on the frontend. See references/frontend.md.
Use standard Hono routing with Drizzle queries on db.sql:
import { eq, desc, and, gt } from "ponder/drizzle";
import { replaceBigInts } from "ponder";
import { transfers, accounts } from "ponder:schema";
app.get("/top-holders", async (c) => {
const result = await db.sql
.select()
.from(accounts)
.where(eq(accounts.isHolder, true))
.orderBy(desc(accounts.balance))
.limit(10);
return c.json(replaceBigInts(result, (v) => String(v)));
});
app.get("/transfers/:address", async (c) => {
const address = c.req.param("address") as `0x${string}`;
const result = await db.sql
.select()
.from(transfers)
.where(
and(
gt(transfers.amount, 0n),
eq(transfers.from, address)
)
)
.orderBy(desc(transfers.timestamp))
.limit(50);
return c.json(replaceBigInts(result, (v) => String(v)));
});Access chain RPC from API routes:
import { publicClients } from "ponder:api";
app.get("/block-number", async (c) => {
const blockNumber = await publicClients.mainnet.getBlockNumber();
return c.json({ blockNumber: Number(blockNumber) });
});import { count, sum, avg } from "ponder/drizzle";
app.get("/stats", async (c) => {
const [stats] = await db.sql
.select({
totalTransfers: count(),
totalVolume: sum(transfers.amount),
avgAmount: avg(transfers.amount),
})
.from(transfers);
return c.json(replaceBigInts(stats, (v) => String(v)));
});app.get("/account/:address", async (c) => {
const address = c.req.param("address") as `0x${string}`;
const result = await db.sql.query.accounts.findFirst({
where: eq(accounts.address, address),
with: {
sentTransfers: { limit: 10, orderBy: desc(transfers.timestamp) },
receivedTransfers: { limit: 10, orderBy: desc(transfers.timestamp) },
},
});
if (!result) return c.json({ error: "Not found" }, 404);
return c.json(replaceBigInts(result, (v) => String(v)));
});JSON cannot serialize BigInt values. Use replaceBigInts to convert them:
import { replaceBigInts } from "ponder";
// Convert to string:
replaceBigInts(data, (v) => String(v));
// Convert to number (loses precision for large values):
replaceBigInts(data, (v) => Number(v));
// Custom formatting:
replaceBigInts(data, (v) => `${v}n`);These routes are handled by Ponder internally and cannot be overridden:
| Route | Description |
|---|---|
/health | Always returns 200. Use for liveness probes. |
/ready | Returns 503 during backfill, 200 when caught up. Use for readiness probes. |
/status | Returns indexing progress as JSON. |
/metrics | Prometheus-format metrics. |