@usebetterdev/audit-next
v0.8.1
Published
Next.js App Router adapter for [`@usebetterdev/audit-core`](../core). Extracts actor identity from incoming requests and propagates it via `AsyncLocalStorage` so that every audit log captured during a request automatically includes the correct actor.
Readme
@usebetterdev/audit-next
Next.js App Router adapter for @usebetterdev/audit-core. Extracts actor identity from incoming requests and propagates it via AsyncLocalStorage so that every audit log captured during a request automatically includes the correct actor.
Installation
pnpm add @usebetterdev/audit-next @usebetterdev/audit-coreRequires next >= 14.
How context propagation works in Next.js
Next.js App Router has three distinct execution contexts, each requiring a different approach:
| Context | Has Request? | ALS propagates? | Use |
|---|---|---|---|
| Edge Middleware (middleware.ts) | Yes | No — separate isolate | createAuditMiddleware |
| Route Handler (route.ts) | Yes | Yes | withAuditRoute |
| Server Action | No | Yes | withAudit |
Edge Middleware runs in a separate V8 isolate from route handlers. AsyncLocalStorage set in middleware does not carry over. Instead, createAuditMiddleware extracts the actor from the request and forwards it as a request header (x-better-audit-actor-id by default). withAuditRoute then reads that header and sets up ALS for the handler's execution.
Quick start
Option A — Middleware + route handlers (recommended for APIs)
// middleware.ts
import { createAuditMiddleware } from "@usebetterdev/audit-next";
export default createAuditMiddleware();
export const config = { matcher: "/api/:path*" };// app/api/orders/route.ts
import { withAuditRoute, fromHeader, AUDIT_ACTOR_HEADER, getAuditContext } from "@usebetterdev/audit-next";
import { audit } from "@/lib/audit";
async function handler(request: NextRequest) {
const ctx = getAuditContext(); // { actorId: "user-123" }
await audit.captureLog({ tableName: "orders", operation: "INSERT", recordId: "ord-1", after: {} });
return Response.json({ ok: true });
}
export const POST = withAuditRoute(handler, {
extractor: { actor: fromHeader(AUDIT_ACTOR_HEADER) },
});Use
AUDIT_ACTOR_HEADERto keep the header name in sync between middleware and route handlers without hardcoding strings.
Option B — Route handler only (no middleware)
If you don't use Next.js middleware, withAuditRoute can extract directly from the request:
// app/api/orders/route.ts
import { withAuditRoute } from "@usebetterdev/audit-next";
export const POST = withAuditRoute(handler);
// Reads `sub` from Authorization: Bearer <jwt> by defaultOption C — Server actions
// app/actions.ts
"use server";
import { withAudit, getAuditContext } from "@usebetterdev/audit-next";
import { audit } from "@/lib/audit";
export const createOrder = withAudit(async (formData: FormData) => {
const ctx = getAuditContext(); // { actorId: "user-123" }
await audit.captureLog({ tableName: "orders", operation: "INSERT", recordId: "ord-1", after: {} });
});Server actions don't receive a Request object. withAudit reads all request headers via next/headers and constructs a synthetic request to run the extractor against.
Actor extraction
All three wrappers use a ContextExtractor to resolve the actor. The default extracts the sub claim from Authorization: Bearer <jwt>. The token is decoded without signature verification — that is the auth layer's responsibility.
Custom JWT claim
import { fromBearerToken } from "@usebetterdev/audit-next";
withAuditRoute(handler, {
extractor: { actor: fromBearerToken("user_id") },
});Header-based extraction
Common when running behind an API gateway that forwards identity as a plain header:
import { fromHeader } from "@usebetterdev/audit-next";
withAuditRoute(handler, {
extractor: { actor: fromHeader("x-user-id") },
});Cookie-based extraction
import { fromCookie } from "@usebetterdev/audit-next";
withAudit(action, {
extractor: { actor: fromCookie("session_id") },
});Custom extractor
Write any async function that receives a Request and returns a string or undefined:
withAuditRoute(handler, {
extractor: {
actor: async (request) => {
const key = request.headers.get("x-api-key");
if (!key) return undefined;
const owner = await resolveApiKeyOwner(key);
return owner?.id;
},
},
});Error handling
All wrappers fail open — if extraction fails, the request or action proceeds without audit context. No request is ever blocked by the audit layer.
Use onError to observe failures:
createAuditMiddleware({
onError: (error) => console.error("Audit extraction failed:", error),
});Security note
createAuditMiddleware always overwrites the actor header on the forwarded request — including when extraction fails (sets it to ""). This prevents clients from spoofing the actor identity by sending the header directly. Do not trust x-better-audit-actor-id on incoming requests unless it was set by your middleware.
API
createAuditMiddleware(options?)
Creates a Next.js edge middleware function. Extracts actor from the request and forwards it as a request header to downstream route handlers.
betterAuditNext(options?)
Convenience alias for createAuditMiddleware. Use as the default export in middleware.ts.
withAuditRoute(handler, options?)
Wraps an App Router route handler. Extracts actor from the incoming NextRequest and runs the handler inside an ALS scope.
withAudit(action, options?)
Wraps a server action. Reads headers via next/headers, extracts actor, and runs the action inside an ALS scope.
Options (all wrappers):
| Option | Type | Description |
|---|---|---|
| extractor | ContextExtractor | Actor extractor config. Defaults to JWT sub claim. |
| onError | (error: unknown) => void | Called when extraction throws. Defaults to no-op. |
Additional option for createAuditMiddleware:
| Option | Type | Description |
|---|---|---|
| actorHeader | string | Header name for forwarding the actor id. Defaults to AUDIT_ACTOR_HEADER. |
AUDIT_ACTOR_HEADER
The default header name ("x-better-audit-actor-id") used to forward the actor id from middleware to route handlers. Import this constant in both files to avoid hardcoding the string.
getAuditContext()
Returns the current AuditContext from ALS, or undefined when called outside a request scope. Re-exported from @usebetterdev/audit-core for convenience.
