Most "recommended stack" posts are a list of favorites. The problem with a list is that the interesting parts of a stack are the seams: which layer owns which question, what has to be running in production, and which piece breaks when you wire it to the others.
So instead of listing tools, I assembled one. Foundry for the contracts, Anvil for the chain, Ponder for the indexer, viem for reads, wagmi for writes, Next.js for the frontend. Then I ran the whole thing cold — empty node_modules to a rendered page — six times, and had it check that the chain, the indexer and the page all agree.
It works. Median 41.8 seconds from npm ci to a verified page, with eight assertions passing at the end. Three things bit me on the way: a silent indexing bug, a four-second default, and a dependency tree I do not control.
The stack, and what each layer is for
| Layer | Choice | Version measured | The question it owns |
|---|---|---|---|
| Contracts and tests | Foundry | 1.5.1, solc 0.8.36 | Is the contract correct? |
| Local chain | Anvil | 1.5.1 | What is true right now? |
| Deployment | forge script --broadcast |
1.5.1 | What is deployed where? |
| Indexing | Ponder + Postgres | 0.17.9, Postgres 17.11 | What happened, aggregated? |
| Reads | viem | 2.56.3 | Current state, from the server |
| Writes | wagmi | 3.7.7 | The user's wallet, in the browser |
| Frontend | Next.js | 16.3.4, React 19.2.8 | What the user sees |
Two of those rows exist because the chain answers some questions badly. Reads are cheap and current; aggregates over history are neither, which is what the indexer is for — and the indexer is the row people leave out until it is too late.
The test subject is deliberately small: a guestbook where anyone can post a message with an optional tip, and the owner can withdraw. Small enough to read in a minute, and big enough that every layer has real work to do.
Contracts: Foundry, and when I would not use it
I argued in Foundry vs Hardhat: why Foundry won that Foundry understood Solidity developers better, and I still think that. What has changed is that the argument is no longer one-sided. Hardhat 3 rebuilt its execution layer in Rust, made Solidity tests first-class, and closed most of the gap — I went through that in The comeback of Hardhat, and then measured it: Foundry won every scenario, but on Solidity test workloads Hardhat came within 12 to 22 percent.
So this is not a speed decision anymore. For a repository whose center of gravity is Solidity, Foundry is still the leanest thing that does the job. forge build compiled the project cold in a median of 894 ms, and forge test ran 6 unit tests, one fuzz test of 512 cases and two stateful invariants of 2,048 calls each in 321 ms. No Node process, no plugin graph, no configuration beyond a 23-line foundry.toml.
The invariant is the part worth writing even for a toy contract. A handler drives randomized posts and keeps ghost totals; the invariant asserts the contract's own accounting matches them:
function invariant_TotalsMatchGhosts() public view {
assertEq(guestbook.postCount(), handler.ghostPosts());
assertEq(guestbook.totalTipped(), handler.ghostTipped());
}
That is 33 lines of handler and invariant contract for 4,096 randomized calls per run, against a 49-line contract. It is the cheapest real testing anywhere in this stack.
I would reach for Hardhat 3 instead when the repository is more than contracts: TypeScript services in the same tree, a plugin-shaped build, Ignition-style declarative deployments, or chain-specific simulation. If Solidity is the whole project, Foundry.
The chain is the only shared source of truth
Anvil starts in a median of 80 ms, which is fast enough that it stops being a step you think about. Everything else in the stack points at it: the deploy script, the indexer, the server-side reads, and the browser's wallet.
That is also where the first thing broke. My deploy script wrote its own deployment record using block.number, the indexer read that as its startBlock, and the indexer found nothing.
Inside forge script, block.number is the simulation block, and it is always behind the block the broadcast transaction lands in. I checked it four times against the receipt: with one-second blocks the script reported 6, 21 and 25 while the transaction landed in 8, 22 and 27; with instant mining it reported 0 and the transaction landed in 1. The gap was never zero.
The fix is to stop asking the script and read the receipt Foundry already wrote:
const artifact = JSON.parse(
readFileSync(
new URL(`../contracts/broadcast/Deploy.s.sol/${chainId}/run-latest.json`, import.meta.url),
),
);
const receipt = artifact.receipts.find((r) => r.contractAddress);
if (!receipt) throw new Error("no contract-creation receipt in run-latest.json");
// The block the transaction landed in, not `block.number` from inside the
// script — that one is the simulation block, and it is always behind.
const deploymentBlock = Number.parseInt(receipt.blockNumber, 16);
A start block two blocks too late is a silent failure: the indexer runs, reports healthy, and serves an empty table. On a public chain, with a contract deployed months ago and events arriving daily, you would not notice for a while.
Every private key in the project is one of Anvil's default development keys. Foundry's own documentation is blunt about them — "Default Anvil accounts are publicly known" — which is exactly why they are the right thing to commit and the wrong thing to ever fund.
On a public network the same forge script --broadcast step is the deployment, and forge verify-contract is what makes the source readable afterwards. Worth knowing which provider you are actually using: in Foundry 1.5.1 the --verifier flag accepts etherscan, sourcify, blockscout, oklink or a custom Etherscan-compatible endpoint, and it defaults to sourcify, not Etherscan.
The frontend: viem on the server, wagmi in the browser
These are two different jobs and it helps to keep them apart.
Current contract state does not need a wallet, a provider, or a browser. It is an eth_call, so it belongs in a React server component:
/** Server-side reads. No wallet, no provider, no browser. */
export const publicClient = createPublicClient({
chain: localChain,
transport: http(chainRpcUrl),
});
The page then asks for both sources at once — the node for state, the indexer for the aggregate:
// Two data sources, two different jobs. Current contract state comes
// straight from the node; the per-author aggregate comes from the indexer.
const [postCount, totalTipped, leaderboard] = await Promise.all([
publicClient.readContract({
address: guestbookAddress,
abi: guestbookAbi,
functionName: "postCount",
}),
publicClient.readContract({
address: guestbookAddress,
abi: guestbookAbi,
functionName: "totalTipped",
}),
fetchLeaderboard(),
]);
I compared viem against Ethers.js in detail in ethers vs viem; the short version is that viem's size advantage is real but conditional on writing it in the tree-shakable style. On the server none of that matters, and what you notice instead is the polling default. viem's clients poll every 4,000 ms, which is tuned for public networks. On a local chain that turned a four-transaction seed script into a 20-second wait. Setting pollingInterval: 100 took the same script to a median of 3.9 seconds, and most of what is left is Anvil's one-second block time.
Writes are the browser's job, and that is where wagmi earns its place. wagmi 3 made every connector dependency an optional peer dependency, so an app that only needs an injected browser wallet installs 51 packages in total — including Next.js, React and viem.
Two things about wagmi 3 that will trip you up if you learned wagmi 2. First, the account hooks were renamed: useAccount is now useConnection, useAccountEffect is useConnectionEffect. Second, every mutation hook now prefers TanStack Query's mutate over its bespoke name. The migration guide describes both as breaking changes, but the shipped types are gentler than that: useAccount is exported as an alias of useConnection, and across 17 hook type files there are 41 members marked @deprecated rather than removed. Your v2 code will compile. It will just be writing the old shape.
const { address, isConnected } = useConnection();
const connectors = useConnectors();
const { mutate: connect } = useConnect();
const { mutate: write, data: hash, isPending, error } = useWriteContract();
const { isSuccess } = useWaitForTransactionReceipt({ hash });
The wallet layer is not free, and it is worth knowing the number. I built the same page twice, once as written and once with the wagmi provider and the write form removed, then summed every script the rendered HTML references. With the wallet layer: 11 scripts, 285.3 kB gzipped. Without it: 6 scripts, 168.6 kB. So wagmi, TanStack Query and the injected connector cost 116.7 kB gzipped on first load.
That is a real cost for a real capability, and it is also an argument for keeping reads on the server. A page that only displays state should not be paying it.
The indexer is the layer people skip
The chain can tell you postCount(). It cannot tell you the top ten authors by total tipped without scanning every log you ever emitted, and it cannot do it in one round trip. That question belongs to an indexer, and skipping the indexer is how apps end up with a loop of eth_getLogs calls in a useEffect.
Ponder is the version of this I would pick today, because an indexing function is just TypeScript against the ABI you already have. This is the body of ponder.on("Guestbook:Posted", ...), which keeps a running per-author aggregate:
await context.db
.insert(author)
.values({
address: event.args.author,
postCount: 1,
totalTipped: event.args.tip,
lastPostBlock: event.block.number,
})
.onConflictDoUpdate((row) => ({
postCount: row.postCount + 1,
totalTipped: row.totalTipped + event.args.tip,
lastPostBlock: event.block.number,
}));
The alternative most people reach for first is a subgraph, where mappings are written in AssemblyScript — "stricter than normal TypeScript" — and compiled to WebAssembly. That is a different language with a different toolchain for a job your application already does in TypeScript. Ponder's requirements are Node 22 or later and Postgres 14 through 17, which are things you probably already run.
Two configuration details will cost you time. Ponder caches RPC responses by chain id, and Anvil's history disappears on restart, so the docs tell you to set disableCache: true for a development node; without it you get yesterday's chain served with confidence. And ponder start refuses to run at all until you name a database schema, either through DATABASE_SCHEMA or --schema. The error message says so clearly, which is more than most tools manage.
Against a chain of a few hundred blocks the backfill finished in a median of 3.2 seconds, and once it was live the indexer served a newly mined post a median of 388 ms after the receipt — with a worst case of 1.3 seconds across six runs, which is about what two one-second polling intervals should look like.
There is a governance fact worth knowing before you build on it: in February 2026 the Monad Foundation acquired the team behind Ponder, stating that "the team will continue to develop Ponder as the leading open-source indexing solution for EVM developers." Just under seven months on, that reads as accurate rather than aspirational — the repository has taken 120 commits since the announcement, it is still MIT, and it still indexes any EVM chain. It is a single-sponsor project now, which is a risk to note rather than a reason to avoid it.
What it costs to keep running
This is the part a stack diagram hides. Counting installed packages per layer:
| Layer | Direct deps | Installed packages | node_modules |
npm audit |
|---|---|---|---|---|
| contracts | 1 | 1 | 1 MB | clean |
| scripts | 1 | 13 | 76 MB | clean |
| indexer | 5 | 216 | 215 MB | 7 advisories, 5 high |
| web | 10 | 51 | 445 MB | clean |
The frontend's 445 MB is mostly Next.js platform binaries rather than dependency count, which is why the two columns disagree. The indexer is the only layer with advisories, and none of them are yours to fix. Ponder 0.17.9 pins exact versions of @hono/node-server 1.19.5, drizzle-orm 0.41.0, vite 5.4.21 and vite-node 1.0.2, plus kysely ^0.26.3, and every one of those falls inside an advisory range. Upgrading the direct dependency you control — Hono — removes exactly one of the eight I started with. The rest wait for an upstream release.
Worth saying plainly: five high-severity advisories in a build-tool dependency graph are not five ways into your database. But if your organization gates deploys on a clean audit, this is the layer that will fail the gate, and it will fail it for reasons outside your repository.
Operationally, the same asymmetry shows up again. The frontend is a build artifact — it can go anywhere that serves Next.js. The contracts are deployed once. The indexer is a long-running process that owns a Postgres database with real state in it, which means backups, migrations, and a plan for what happens when you change an indexing function. That is the actual bill for the leaderboard.
The whole thing, cold
One script does the whole sequence and times each stage: install, build, test, start the chain, deploy, seed, start Postgres, backfill, check realtime indexing, build the frontend, serve it, and verify. Median total across six cold runs was 41.8 seconds, ranging from 36.0 to 52.3 seconds, on an Apple M3 Pro with 12 logical CPUs and 36 GB of memory, Node 22.17.0.
The distribution is the useful part. Everything Solidity — compile, 4,096 randomized invariant calls, 512 fuzz cases, deploy — is 2.4 seconds combined. next build alone is 10.0 seconds, and installing the indexer and the frontend takes 14.7 between them. The Ethereum-specific parts of an Ethereum stack are not what you wait for. The JavaScript is.
The last stage is the one I would keep in any project like this. It asserts eight things across the three layers: that the contract's balance equals its totalTipped(), that the indexer's row count and summed tips match the chain, that the per-author aggregate agrees with the per-post rows, and that the rendered HTML actually contains both the server-side chain read and every indexed author. Five of the eight lines:
PASS chain: balance equals totalTipped — balance=0.41 totalTipped=0.41
PASS indexer: post count matches chain — 5 rows vs 5 onchain
PASS indexer: summed tips match chain — 0.41 vs 0.41 ETH
PASS indexer: author aggregate matches post rows — 4 authors, 5 posts, 0.41 ETH
PASS page: server-rendered chain state present — <strong>5</strong> posts, <strong>0.41</strong>
Those assertions are what caught the startBlock bug. A health check that only asks "is the indexer up" would have passed.
Where this stack stops
This is one small contract on one local chain on one machine, so treat the numbers as a shape rather than a benchmark.
Anvil is not a public network. There is no RPC rate limiting, no reorgs, no gas market, and no network latency between the indexer and the node — which is most of what would change how the indexing layer behaves in production. The measured run used Foundry 1.5.1, which is what is installed here; 1.8.1 has been the stable release since August 2026. Nothing in the frontend was tested in a browser: next build type-checks the wagmi hooks and the production server renders the page, but no wallet was ever connected, so the write path is verified as far as the wallet boundary and no further.
And this is a stack for an application with a frontend. A protocol repository with no offchain layer needs about a quarter of it: Foundry, and nothing else on this list.
What I would choose today
Foundry for anything Solidity-shaped, still, and without much hesitation. viem on the server for reads. wagmi in the browser for writes, with the 117 kB understood as the price of a wallet rather than a surprise. Ponder for the questions the chain answers badly, accepting that it brings a long-running process, a database, and a dependency graph you do not control.
Next.js because the frontend has to go somewhere, and because keeping reads on the server is what makes the browser bundle a choice instead of a default.
The decision I would think hardest about is the indexer, and not because of Ponder. It is the only layer here that turns a deployment into an operation.