Application & framework security

Structured logging in JSON for Next.js

Use Pino from instrumentation.ts on the Node runtime. next-logger v5 patches Next.js framework logs through the same hook; you do not need experimental.instrumentationHook on Next.js 15. List pino and pino-pretty in serverExternalPackages (the old name was serverComponentsExternalPackages). Log Arcjet decision IDs, not secrets.

5 min read
In short: Use Pino from instrumentation.ts on the Node runtime. next-logger v5 patches Next.js framework logs through the same hook; you do not need experimental.instrumentationHook on Next.js 15. List pino and pino-pretty in serverExternalPackages (the old name was serverComponentsExternalPackages). Log Arcjet decision IDs, not secrets.

How do you add structured JSON logging to Next.js?

Use Pino. Load it from the Instrumentation hook (instrumentation.ts) so the Node server and your Route Handlers share one logger. If you want Next.js's own console.log lines as JSON without changing call sites, use next-logger v5 on the same hook.

console.log works in development. In production you need one JSON object per line, with a level, a timestamp, and fields you can filter (request ID, user ID, module). That is what Grafana, Datadog, and CloudWatch expect.

Applies to Next.js 14 and 15 App Router on the Node.js runtime. Edge and the browser still need a different logger or a reduced field set. Pino uses Node streams and optional worker threads; it is the wrong default inside middleware that runs on the Edge runtime.

Start from one question: can you find every log line for a single request ID after a 500 or a 403? If the answer is no, you do not yet have structured logging. You have print statements.

When should you use next-logger v5?

Use next-logger v5 when you want Next.js framework logs (compilation, routing, errors the framework prints) as newline-delimited JSON, and you are fine with Pino (or Winston) as the backend.

Install next-logger 5.x and Pino:

Terminal window
npm install next-logger@5 pino

Create instrumentation.ts at the project root or in src/:

export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("pino");
await import("next-logger");
}
}

In Next.js 15 the Instrumentation hook is stable. Do not set experimental.instrumentationHook. That flag was required on older 14.x releases and can be removed.

next-logger v5 patches the Next.js logger through this hook. You do not need NODE_OPTIONS=-r next-logger. You also do not need to list next-logger in serverExternalPackages; that workaround was for v4 and early 14.2 bundling bugs.

This path still uses the Node.js runtime. It does not patch Edge.

How do you log with Pino from application code?

Use a dedicated Pino instance when you want child loggers, redaction, and explicit levels in Route Handlers and Server Actions. Install Pino and, for local development, pino-pretty:

Terminal window
npm install pino pino-pretty

Mark both as external so Next.js 15 does not bundle them. The old name was experimental.serverComponentsExternalPackages. In Next.js 15 the stable key is serverExternalPackages:

import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: ["pino", "pino-pretty"],
};
export default nextConfig;

Create lib/logger.ts:

import pino, { type Logger } from "pino";
export const logger: Logger =
process.env.NODE_ENV === "production"
? pino({ level: process.env.LOG_LEVEL ?? "warn" })
: pino({
transport: {
target: "pino-pretty",
options: { colorize: true },
},
level: process.env.LOG_LEVEL ?? "debug",
});

Create a child logger per module so every line carries a stable field:

import { logger } from "@/lib/logger";
const log = logger.child({ module: "billing" });
log.debug("called");
log.info({ invoiceId }, "issued invoice");

Pino puts the fields object first and the message second. Configure redact for paths such as req.headers.authorization, password, and cookie. Do not log raw tokens, password hashes, or full request bodies from a login route.

Add a request ID at the edge of the handler (incoming x-request-id if you trust the proxy, otherwise crypto.randomUUID()) and pass it into every child logger:

const log = logger.child({ module: "login", requestId });

Without that field you cannot join an Arcjet deny, a failed password check, and a downstream timeout. Prefer allowlisted fields over serializing the whole Request. Next.js request objects contain cookies and headers you do not want in a log store.

You can use next-logger v5 and a custom Pino instance together: the former formats framework output, the latter formats your application events.

Where should Pino be created?

Create the Pino instance in a server-only module and import it from Route Handlers, Server Actions, and instrumentation.ts if you need startup logs. Do not import it from a Client Component.

If you also initialize OpenTelemetry in register(), keep the NEXT_RUNTIME === "nodejs" guard so Edge evaluation does not load native addons.

Standalone output (output: "standalone") copies node_modules for externals. serverExternalPackages is what keeps Pino out of the webpack bundle and in that copy. The Docker guide uses standalone output; pair the two so container logs stay JSON.

How do you log Arcjet decisions?

Log the decision ID, conclusion, and reason. Do not log the whole request or any secret the rule saw. Attach the same request ID you already put on application logs so a 403 in the access log matches a deny in the security log.

import arcjet, { detectBot, shield, slidingWindow } from "@arcjet/next";
import { logger } from "@/lib/logger";
const log = logger.child({ module: "arcjet" });
const aj = arcjet({
key: process.env.ARCJET_KEY!,
rules: [
shield({ mode: "LIVE" }),
detectBot({ mode: "LIVE", allow: [] }),
slidingWindow({ mode: "LIVE", interval: 60, max: 60 }),
],
log: logger,
});
export async function POST(req: Request) {
const decision = await aj.protect(req);
log.info(
{
decisionId: decision.id,
conclusion: decision.conclusion,
denied: decision.isDenied(),
reason: {
rateLimit: decision.reason.isRateLimit(),
bot: decision.reason.isBot(),
shield: decision.reason.isShield(),
},
},
"arcjet decision",
);
if (decision.isDenied()) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
}

Passing log: logger into arcjet() sends SDK diagnostics through Pino at the level you configured. Application code still logs the fields you care about for alerts.

In DRY_RUN, the top-level conclusion is always allow. Iterate decision.results and log each rule that would have denied, or you will think the control is idle.

Which fields should you index?

Index level, time, module, decisionId, requestId, and a stable user or tenant ID (not an email if you can avoid it). Alert on a burst of conclusion=DENY on /api/login and on level=error in module=arcjet.

Keep login successes and failures in the same shape as the login page guide so brute-force and stuffing queries stay simple. The Next.js security checklist is the rest of the application baseline these logs support.

Frequently asked questions

How do you add JSON logging to Next.js?

Use Pino from the Instrumentation hook, or next-logger v5 to also reformat Next.js's own console and framework logs. Both run on the Node.js runtime, not Edge.

Do you still need experimental.instrumentationHook?

Not on Next.js 15. The Instrumentation hook is stable. next-logger v5 loads from instrumentation.ts.

What replaced serverComponentsExternalPackages?

serverExternalPackages in Next.js 15. Put pino and pino-pretty there so the bundler does not pack them.

Which next-logger version should you use?

next-logger v5 (5.0.x). It uses the Instrumentation hook and does not need the v4 serverComponentsExternalPackages workaround.

What should you log from an Arcjet decision?

decision.id, conclusion, and reason flags (rate limit, bot, shield). In DRY_RUN, iterate decision.results. Do not log passwords, tokens, or full bodies.

Application security in your code

Protect your application with Arcjet

Get rate limits, bot detection, and attack blocking in your request handlers.