Next.js
App Router route handlers on the Node runtime. The edge runtime is refused, not degraded.
The route below is compiled and served under a Node host by the docs' test suite, against the real @keyring/next.
import { getKeyring, withKeyring } from '@keyring/next';
const liveOrders = [{ id: 'ord_live_1', total: 4200 }];
const testOrders = [{ id: 'ord_test_1', total: 100 }];
export const runtime = 'nodejs';
const keyring = getKeyring({
resources: { orders: { live: liveOrders, test: testOrders } },
});
export const GET = withKeyring(
(_request, { keyring: context }) =>
Response.json({
tenant: context?.tenantId,
env: context?.env,
degraded: context?.degraded,
orders: context?.orders,
}),
{ instance: keyring },
);withKeyring wraps one App Router route handler: a Request in, a Response out, and the context as keyring on the second argument. Pages Router is not supported.
One client per process
getKeyring() returns a process-wide client held on globalThis, the same pattern as globalThis.prisma and for the same reason: next dev re-evaluates a route module on every save, and a client at module scope would be a new poller per edit with the old ones still holding their timers. Configure it in one place, a lib/keyring.ts that exports getKeyring({ ... }), and pass it as instance from each route. A later call with different options gets the existing instance and reports the mismatch through that call's onError.
The edge runtime, middleware.ts and proxy.ts
They cannot run this SDK. Verification is a poller, an LRU, a disk snapshot and an AsyncLocalStorage, none of which the edge runtime has, and the only edge-shaped alternative is a network call on every request, the one thing this architecture exists not to do. Every route that uses withKeyring needs export const runtime = 'nodejs'. Under the edge export conditions the package is a module that throws that instruction, so an export const runtime = 'edge' route fails next build naming the fix.
Serverless
A process that starts cold and serves one request never gets a policy snapshot in time, and under the default stale-then-open a store that has never loaded admits any well-formed key unverified. On a platform that gives you a fresh process per request, set onUnavailable: 'stale-then-closed' and accept the other cost: during an outage of the control plane a cold process refuses every request until its first successful poll. On next start, a container or a VM, the default is the right one, and the disk snapshot makes a restart serve from what it left.
Per-route options
export const POST = withKeyring(handler, {
onUnavailable: 'closed',
scopes: ['write:payments'],
});
export const GET = withKeyring(handler, { skip: true });A rule passed here replaces the routes table's rule for the request; it does not merge. Put everything a route needs in one place. A dynamic route can pass route: '/api/orders/[id]' so usage and any routes rule see the pattern rather than a different concrete path per request.
next build needs the same environment
next build evaluates each route module, so the build environment needs KEYRING_SECRET_KEY, KEYRING_PROJECT_ID and KEYRING_BASE_URL as well. Construction makes no network call: the client detects the build phase and defers starting the poller to the process that serves requests, so a build worker never presents your secret key to the control plane. On Coolify that means the three stay build variables.
Rate limits and idempotency
Both are the SDK's, unchanged. Rate-limit headers are set on whatever Response your handler returned, including one proxied from fetch() whose headers are immutable. A request carrying Idempotency-Key has its body read from a clone before your handler runs, and its response buffered and stored before the bytes reach the caller.