Add a paid route to Next.js
Charge per call for a Next.js App Router route handler with @tollstile/next, from the test rail to x402 with a Postgres ledger.
Goal: GET /api/reports/[id] in a Next.js App Router app costs $0.01 per call, with the payment settled before the response is returned.
Prerequisites
- A Next.js App Router app (route handlers in
app/**/route.ts). - For production: a PostgreSQL database reachable from your deployment, and the x402 prerequisites from Monetize an API with x402.
npm install tollstile @tollstile/next@tollstile/next has no dependency on next; it wraps Web-standard route handlers.
Runnable example in the repository: examples/nextjs.
1. Create one instance
import { createTollstile, memoryLedger, testRail } from "tollstile";
export const toll = createTollstile({ rails: [testRail()], ledger: memoryLedger() });2. Wrap the route handler
import { paid } from "@tollstile/next";
import { toll } from "@/lib/toll";
export const GET = paid(
toll.price("$0.01", { resource: "GET /api/reports/[id]" }),
async (request, { params, payment }) => {
const { id } = await params;
return Response.json({ id, paidWith: payment.via });
},
);paid(gate, handler, options?) returns a route handler. Your handler receives the request and { params, payment }.
Name the resource on routes with dynamic segments. Without it, the resource is "<METHOD> <pathname>", so every /api/reports/42 becomes its own resource in your ledger and limits.
3. Call it
npm run dev
curl -i localhost:3000/api/reports/42 # 402 Payment Required, signed quote in the body
curl -i -H "Payment: test" localhost:3000/api/reports/42 # 200 OKHTTP/1.1 200 OK
payment-receipt: test_settlement_chg_…
{"id":"42","paidWith":"rail"}Run next build as well: it type-checks the exported GET.
4. Go to production
memoryLedger() lives in one process. On serverless deployments each invocation may run in a different instance, so use a database ledger. With Postgres on Neon's WebSocket Pool:
npm install @tollstile/x402 @tollstile/postgres @neondatabase/serverlessimport { Pool } from "@neondatabase/serverless";
import { postgresLedger } from "@tollstile/postgres";
import { x402 } from "@tollstile/x402";
import { createTollstile } from "tollstile";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const ledger = postgresLedger({
query: (sql, params) => pool.query(sql, params),
transaction: async (work) => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await work((sql, params) => client.query(sql, params));
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
},
});
export const toll = createTollstile({
rails: [
x402({
network: "eip155:84532",
payTo: process.env.PAY_TO!,
denomination: "USD",
rpcUrl: process.env.RPC_URL!,
}),
],
ledger,
secret: process.env.TOLLSTILE_SECRET!, // 32+ random characters, the same in every instance
});Apply the schema once, as a migration: see Applying the schema.
Every instance must share secret, or a quote issued by one instance fails in another.
Run reconciliation on a schedule from a route your scheduler (for example, Vercel Cron) calls:
import { toll } from "@/lib/toll";
export async function GET(request: Request) {
if (request.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response("Unauthorized", { status: 401 });
}
return Response.json(await toll.reconcile());
}Verify
- With the test rail, the two
curlcommands return402with aquotein the body, then200with apayment-receiptheader. - With x402,
curl -ireturns402with aPAYMENT-REQUIREDheader. Pay with the reference client script and check thattollstile_chargeshas one row insettled.
Verification status
The Next.js adapter is tested by calling the exported handler the way Next.js does, including type assignability for static, dynamic, and catch-all routes. It has not been run inside a Next.js application. The x402 rail and the Neon adapter have not been verified against live services.
When things fail
| Handler result | Payment |
|---|---|
A response below 400 | Settled; receipt headers added to a copy of the response |
A response of 400 or above | Released (or refunded on the upfront flow) |
| Throws or rejects | Released, then the error is rethrown to Next.js |
| Settlement rejected | The body is cancelled and a fresh 402 with error code settlement_rejected is returned |
| Settlement outcome unknown | The response is returned without a receipt; reconciliation resolves the charge |
redirect() and notFound() from next/navigation work by throwing, so they count as failures. Return NextResponse.redirect() when a redirect is the paid result.
Next
Retries
Send Idempotency-Key on the first paid request and keep it on retries of the same request (on MCP: _meta["tollstile/idempotency-key"]). A completed charge returns 409 already_paid; an in-flight charge returns 409 request_in_progress; an unknown outcome returns 503 payment_outcome_unknown. Do not start a new payment while the outcome is unknown. Released/refunded attempts may run again. See Idempotency for request matching and rail-specific key scopes.
Accept x402 payments in Express
Charge per request in USDC on an Express 5 API with @tollstile/express and the x402 rail, starting from the test rail.
Charge per call on Cloudflare Workers
Price a Cloudflare Worker with @tollstile/fetch, keep the ledger in D1 with @tollstile/sqlite, and reconcile from a cron trigger.