The complete build-to-production pipeline for Ethereum dApps. Fork mode setup, IPFS deployment, Vercel config, ENS subdomain setup, and the full production checklist. Built around Scaffold-ETH 2 but applicable to any Ethereum frontend project. Use when deploying any dApp to production.
"I'll use yarn chain." Wrong. yarn chain gives you an empty local chain with no protocols, no tokens, no state. yarn fork --network base gives you a copy of real Base with Uniswap, Aave, USDC, real whale balances — everything (verified addresses: addresses/SKILL.md). Always fork.
"I deployed to IPFS and it works." Did the CID change? If not, you deployed stale output. Did routes work? Without trailingSlash: true, every route except / returns 404. Did you check the OG image? Without NEXT_PUBLIC_PRODUCTION_URL, it points to localhost:3000.
"I'll set up the project manually." Don't. npx create-eth@latest handles everything — Foundry, Next.js, RainbowKit, scaffold hooks. Never run forge init or create Next.js projects from scratch.
yarn chain (WRONG) yarn fork --network base (CORRECT)
└─ Empty local chain └─ Fork of real Base mainnet
└─ No protocols └─ Uniswap, Aave, etc. available
└─ No tokens └─ Real USDC, WETH exist
└─ Testing in isolation └─ Test against REAL state
npx create-eth@latest # Select: foundry, target chain, name
cd <project-name>
yarn install
yarn fork --network base # Terminal 1: fork of real Base
yarn deploy # Terminal 2: deploy contracts to fork
yarn start # Terminal 3: Next.js frontend
When using fork mode, the frontend target network MUST be chains.foundry (chain ID 31337), NOT the chain you're forking.
The fork runs locally on Anvil with chain ID 31337. Even if you're forking Base:
// scaffold.config.ts during development
targetNetworks: [chains.foundry], // ✅ NOT chains.base!
Only switch to chains.base when deploying contracts to the REAL network.
# In a new terminal — REQUIRED for time-dependent logic
cast rpc anvil_setIntervalMining 1
Without this, block.timestamp stays FROZEN. Any contract logic using timestamps (deadlines, expiry, vesting) will break silently.
Make it permanent by editing packages/foundry/package.json to add --block-time 1 to the fork script.
IPFS is the recommended deploy path for SE2. Avoids Vercel's memory limits entirely. Produces a fully decentralized static site.
cd packages/nextjs
rm -rf .next out # ALWAYS clean first
NEXT_PUBLIC_PRODUCTION_URL="https://yourapp.yourname.eth.link" \
NODE_OPTIONS="--require ./polyfill-localstorage.cjs" \
NEXT_PUBLIC_IPFS_BUILD=true \
NEXT_PUBLIC_IGNORE_BUILD_ERROR=true \
yarn build
# Upload to BuidlGuidl IPFS
yarn bgipfs upload out
# Save the CID!
Node.js 25+ ships a built-in localStorage object that's MISSING standard WebStorage API methods (getItem, setItem). This breaks next-themes, RainbowKit, and any library that calls localStorage.getItem() during static page generation.
Error you'll see:
TypeError: localStorage.getItem is not a function
Error occurred prerendering page "/_not-found"
The fix: Create polyfill-localstorage.cjs in packages/nextjs/:
if (typeof globalThis.localStorage !== "undefined" &&
typeof globalThis.localStorage.getItem !== "function") {
const store = new Map();
globalThis.localStorage = {
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => store.set(key, String(value)),
removeItem: (key) => store.delete(key),
clear: () => store.clear(),
key: (index) => [...store.keys()][index] ?? null,
get length() { return store.size; },
};
}
Why --require and not instrumentation.ts? Next.js spawns a separate build worker process for prerendering. --require injects into EVERY Node process (including workers). next.config.ts polyfill only runs in the main process. instrumentation.ts doesn't run in the build worker. Only --require works.
IPFS gateways serve static files. No server handles routing. Three things MUST be true:
1. output: "export" in next.config.ts — generates static HTML files.
2. trailingSlash: true (CRITICAL) — This is the #1 reason routes break:
trailingSlash: false (default) → generates debug.htmltrailingSlash: true → generates debug/index.htmlindex.html automatically, but NOT bare filenames/debug → 404 ❌/debug → debug/ → debug/index.html ✅3. Pages must survive static prerendering — any page that crashes during yarn build (browser APIs at import time, localStorage) gets skipped silently → 404 on IPFS.
The complete IPFS-safe next.config.ts pattern:
const isIpfs = process.env.NEXT_PUBLIC_IPFS_BUILD === "true";
if (isIpfs) {
nextConfig.output = "export";
nextConfig.trailingSlash = true;
nextConfig.images = { unoptimized: true };
}
SE2's block explorer pages use localStorage at import time and crash during static export. Rename app/blockexplorer to app/_blockexplorer-disabled if not needed.
The #1 IPFS footgun: You edit code, then deploy the OLD build.
# MANDATORY after ANY code change:
rm -rf .next out # 1. Delete old artifacts
# ... run full build command ... # 2. Rebuild from scratch
grep -l "YOUR_STRING" out/_next/static/chunks/app/*.js # 3. Verify changes present
# Timestamp check:
stat -f '%Sm' app/page.tsx # Source modified time
stat -f '%Sm' out/ # Build output time
# Source NEWER than out/ = STALE BUILD. Rebuild first!
The CID is proof: If the IPFS CID didn't change after a deploy, you deployed the same content. A real code change ALWAYS produces a new CID.
ls out/*/index.html # Each route has a directory + index.html
curl -s -o /dev/null -w "%{http_code}" -L "https://GATEWAY/ipfs/CID/debug/"
# Should return 200, not 404
SE2 is a monorepo — Vercel needs special configuration.
packages/nextjscd ../.. && yarn installnext build).next)# Via API:
curl -X PATCH "https://api.vercel.com/v9/projects/PROJECT_ID" \
-H "Authorization: Bearer $VERCEL_TOKEN" \
-H "Content-Type: application/json" \
-d '{"rootDirectory": "packages/nextjs", "installCommand": "cd ../.. && yarn install"}'
| Error | Cause | Fix |
|---|---|---|
| "No Next.js version detected" | Root Directory not set | Set to packages/nextjs |
| "cd packages/nextjs: No such file" | Build command has cd | Clear it — root dir handles this |
| OOM / exit code 129 | SE2 monorepo exceeds 8GB | Use IPFS instead, or vercel --prebuilt |
Want to deploy SE2?
├─ IPFS (recommended) → yarn ipfs / manual build + upload
│ └─ Fully decentralized, no memory limits, works with ENS
├─ Vercel → Set rootDirectory + installCommand
│ └─ Fast CDN, but centralized. May OOM on large projects
└─ vercel --prebuilt → Build locally, push artifacts to Vercel
└─ Best of both: local build power + Vercel CDN
Two mainnet transactions to point an ENS subdomain at your IPFS deployment.
https://app.ens.domains/yourname.ethmyapp) → Next → Skip profile → Open Wallet → Confirmhttps://app.ens.domains/myapp.yourname.ethipfs://<CID>For updates to an existing app: skip Tx 1, only do Tx 2.
# 1. Onchain content hash matches
RESOLVER=$(cast call 0x00000000000C2e074eC69A0dFb2997BA6C7d2e1e \
"resolver(bytes32)(address)" $(cast namehash myapp.yourname.eth) \
--rpc-url https://eth.llamarpc.com)
cast call $RESOLVER "contenthash(bytes32)(bytes)" \
$(cast namehash myapp.yourname.eth) --rpc-url https://eth.llamarpc.com
# 2. Gateway responds (may take 5-15 min for cache)
curl -s -o /dev/null -w "%{http_code}" -L "https://myapp.yourname.eth.link"
# 3. OG metadata correct (not localhost)
curl -s -L "https://myapp.yourname.eth.link" | grep 'og:image'
Use .eth.link NOT .eth.limo — .eth.link works better on mobile.
When the user says "ship it", follow this EXACT sequence.
scaffold.config.ts has rpcOverrides and pollingInterval: 3000Ask: "What subdomain do you want? e.g. myapp.yourname.eth → myapp.yourname.eth.link"
public/thumbnail.png) — NOT the stock SE2 thumbnailNEXT_PUBLIC_PRODUCTION_URL to the live domainog:image will resolve to an absolute production URLcd packages/nextjs && rm -rf .next out
NEXT_PUBLIC_PRODUCTION_URL="https://myapp.yourname.eth.link" \
NODE_OPTIONS="--require ./polyfill-localstorage.cjs" \
NEXT_PUBLIC_IPFS_BUILD=true NEXT_PUBLIC_IGNORE_BUILD_ERROR=true \
yarn build
# Verify before uploading:
ls out/*/index.html # Routes exist
grep 'og:image' out/index.html # Not localhost
stat -f '%Sm' app/page.tsx # Source older than out/
stat -f '%Sm' out/
yarn bgipfs upload out # Save the CID
Send: "Build ready for review: https://community.bgipfs.com/ipfs/<CID>"
Wait for approval before touching ENS.
Create subdomain (if new) + set IPFS content hash. Two mainnet transactions.
.eth.link gateway responds with 200/debug/, etc.)"Live at https://myapp.yourname.eth.link — ENS content hash confirmed onchain, unfurl metadata set."
A build is NOT done when the code compiles. It's done when you've tested it like a real user.
.tsx files for raw address strings (should use <Address/>)isLoading state across multiple buttonsdisabled props on transaction buttonsforge test # All tests pass
forge test --fuzz-runs 10000 # Fuzz testing
Test edge cases: zero amounts, max amounts, unauthorized callers, reentrancy attempts.
Open the app and do a FULL walkthrough:
For bigger projects, spawn a sub-agent with fresh context. Give it the repo path and deployed URL. It reads all code against the UX rules, opens a browser, clicks through independently, and reports issues.
yarn chain — use yarn fork --network <chain>forge init — use npx create-eth@latestdeployedContracts.ts — it's auto-generated by yarn deployscaffold.config.ts — use .env.localmainnet.base.org in production — use Alchemy or similar