Next.js
withAuditLogging() for route handlers and withAuditAction() for Server Actions — field resolvers, redirect-as-success, and fail-closed behaviour.
audkit/nextjs gives you two wrappers over the places App Router mutations actually
live. Both share one config surface: every audit field is either a literal value or a
(possibly async) function of what happened.
Neither wrapper imports from next. withAuditLogging is written against web-standard
Request/Response, and Next.js control flow is detected structurally — so the module
works in any runtime and never pins a Next version.
Route handlers
import { withAuditLogging } from "audkit/nextjs";
export const POST = withAuditLogging(
{
action: "api_key.created",
risk: "medium",
actor: async ({ request }) => {
const session = await auth.api.getSession({ headers: request.headers });
return { type: "user", id: session!.user.id, display: session!.user.email };
},
target: ({ result }) => ({ type: "api_key", id: result.apiKey.id }),
metadata: ({ result }) => ({ scopes: result.apiKey.scopes }),
},
async (request) => {
return createApiKey(await request.json());
},
);What the handler may return
Return a Response and it is passed through untouched. Return anything else and it is
serialized as a JSON Response (status 200), or 204 for undefined — and the raw value
is also handed to your resolvers as result, which is usually what you want to build
target and metadata from.
Resolver context
type NextAuditResolverContext = {
request: Request; // a fresh clone — safe to read
routeContext?: TRouteContext; // the second handler arg (route params)
result?: TResult; // set when the handler did NOT return a Response
response?: Response; // a clone of the final response, when available
error?: unknown; // what the handler threw
status: EventStatus;
requestJson: () => Promise<unknown>;
requestText: () => Promise<string | undefined>;
responseJson: () => Promise<unknown>;
responseText: () => Promise<string | undefined>;
};Bodies are safe to read. request is a clone per resolver, the response reader works off
a clone, and requestJson() / responseJson() memoize — so your original streams are
never consumed and reading twice costs nothing.
Clones are always base `Request`
clone() does not preserve the NextRequest subclass, so resolvers receive a plain
Request. Headers, URL, and body are all intact. Your handler still receives the
original, subclass and all.
responseJson() only parses bodies whose content-type is JSON, and returns undefined
for 204/205 or unparseable bodies rather than throwing.
Status
status defaults to success, and to failed when the handler throws or the response
status is ≥ 400. Override it with a status resolver — it is resolved first, so every
other resolver observes the final status.
Whatever the handler threw is rethrown after the event is written. The audit never swallows your error.
Auto-captured fields
Resolved values always win; these only fill gaps:
| Field | Source |
|---|---|
ipAddress | x-vercel-forwarded-for, else x-forwarded-for (first entry), else x-real-ip |
userAgent | user-agent |
requestId | x-request-id |
context.geo | Vercel's x-vercel-ip-* headers: country, region, city, continent, timezone, postal code, latitude, longitude |
context.vercel | x-vercel-id and x-vercel-deployment-url |
x-vercel-forwarded-for is preferred because, on Vercel, a proxy stacked in front cannot
overwrite it the way it can x-forwarded-for.
Set forwardVercelHeaders: false to opt out of the geo / vercel context entirely. Off
Vercel the headers are simply absent, so leaving it on is harmless. The extractor is also
exported on its own:
import { vercelRequestContext } from "audkit/nextjs";
const ctx = vercelRequestContext(request); // undefined when no headers are presentServer Actions
Same config, different context — resolvers see args, result, error, and status:
"use server";
import { withAuditAction } from "audkit/nextjs";
export const changeRole = withAuditAction(
{
action: "role.changed",
risk: "high",
actor: async () => {
const session = await getSession();
return { type: "user", id: session.user.id };
},
target: ({ args }) => ({ type: "member", id: args[0].memberId }),
metadata: ({ args, result }) => ({
to: args[0].role,
previous: result?.previousRole,
}),
},
async (input: { memberId: string; role: string }) => {
return updateRole(input);
},
);args is the full argument tuple, typed from the action signature.
redirect() is a success
Next.js implements redirect() by throwing. Naively wrapped, every successful
redirect-after-mutation would be logged as a failure.
withAuditAction detects the redirect structurally — the thrown value carries a digest
starting with NEXT_REDIRECT — and logs success: the action completed, then navigated.
notFound(), forbidden(), unauthorized(), and real errors log failed. In every case
the thrown value is rethrown unchanged so Next handles it normally.
export const deleteProject = withAuditAction(
{ action: "project.deleted", risk: "critical", actor, target },
async (id: string) => {
await projects.remove(id);
redirect("/dashboard"); // still logs status: "success"
},
);Shared config
Both wrappers take the same audit event fields:
| Field | Required | Notes |
|---|---|---|
action | yes | Throws if it resolves empty. |
actor | yes | Throws if it resolves empty. |
target, risk, metadata, context | no | Omitted from the event when they resolve nullish. |
status | no | Resolved first; overrides the derived default. |
requestId, sessionId, ipAddress, userAgent | no | |
agentId, model, toolName, toolCallId, approvalId, policyVersion | no | |
inputHash, outputHash | no |
Plus the transport and failure options:
| Option | Default | Notes |
|---|---|---|
client | — | Reuse an existing Audkit instance. |
apiKey, baseUrl | AUDKIT_API_KEY, AUDKIT_BASE_URL | |
signingKeyId, signingPrivateKey | AUDKIT_SIGNING_KEY_ID, AUDKIT_SIGNING_PRIVATE_KEY | See BYOK signing. |
failClosed | false | Throw audit failures instead of continuing. |
onAuditError | — | (error, input?) => void | Promise<void> |
Failing open vs. closed
By default the wrapped operation wins: if building or sending the audit event fails, the
route still responds and the action still returns. onAuditError is called so you can
report it.
{
failClosed: true,
onAuditError: (error, input) => reportToSentry(error, { input }),
}With failClosed: true, onAuditError runs first and then the audit error is thrown —
so an unrecorded mutation surfaces as a failure instead of disappearing. If
onAuditError itself throws under failClosed, that error is what propagates.
Define the config once, at module scope
The wrappers cache one Audkit client per config object. A module-level config gets one
client whose pinned tree head — and therefore its tamper-evidence — survives across
requests; a config rebuilt per request would re-pin from scratch every time and never
carry anything forward.
Related
- SDK reference — the client both wrappers write through.
- AI agent tools — the same resolver pattern for agent tool calls.
- Verification — proving the events these wrappers wrote are intact.
AI agent tools
auditedTool() wraps an AI SDK tool so every agent call enters the audit record — with risk labels, required reasons, and an authorization gate.
Verification
Signed receipts, local consistency proofs, customer-held signing keys, RFC 6962 inclusion and consistency proofs, the offline CLI, and Rekor anchoring.