AI agent index: llms.txt
A production-ready template for building full-stack React dApps on Cloudflare Workers. It marries a TanStack Start frontend (SSR + file-based routing) and a Hono API on the edge with a complete EVM stack: Foundry contracts, wagmi + viem + ConnectKit on the client, and a typegen pipeline that turns Foundry artifacts into as const ABIs and typed contract addresses.
Use it as the starting point for your next on-chain project — clone it, rename it, point it at your own contracts, and start shipping.
- Click Use this template on GitHub (or
gh repo create --template). pnpm install.pnpm run init-project— prompts for a kebab-case project name, renameswrangler.jsonc+package.json, and fans out the*.exampletemplates into per-env files (.env/.env.staging/.env.production,.dev.vars/.staging.vars/.production.vars,contracts/.env). Idempotent — re-runnable, never overwrites filled-in files. The script's "Next steps" output lists every field that still needs a value.- Set
VITE_CHAIN_ID(default31337Anvil; use11155111for sepolia,1for mainnet) andVITE_WALLETCONNECT_PROJECT_ID(free at https://cloud.walletconnect.com) in.env/.env.staging/.env.production. - Drop your contracts into
contracts/src/, write aDeploy<Name>.s.solscript incontracts/script/, and add a deploy command inpackage.jsonmirroringcontracts:deploy:local(testnet/mainnetvariants are wrapped withdotenvx run -f contracts/.envand read${TESTNET_RPC_URL}/${MAINNET_RPC_URL}from there). - Run
pnpm contracts:dev— anvil + deploy + typegen in one go. - (Optional) provision a Neon database, fill
DATABASE_HOST/USERNAME/PASSWORDin.dev.vars, thenpnpm cf-typegen && pnpm db:migrate:dev. - (Optional, when you're done with the demos) delete
src/db/client/,src/hono/api/clients.ts, and the exampleCounterflow. Then start modelling your own domain.
See Quick Start below for the dev-loop commands.
- Onchain end-to-end — Solidity contracts in
contracts/, deployed via Foundry scripts, ABIs and addresses regenerated intosrc/contracts/, consumed through wagmi hooks. Includes a workingCounterexample wired all the way to a UI button. - Wallet UX out of the box — ConnectKit + wagmi + viem, SSR-safe lazy hydration of the wallet provider, chain whitelist (mainnet / sepolia / anvil) configurable per environment.
- Edge-first — single
src/server.tsentrypoint that routes/api/*to Hono and everything else to TanStack Start, all running on Cloudflare Workers. - Type-safe end-to-end — strict TypeScript, Zod at every boundary, Drizzle-inferred DB types, typed Cloudflare
Envviawrangler types,as constABIs and address registries. - Local chain orchestrator —
pnpm contracts:devboots anvil, deploys, regenerates bindings, and keeps the chain in the foreground. Ctrl+C cleans up. - Deep modules — domain-oriented layout (
src/db/{domain}/,src/hono/api/{name}.ts,src/lib/web3/) with narrow public APIs. See.claude/rules/deep-modules.md. - Batteries included — error infrastructure, Neon + Drizzle migrations, Shadcn/UI, TanStack Query SSR hydration, Vitest, Biome, knip, semantic-release, taze.
- Agent-friendly — project rules in
.claude/rules/activate automatically based on the files you touch.
# Install dependencies
pnpm install
# Copy env templates and fill them in
cp .example.vars .dev.vars # Cloudflare bindings (DB credentials)
cp .env.example .env # Vite-side env (chain id, WalletConnect)
# Generate Cloudflare Env types
pnpm cf-typegen
# (Optional) run migrations against your dev database
pnpm db:migrate:dev
# In one terminal — boot the local chain, deploy contracts, run typegen
pnpm contracts:dev
# In another terminal — start the dev server
pnpm devThe app runs on http://localhost:3000. API endpoints are served under /api/*. The on-chain Counter card on the landing page reads from the locally deployed contract and lets a connected wallet call increment().
pnpm contracts:dev boots anvil on :8545, waits for it to be ready, deploys Counter.sol, regenerates src/contracts/, and keeps anvil in the foreground. Ctrl+C stops it cleanly. Requires Foundry (anvil, forge) on your PATH.
# .dev.vars — Cloudflare Worker bindings (server-side, gitignored)
CLOUDFLARE_ENV=dev
DATABASE_HOST="" # leave blank to skip DB init
DATABASE_USERNAME=""
DATABASE_PASSWORD=""
# .env — Vite-side, exposed to the browser bundle
VITE_CHAIN_ID=31337 # 1 = mainnet, 11155111 = sepolia, 31337 = anvil
VITE_WALLETCONNECT_PROJECT_ID="" # https://cloud.walletconnect.comThe DB is only initialised when DATABASE_HOST is set, so the template runs fully on-chain without a Postgres instance.
| Script | Purpose |
|---|---|
pnpm dev |
Dev server on port 3000 (Vite + Cloudflare plugin) |
pnpm build |
Production build (runs contracts:build + contracts:typegen first) |
pnpm serve |
Preview the production build locally |
pnpm deploy |
Build and deploy to Cloudflare Workers |
pnpm cf-typegen |
Generate Env types from wrangler.jsonc |
pnpm test / pnpm test:watch / pnpm test:coverage |
Vitest |
pnpm types |
tsc --noEmit type-check |
pnpm lint / pnpm lint:fix |
Biome check / auto-fix |
pnpm knip |
Detect unused files, deps, and exports |
pnpm db:generate:{dev,staging,production} |
Generate Drizzle migrations for each env |
pnpm db:migrate:{dev,staging,production} |
Apply migrations to each env |
pnpm db:pull:{dev,staging,production} |
Pull schema from existing DB |
pnpm db:studio |
Open Drizzle Studio against dev |
pnpm db:seed:{dev,staging,production} |
Run scripts/seed.ts against each env |
pnpm deps / pnpm deps:update |
Check / apply dependency updates via taze |
pnpm release |
semantic-release |
pnpm contracts:build / pnpm contracts:test |
forge build / forge test |
pnpm contracts:typegen |
Generate as const ABI + typed addresses into src/contracts/ |
pnpm contracts:deploy:{local,testnet,mainnet} |
Run DeployCounter.s.sol against the matching [rpc_endpoints] profile |
pnpm contracts:dev |
Start anvil on :8545, deploy contracts, run typegen, keep anvil in the foreground |
All db:* scripts load secrets via @dotenvx/dotenvx from .dev.vars, .staging.vars, or .production.vars.
contracts/ # Self-contained Foundry project
├── foundry.toml # profile, fs_permissions, [rpc_endpoints]
├── remappings.txt # forge-std/, @openzeppelin/contracts/
├── src/Counter.sol # example contract
├── test/Counter.t.sol # forge test
├── script/
│ ├── DeploymentRegistry.sol # library: read/merge/write registry JSON
│ └── DeployCounter.s.sol # forge script
└── deployments/{chainId}.json # auto-written registry, format: {"Counter":"0x.."}
scripts/
├── contracts-typegen/ # Foundry artifacts → src/contracts/ bindings
├── contracts-dev/ # anvil + deploy + typegen orchestrator
└── seed.ts # DB seed entrypoint
src/
├── server.ts # CF Workers entry — routes /api/* → Hono, rest → TanStack Start
├── router.tsx # TanStack Router instance (wraps tree in Web3Provider)
├── routes/ # File-based routes (auto-generates routeTree.gen.ts)
│ ├── __root.tsx
│ ├── index.tsx # Landing page with on-chain Counter card
│ └── clients.tsx
├── components/
│ ├── ui/ # Shadcn primitives (do not edit manually)
│ ├── landing/ # Landing page sections
│ ├── navigation/ # App navigation (includes wallet connect button)
│ ├── theme/ # Theme provider / toggle
│ ├── clients/ # CRUD example
│ └── web3/ # ConnectButton, CounterCard (SSR-safe lazy live shells)
├── contracts/ # Generated — do not edit
│ ├── abis/Counter.ts # `as const` ABI
│ ├── addresses.ts # `as const` chainId → name → address
│ └── README.md
├── core/
│ ├── errors.ts # AppError, Result<T>, isUniqueViolation
│ ├── functions/ # TanStack server functions
│ └── middleware/ # Server-function middleware
├── db/
│ ├── setup.ts # initDatabase / getDb singleton
│ ├── index.ts # Public DB module API
│ ├── schema.ts # Re-exports all tables
│ ├── migrations/{dev,staging,production}/ # Per-env Drizzle migrations
│ ├── client/ # Domain: clients (table, queries, zod schema)
│ └── health/ # Domain: health check query
├── hono/
│ ├── factory.ts # Typed Hono factory with CF Bindings
│ ├── api.ts # Router mounting /api/health, /api/clients
│ └── api/{health,clients}.ts
├── integrations/
│ ├── tanstack-query/ # QueryClient + SSR provider
│ └── web3/ # Web3Provider (lazy WagmiProvider + ConnectKit)
├── lib/
│ ├── utils.ts
│ └── web3/ # chains, wagmi-config, contract-address, useCounter, wallet-ready-context
└── styles.css # Tailwind v4 entry
Path alias @/* resolves to src/*.
| Layer | Technology |
|---|---|
| Framework | TanStack Start (Router + Query SSR) |
| UI | React 19, Shadcn/UI (new-york, Zinc), Tailwind CSS v4, Lucide |
| API | Hono on Cloudflare Workers |
| Runtime | Cloudflare Workers (nodejs_compat) |
| Wallet / Web3 | wagmi 2 + viem 2 + ConnectKit |
| Smart contracts | Solidity 0.8.28, Foundry, Soldeer (OpenZeppelin, forge-std) |
| Database | Neon Postgres + Drizzle ORM (neon-http) |
| Validation | Zod 4 |
| Forms | TanStack Form |
| Language | TypeScript (strict) |
| Linter | Biome 2 |
| Testing | Vitest + Testing Library + jsdom |
| Dead-code detection | knip |
| Release | semantic-release |
| Package manager | pnpm 10 |
The on-chain stack is structured as deep modules, with the wagmi/ConnectKit provider isolated behind a tiny SSR-safe shell so the worker bundle stays lean and hydration is always correct.
src/integrations/web3/root-provider.tsx mounts a placeholder QueryClientProvider during SSR and the first client render, then lazy-loads the real WalletProvider (WagmiProvider + ConnectKitProvider) once useEffect confirms we're on the client. Components that need wallet APIs gate themselves on a WalletReadyContext flag, falling back to a placeholder until the provider is up.
// src/integrations/web3/root-provider.tsx
const WalletProvider = lazy(() => import("./wallet-provider"));
export function Web3Provider({ children, queryClient }: Web3ProviderProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const queryShell = <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
if (!mounted) return queryShell;
return (
<Suspense fallback={queryShell}>
<WalletProvider queryClient={queryClient}>{children}</WalletProvider>
</Suspense>
);
}Web3Provider is wired into the router in src/router.tsx via the Wrap option, so every route gets it for free.
Supported chains live in a single registry (src/lib/web3/chains.ts). The active chain comes from VITE_CHAIN_ID and feeds both wagmi transports and contract reads.
// src/lib/web3/chains.ts
const SUPPORTED: Record<number, Chain> = {
1: mainnet,
11155111: sepolia,
31337: anvil,
};
export const activeChain: Chain = resolveChain(Number(import.meta.env.VITE_CHAIN_ID ?? 31337));Add a chain by extending SUPPORTED. createWagmiConfig in src/lib/web3/wagmi-config.ts builds an http() transport per supported chain and pulls the WalletConnect project id from VITE_WALLETCONNECT_PROJECT_ID.
pnpm contracts:typegen reads Foundry artifacts and the per-chain deployment registries, then writes:
src/contracts/abis/<Name>.ts—as constABI for every user contract incontracts/src/.src/contracts/addresses.ts—as constmappingchainId → name → address, sourced fromcontracts/deployments/{chainId}.json.
Both files are gitignored — never edit them by hand. pnpm build runs contracts:build and contracts:typegen automatically via prebuild, so the bundle always ships fresh bindings.
A typical wagmi hook over the generated bindings — read + write + transaction watcher in one place:
// src/lib/web3/use-counter.ts
export function useCounter(): UseCounterResult {
const address = getContractAddress(activeChain.id, "Counter");
const { isConnected } = useAccount();
const { writeContract, data: txHash, isPending: isWritePending } = useWriteContract();
const { isLoading: isConfirming, isSuccess: isConfirmed } = useWaitForTransactionReceipt({ hash: txHash });
const { data, isLoading, refetch } = useReadContract({
abi: counterAbi,
address,
functionName: "get",
chainId: activeChain.id,
});
useEffect(() => { if (isConfirmed) refetch(); }, [isConfirmed, refetch]);
const increment = () => {
if (!address) return;
writeContract({ abi: counterAbi, address, functionName: "increment", chainId: activeChain.id });
};
return { value: data, isLoading, hasAddress: Boolean(address), isConnected, isInFlight: isWritePending || isConfirming, increment };
}UI components (src/components/web3/connect-button.tsx, counter-card.tsx) use the same lazy-shell pattern as Web3Provider: a static placeholder for SSR, a lazy() "live" component once WalletReadyContext flips. This keeps the wallet runtime out of the SSR HTML and avoids hydration mismatches.
The contracts/ folder is a self-contained Foundry project. Soldeer manages dependencies (OpenZeppelin, forge-std), pnpm contracts:typegen generates as const ABIs + typed addresses into src/contracts/, and Solidity deploy scripts in contracts/script/ write deployed addresses to contracts/deployments/{chainId}.json.
RPC endpoints come from [rpc_endpoints] in foundry.toml:
[rpc_endpoints]
local = "http://127.0.0.1:8545"
testnet = "${TESTNET_RPC_URL}"
mainnet = "${MAINNET_RPC_URL}"Set TESTNET_RPC_URL / MAINNET_RPC_URL in your shell or .dev.vars before deploying. The local profile points at anvil and is the smoke-test path.
# Local — preferred: orchestrated end-to-end
pnpm contracts:dev
# anvil :8545 → DeployCounter → typegen, anvil stays in foreground
# Local — manual
anvil --silent &
pnpm contracts:deploy:local
# → contracts/deployments/31337.json now contains {"Counter":"0x.."}
pnpm contracts:typegen
# Testnet / mainnet — supply your signer
TESTNET_RPC_URL=https://... pnpm contracts:deploy:testnet --private-key $DEPLOYER_PRIVATE_KEY
MAINNET_RPC_URL=https://... pnpm contracts:deploy:mainnet --private-key $DEPLOYER_PRIVATE_KEYAny flags after the script name (--private-key, --account <keystore>, --ledger, --verify, …) are forwarded to forge script. The local script bakes in anvil's well-known dev key — never use it on a real chain.
The deploy script delegates to DeploymentRegistry.record(path, name, address), which:
- reads
contracts/deployments/{chainId}.jsonif it exists, - preserves entries for other contracts,
- overwrites the entry for the redeployed contract,
- creates the parent directory if missing.
Run pnpm contracts:typegen after a deploy to refresh src/contracts/addresses.ts with the new addresses (or just use pnpm contracts:dev, which does it for you).
- Use
wrangler.jsonc(not.toml) for configuration. varsis committed — only put non-secret config here. DB credentials and any other secrets must be set viawrangler secret put(see Secrets & Environments below).- Prefer
custom_domain: trueover routes withzone_name— see.claude/rules/cloudflare-deployment.md. - Run
pnpm cf-typegenwhenever you add bindings to regenerateworker-configuration.d.ts.
One fetch handler owns the entire worker: it boots the DB once per isolate (only when DATABASE_HOST is set) and dispatches to Hono or TanStack Start.
import handler from "@tanstack/react-start/server-entry";
import { initDatabase } from "@/db";
import { apiHono } from "@/hono/api";
export default {
fetch(request: Request, env: Env, ctx: ExecutionContext) {
if (env.DATABASE_HOST) {
initDatabase({
host: env.DATABASE_HOST,
username: env.DATABASE_USERNAME,
password: env.DATABASE_PASSWORD,
});
}
const url = new URL(request.url);
if (url.pathname.startsWith("/api/")) {
return apiHono.fetch(request, env, ctx);
}
return handler.fetch(request, { context: { fromFetch: true } });
},
};You can extend this handler with Queue consumers, scheduled events, or Durable Object bindings as your project grows.
Local dev — worker secrets live in .dev.vars (gitignored, copied from .example.vars):
# .dev.vars
CLOUDFLARE_ENV=dev
DATABASE_HOST="ep-xxx.region.aws.neon.tech/neondb?sslmode=require"
DATABASE_USERNAME="neondb_owner"
DATABASE_PASSWORD="npg_xxx"Staging / production — never commit DB credentials to wrangler.jsonc vars (plaintext, visible in dashboard). Set them as Cloudflare secrets via wrangler secret put, per environment:
# Run once per env to bootstrap each secret. Wrangler prompts for the value.
wrangler secret put DATABASE_HOST --env staging
wrangler secret put DATABASE_USERNAME --env staging
wrangler secret put DATABASE_PASSWORD --env staging
wrangler secret put DATABASE_HOST --env production
wrangler secret put DATABASE_USERNAME --env production
wrangler secret put DATABASE_PASSWORD --env productionEquivalently via Dashboard: Workers & Pages → your worker → Settings → Variables and Secrets → Add → type Secret. Never use type Plaintext for credentials.
Vite-side variables (VITE_*) belong in .env / .env.<mode> because they're inlined into the browser bundle — they're public by construction, so never put secrets there.
The DB layer is optional — leave DATABASE_HOST empty and the worker skips initialization. When you do need persistence, the DB module follows the deep-modules pattern: every domain has its own folder with a narrow public API.
src/db/client/
├── table.ts # pgTable definition
├── schema.ts # Zod schemas for input/output
├── queries.ts # getClients, getClient, createClient, updateClient, deleteClient
└── index.ts # Public re-exports
initDatabase()is called once per Worker isolate fromsrc/server.ts.- Every query calls
getDb()— never pass the DB as a parameter. - Inputs are validated with Zod at the API boundary; mutations use
.returning()to avoid extra round trips.
Each environment has its own Drizzle config (drizzle-{env}.config.ts) and migration directory (src/db/migrations/{env}/).
# 1. Edit your table definition in src/db/{domain}/table.ts
# 2. Generate a migration for the target environment
pnpm db:generate:dev
pnpm db:generate:staging
pnpm db:generate:production
# 3. Apply it
pnpm db:migrate:dev
pnpm db:migrate:staging
pnpm db:migrate:production
# Pull schema from an existing database
pnpm db:pull:dev
# Seed sample data
pnpm db:seed:dev
# Inspect data
pnpm db:studioPer-env configs (drizzle-dev.config.ts, drizzle-staging.config.ts, drizzle-production.config.ts) all point at src/db/schema.ts but write migrations to separate directories, allowing independent migration tracking per environment.
All /api/* routes are handled by Hono. Endpoints live in src/hono/api/ and are mounted in src/hono/api.ts.
// src/hono/api/clients.ts
import { isUniqueViolation } from "@/core/errors";
import {
ClientCreateRequestSchema,
createClient,
getClients,
PaginationRequestSchema,
} from "@/db/client";
import { createHono } from "@/hono/factory";
const clientsEndpoint = createHono();
clientsEndpoint.get("/", async (c) => {
const parsed = PaginationRequestSchema.safeParse({
limit: c.req.query("limit"),
offset: c.req.query("offset"),
});
if (!parsed.success) return c.json({ error: parsed.error.message }, 400);
return c.json(await getClients(parsed.data));
});
clientsEndpoint.post("/", async (c) => {
const parsed = ClientCreateRequestSchema.safeParse(await c.req.json());
if (!parsed.success) return c.json({ error: parsed.error.message }, 400);
try {
return c.json(await createClient(parsed.data), 201);
} catch (err) {
if (isUniqueViolation(err)) return c.json({ error: "Email already exists" }, 409);
throw err;
}
});
export default clientsEndpoint;// src/hono/api.ts
import { createHono } from "./factory";
import clientsEndpoint from "@/hono/api/clients";
import healthEndpoint from "@/hono/api/health";
export const apiHono = createHono().basePath("/api");
apiHono.route("/health", healthEndpoint);
apiHono.route("/clients", clientsEndpoint);The createHono() factory types Bindings: Env so c.env is fully typed against your Cloudflare configuration.
| Use Hono REST APIs | Use TanStack Server Functions |
|---|---|
| Public APIs for external clients | Server logic called from React |
| Webhooks | Form submissions |
| Third-party integrations | Data fetching for UI |
| Anything with a URL contract | Type-safe client↔server calls |
Error infrastructure lives in src/core/errors.ts:
export class AppError extends Error {
constructor(
message: string,
public code: ErrorCode,
public status: number = 500,
public field?: string,
) { super(message); this.name = "AppError"; }
}
export type Result<T> = { ok: true; data: T } | { ok: false; error: AppError };
export function isUniqueViolation(error: unknown): boolean { /* ... */ }- Use
AppErrorfor known, recoverable failures. - Use
Result<T>when a caller needs to branch on success/failure without throwing. - Check
error.cause.code(noterror.message) when inspecting Drizzle errors — the raw Postgres code lives oncause.isUniqueViolation()is the idiomatic way to detect23505conflicts. - Unexpected errors propagate to the Hono global
onErrorhandler.
See .claude/rules/error-handling.md for the full convention.
Server functions run exclusively on the server with full type safety across the boundary:
// src/core/middleware/example-middleware.ts
export const exampleMiddleware = createMiddleware({ type: "function" }).server(
async ({ next }) => next({ context: { data: "Context from middleware" } }),
);
// src/core/functions/example-functions.ts
const ExampleInputSchema = z.object({ exampleKey: z.string().min(1) });
export const exampleFunction = createServerFn()
.middleware([exampleMiddleware])
.inputValidator((data: z.infer<typeof ExampleInputSchema>) =>
ExampleInputSchema.parse(data),
)
.handler(async (ctx) => {
// ctx.data — validated input
// ctx.context — middleware context
return "Server response";
});Call them from components via TanStack Query:
import { useMutation } from "@tanstack/react-query";
import { exampleFunction } from "@/core/functions/example-functions";
function MyComponent() {
const mutation = useMutation({ mutationFn: exampleFunction });
return (
<button
onClick={() => mutation.mutate({ exampleKey: "Hello Server!" })}
disabled={mutation.isPending}
>
{mutation.isPending ? "Loading..." : "Call Server Function"}
</button>
);
}SSR hydration is wired up in src/integrations/tanstack-query/ — loaders can prefetch into the query cache and it streams down with the HTML.
- File-based routing — add files to
src/routes/, the tree auto-generates torouteTree.gen.tson dev/build. Never edit the generated file. - Root layout —
src/routes/__root.tsx. - Router wrapper —
src/router.tsxwraps the tree inWeb3Provider, so wallet hooks work in any route. - Shadcn/UI — add components with
pnpx shadcn@latest add <component>. Configured viacomponents.json(new-york style, Zinc base, CSS variables). - Tailwind v4 — configured through the
@tailwindcss/viteplugin, no separate config file. Styles entrypoint:src/styles.css.
pnpm test # run once
pnpm test:watch # watch mode
pnpm test:coverage # v8 coverage- Tests live next to source as
*.test.ts/*.test.tsx. - Vitest globals are enabled — no need to import
describe/it/expect. - Route files (
src/routes/**) are excluded from test discovery. - Test at module boundaries (exported queries, HTTP requests, user interactions, wagmi hook outputs), not internals. See
.claude/rules/deep-modules.md.
This template is set up for agent-assisted development:
.claude/CLAUDE.md— project-wide instructions..claude/rules/— topic rules (general.md,deep-modules.md,error-handling.md,atomic-imports.md,cloudflare-deployment.md, plus stack-specific rules underdb/,api/, andfrontend/) that activate automatically based on the files being edited.AGENTS.md— agent workflow guide./docs— single source of truth for business requirements / design docs.
- TanStack Start — full-stack React framework
- TanStack Router — type-safe routing
- TanStack Query — server state management
- Hono — fast web framework for APIs
- wagmi — React Hooks for Ethereum
- viem — TypeScript interface for Ethereum
- ConnectKit — wallet connection UI
- Foundry — Solidity dev toolchain
- Soldeer — Solidity package manager
- Drizzle ORM — type-safe SQL
- Neon — serverless Postgres
- Cloudflare Workers — edge computing platform
- Shadcn/UI — component library
- Tailwind CSS — utility-first CSS
- Biome — fast formatter and linter
Open source under the MIT License.

{ "$schema": "node_modules/wrangler/config-schema.json", "name": "tanstack-start-app", "compatibility_date": "2025-09-02", "compatibility_flags": ["nodejs_compat"], "main": "./src/server.ts", "vars": { "CLOUDFLARE_ENV": "dev" } }