Audkit
Guides

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

app/api/keys/route.ts
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:

FieldSource
ipAddressx-vercel-forwarded-for, else x-forwarded-for (first entry), else x-real-ip
userAgentuser-agent
requestIdx-request-id
context.geoVercel's x-vercel-ip-* headers: country, region, city, continent, timezone, postal code, latitude, longitude
context.vercelx-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 present

Server Actions

Same config, different context — resolvers see args, result, error, and status:

app/actions.ts
"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:

FieldRequiredNotes
actionyesThrows if it resolves empty.
actoryesThrows if it resolves empty.
target, risk, metadata, contextnoOmitted from the event when they resolve nullish.
statusnoResolved first; overrides the derived default.
requestId, sessionId, ipAddress, userAgentno
agentId, model, toolName, toolCallId, approvalId, policyVersionno
inputHash, outputHashno

Plus the transport and failure options:

OptionDefaultNotes
clientReuse an existing Audkit instance.
apiKey, baseUrlAUDKIT_API_KEY, AUDKIT_BASE_URL
signingKeyId, signingPrivateKeyAUDKIT_SIGNING_KEY_ID, AUDKIT_SIGNING_PRIVATE_KEYSee BYOK signing.
failClosedfalseThrow 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.

  • 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.

On this page