Keyring

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.

On this page