forge init gives you a contract, a test, and a deployment script. create-next-app gives you a page. Neither one knows the other exists, and the part that nobody writes down is what has to travel between them.
It turns out to be two files: an ABI and an address. Everything else is one of the two tools doing what its own documentation already covers.
So I built the smallest complete version I could — one contract, one page, one button — and wrapped a harness around it that runs the whole thing from an empty node_modules to a rendered page, then asserts that the page, the chain, and a transaction sent from a real browser all agree. Six cold runs: median 20.1 seconds, 11 assertions against the chain and the served HTML, 5 more driving the page in Chrome. All green.
The versions matter for anything in this article: Foundry 1.5.1 with Solidity 0.8.36, viem 2.56.3, Next.js 16.3.4, React 19.2.8, Node.js 22.17.0, on an Apple M3 Pro. Everything below is in examples/articles/how-do-i-start-an-ethereum-project-with-foundry-viem-and-next-js/.
The shape of the project
Two directories that each keep their own dependencies, and two scripts between them.
contracts/
foundry.toml
src/Counter.sol
test/Counter.t.sol
script/Deploy.s.sol
scripts/
write-abi.mjs # out/Counter.sol/Counter.json -> web/lib/counter-abi.ts
write-deployment.mjs # broadcast/.../run-latest.json -> web/.env.local
web/
app/layout.tsx
app/page.tsx # server component: reads the chain
app/bump.tsx # client component: sends the transaction
lib/chain.ts # one chain definition, one public client
lib/counter-abi.ts # generated
.env.local # generated
That is the entire project. contracts/ has no Node dependency at all; web/ installs 43 packages with zero advisories, 13 of which are viem and its transitive dependencies.
Keeping them separate is worth a sentence. A single root package.json looks tidier, but the contract half genuinely does not need Node, and mixing them means every Solidity change waits behind an npm install. The two generated files are cheap enough to regenerate that the boundary costs nothing.
The contract half is the easy half
forge init contracts scaffolds a project with Counter.sol, a test, and a script. I kept the name and gave it just enough behaviour for a frontend to have something to do: a shared number, a per-address tally, an event, and one way to fail.
/// @notice Largest step a single call may take.
uint256 public constant MAX_STEP = 10;
/// @notice The shared value every visitor sees.
uint256 public number;
/// @notice How much each address has contributed to `number`.
mapping(address account => uint256 total) public bumpedBy;
event Bumped(address indexed by, uint256 step, uint256 newNumber);
/// @notice Thrown when a call asks for a step outside 1..MAX_STEP.
error StepOutOfRange(uint256 step, uint256 maxStep);
The custom error is deliberate. A frontend that can only say "the transaction failed" is the default outcome, and it is worth seeing what it takes to do better.
Tests are five Solidity functions, including one fuzz test over the sender and the step:
Ran 5 tests for test/Counter.t.sol:CounterTest
[PASS] testFuzz_IncrementByTracksSender(address,uint256) (runs: 256, μ: 58592, ~: 58719)
[PASS] test_IncrementAddsOne() (gas: 53696)
[PASS] test_IncrementByEmitsBumped() (gas: 56357)
[PASS] test_RevertWhen_StepIsTooLarge() (gas: 8784)
[PASS] test_RevertWhen_StepIsZero() (gas: 8759)
Suite result: ok. 5 passed; 0 failed; 0 skipped; finished in 11.60ms (11.99ms CPU time)
Nothing here is a surprise, which is the point. I picked Foundry because a Solidity-only directory is exactly the case where it is leanest — I measured the current gap against Hardhat 3 in August, and Foundry won every scenario while Hardhat came within 12 to 22 percent on the test workloads. For a repository that is nothing but contracts, that margin is not the reason to choose; the absence of a Node toolchain is.
Deploying, and where the address comes from
Start a chain with anvil. It listens on port 8545, uses chain id 31337, and unlocks ten accounts funded with 10,000 ETH from the published mnemonic test test test test test test test test test test test junk, as documented. Those keys are public. They are safe on a throwaway local chain and nowhere else.
The deployment script is four statements:
contract Deploy is Script {
function run() external returns (Counter counter) {
vm.startBroadcast();
counter = new Counter();
vm.stopBroadcast();
console.log("Counter deployed at", address(counter));
}
}
Run it with forge script and --broadcast, and Foundry writes a record of what it sent to contracts/broadcast/Deploy.s.sol/31337/run-latest.json. That file, not the console output, is what the frontend should read:
const run = JSON.parse(readFileSync(record, "utf8"));
const creation = run.transactions.find((tx) => tx.transactionType === "CREATE");
if (!creation) throw new Error("no CREATE transaction in the broadcast record");
const receipt = run.receipts.find((r) => r.transactionHash === creation.hash);
if (!receipt) throw new Error(`no receipt for ${creation.hash}`);
A fresh Anvil plus a first deployment from account zero always produces the same address — 0x5FbDB2315678afecb367f032d93F642f64180aa3, in every one of my runs, because a contract address is derived from the sender and its nonce. That is convenient enough to tempt you into pasting it into the frontend, and it stops being true the moment you deploy anything else first. Read the record.
The two files that cross the boundary
scripts/write-abi.mjs copies the ABI out of Foundry's artifact and writes a TypeScript file. Seventeen lines in total, and two words of them matter more than the rest:
writeFileSync(
target,
`// Generated by scripts/write-abi.mjs from contracts/out/Counter.sol/Counter.json.\n` +
`// Do not edit by hand; re-run \`npm run abi\` after changing the contract.\n` +
`export const counterAbi = ${JSON.stringify(abi, null, 2)} as const;\n`,
);
as const is the whole reason viem's types work. viem reads the ABI as a literal type and derives function names, argument tuples, and return types from it. Without the assertion, the array widens to { type: string, ... }[] and everything downstream degrades to unknown.
I checked what each mistake actually costs by breaking the file and running tsc:
| Change | Result |
|---|---|
| Baseline | 0 errors |
Drop as const |
error TS18046: 'number' is of type 'unknown'. |
| Ask for a function the contract does not have | error TS2322: Type '"counter"' is not assignable to type '"number" | "MAX_STEP" | "bumpedBy"'. |
The second row is the payoff. Rename a function in Solidity, regenerate, and the frontend stops compiling with the list of names that do exist. That check is free, and it is the main practical argument for generating the ABI file instead of hand-copying a JSON blob.
The second generated file is web/.env.local, holding the address, the chain id, and the RPC URL. Environment variables rather than a checked-in constant, because the address changes per environment and the ABI does not.
Reading the chain from a server component
One file defines the chain and the client for the whole app:
import { createPublicClient, defineChain, http } from "viem";
import { anvil } from "viem/chains";
const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL ?? "http://127.0.0.1:8545";
/** viem already ships an `anvil` chain (id 31337); this only repoints the RPC URL. */
export const chain = defineChain({ ...anvil, rpcUrls: { default: { http: [rpcUrl] } } });
The page is a server component that reads the contract directly. No wallet, no provider, no useEffect:
export const dynamic = "force-dynamic";
export default async function Home() {
const number = await publicClient.readContract({
address: counterAddress,
abi: counterAbi,
functionName: "number",
});
That first line is the one I want to defend, because Next.js will happily build without it and the failure mode is not what you would guess.
I built the page four times — with and without force-dynamic, each with the chain reachable and unreachable — and checked what the build printed and what the server actually served:
| Build | Route marker | Build with the chain down | Serves current data |
|---|---|---|---|
With force-dynamic |
ƒ dynamic |
succeeds | yes |
| Without it | ○ static |
fails | yes |
Both variants serve current data. Next.js 16 marks the page static, writes a prerendered index.html holding the number as it was at build time, and then re-renders on every request anyway, with Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate. I confirmed that directly: the prerendered file held 0, the chain moved to 1, and the server returned 1.
The real cost is the build. Without force-dynamic, next build calls eth_call to prerender the page, and when the RPC is unreachable the build exits 1 with Export encountered an error on /page: /. Your CI now needs a chain, or your production build burns RPC credits rendering a page nobody will ever be served. Marking the route dynamic is not about freshness here. It is about keeping the chain out of your build step.
Writing from the browser
The write path is a client component. viem's custom() transport wraps whatever EIP-1193 provider the wallet injected, and the sequence is simulate, sign, wait, refresh:
setStatus("simulating");
const { request } = await publicClient.simulateContract({
account,
address: counterAddress,
abi: counterAbi,
functionName: "incrementBy",
args: [BigInt(step)],
});
setStatus("waiting for signature");
const hash = await wallet.writeContract(request);
setStatus(`sent ${hash}`);
const receipt = await publicClient.waitForTransactionReceipt({ hash });
setStatus(`mined in block ${receipt.blockNumber}`);
router.refresh();
simulateContract runs the call against current state before anything is signed, and hands back a request object that goes straight into writeContract. It is the difference between a user rejecting a doomed transaction in their wallet and paying gas to watch it revert. router.refresh() re-runs the server component, so the number on the page comes back from the chain rather than from optimistic client state.
Then the error handling, which is where I found the one thing I would not have predicted.
viem does decode custom errors. It just does not put them where you are most likely to look:
function describe(error: unknown) {
if (!(error instanceof BaseError)) return String(error);
const reverted = error.walk((e) => e instanceof ContractFunctionRevertedError);
if (reverted instanceof ContractFunctionRevertedError && reverted.data) {
return `${reverted.data.errorName}(${reverted.data.args?.join(", ")})`;
}
return error.shortMessage;
}
Calling incrementBy(11) against a contract with MAX_STEP = 10 gives a shortMessage of The contract function "incrementBy" reverted. — no error name, no arguments. Walk one level down to ContractFunctionRevertedError and data.errorName is StepOutOfRange with args of [11n, 10n]. The node's own reply was execution reverted: custom error 0xa2fc5cfd; the decoding is viem's, using the ABI you already generated.
If you show shortMessage in your UI — and it is the obvious property to reach for — you throw away the error the contract author went to the trouble of defining.
I verified this end to end rather than by reading types. The harness loads the real page in Chrome with an injected EIP-1193 provider backed by Anvil, whose accounts are unlocked and therefore sign on request, which stands in for a wallet extension's approval:
PASS page rendered a number — 5
PASS component reported a mined transaction — mined in block 4
PASS router.refresh() re-rendered the server component — 5 -> 9
PASS custom error reached the UI with its arguments — StepOutOfRange(11, 10)
PASS rejected call did not change state — 9
5/5 browser checks passed
What it costs the browser
The page is a heading, two lines of text, a number input and a button, so whatever JavaScript it ships is the framework and the library, not the app.
Adding the client component costs 685 bytes gzipped. Adding viem to it costs 93.0 kB. That is the number worth internalising: the interactivity is free and the library is not.
Rewriting the same component in viem's tree-shakable style — createClient instead of createPublicClient, and each action imported from viem/actions — brings it to 46.7 kB, a saving of 46.3 kB gzipped for an identical result. viem's own documentation is explicit about why: "You can use the Client as-is, with no decorated Actions, to maximize tree-shaking in your app." The decorated client attaches every action, so the bundler cannot drop the ones you never call.
Both versions are in the repository and both are exercised by the harness, which asserts they produce the same on-chain result. Worth flagging, because the number usually quoted for viem — around 31 kB gzipped, including in my own earlier comparison with Ethers.js — describes the tree-shakable style, not the decorated client that viem's own getting-started page constructs and that most code therefore inherits.
The small things that cost me time
tsc fails on a clean checkout. app/layout.tsx uses LayoutProps<"/">, a type Next.js generates into .next/types. On a fresh clone there is no .next, so tsc --noEmit reports Cannot find name 'LayoutProps'. Run next typegen first — it writes the types without a full build, in a median of 1.6 seconds.
bigints cross the server/client boundary fine. The common advice is to stringify a bigint before passing it to a client component. I tested it: the prop typechecks, builds, renders, and shows up in the React payload as $n0, React's BigInt marker. Convert for display, not for transport.
viem's default polling interval is 4,000 ms. On a local chain that is slower than everything else in the stack. pollingInterval: 200 on the public client makes waitForTransactionReceipt feel instant.
Next.js picks a workspace root when it sees several lockfiles. In a repository with a lockfile above the app, next build warns that it "inferred your workspace root" and picks the outer directory. Set turbopack.root if it guesses wrong.
Where the time actually goes
| Group | Median | Share |
|---|---|---|
| Contracts: build, test, chain, deploy | 2.2 s | 11% |
| Wiring: the two generated files | 0.1 s | 1% |
| Frontend: install, typegen, typecheck, build, start | 16.8 s | 84% |
| Verification | 0.4 s | 2% |
Six cold runs, median total 20.1 seconds, range 15.7 to 27.7, counting the stages every run recorded. Compiling Solidity, running 5 tests including 256 fuzz cases, starting a chain, and deploying is 2.2 seconds of that. next build alone is 7.1 seconds and npm ci another 4.4.
I am not offering that as a benchmark of anything — it is one small project on one machine. It is a useful expectation to set, though, if you are coming to Ethereum development braced for slow tooling. The Ethereum-specific parts of this stack are not what you wait for.
Where this stops
This is a starting point, and it is honest about being one. Three things I would add before calling it an application:
A real wallet connection. window.ethereum is one injected provider with no chain switching, no reconnection across reloads, and no account-change handling. That is what wagmi exists for, and it is a separate decision from anything above, because wagmi is built on viem and does not replace it.
A testnet. Everything here points at chain id 31337. Moving to a public testnet means a funded key you keep out of the repository, an RPC endpoint, and contract verification — forge verify-contract defaults to Sourcify in Foundry 1.5.1, not Etherscan.
Somewhere to put history. eth_call answers "what is true now" cheaply and answers "what happened over the last month, grouped by address" badly. The moment your page needs the second question, you need an indexer, and that is a bigger decision than any file in this project.
None of that changes the seam. It stays two generated files, and getting them generated rather than pasted is most of what separates a project that keeps working from one that breaks quietly the next time someone redeploys.