omiid
homenotebookai usage

Logging route handler responses in Next.js 14

June 19, 2024 · Updated on August 09, 2026

Next.js middleware cannot log response bodies. It runs before the route handler, so by the time a response exists the middleware has already returned. That rules out the Express.js pattern, where morgan or express-request-logger sit in the middleware chain and see both sides of the request. In Next.js 14 you get the same request logging by wrapping the route handler itself.

I write short, practical notes like this one. Get the next one by email:

Unsubscribe anytime.

Wrap the route handler in a higher-order function

A higher-order function takes the route handler, runs it, and returns the same response after logging. Call it withLogging. It logs the request details and the response body, and it leaves the response stream accessible and unchanged.

import { NextRequest, NextResponse } from "next/server";
 
const withLogging = (handler: (req: NextRequest) => Promise<NextResponse>) => {
	return async (req: NextRequest) => {
		// Call the handler and get the response
		const res = await handler(req);
 
		// Clone the response to avoid locking the stream
		const cloneResponse = res.clone();
 
		// Extract and log the response body
		const outputBody = await cloneResponse.text();
 
		// Create log items
		const logItems = [
			req.method,
			req.nextUrl.toString(),
			res.status,
			outputBody,
		];
 
		// Log the request and response details
		console.log(logItems.join(" - "));
 
		return res;
	};
};

Next, wrap the route handler with withLogging. The POST handler below returns NextResponse.json, the standard route handler response in Next.js 14:

import { NextRequest, NextResponse } from "next/server";
 
export const POST = withLogging(async (req: NextRequest) => {
	return NextResponse.json({ message: "Hello, World!" });
});

When a POST request is made to this endpoint, the console will log something like this:

POST - http://localhost:3000/api - 200 - {"message":"Hello, World!"}

Clone the response or the stream locks

In Next.js, the body of the response is a ReadableStream. Once you read from the stream it becomes locked, and Next.js throws when it tries to use it again. Cloning the response lets you read the body without locking the original stream, so the handler's response is returned untouched.

Here’s the relevant error you might encounter without cloning:

Error: failed to pipe response
 
[cause]: TypeError [ERR_INVALID_STATE]: Invalid state: The ReadableStream is locked
 

Cloning the response and then accessing its body ensures that the response remains in a valid state, especially for more complex responses like redirects or rewrites.

Cloning costs a small amount of performance

Cloning the response and reading the body adds work to every request. The cost is small, and the extra visibility during debugging is usually worth it.

Log only in development

To control logging by environment, check process.env and apply the wrapper conditionally. Detailed logs stay in development, and production logs stay clean.

const withLogging = (handler: (req: NextRequest) => Promise<NextResponse>) => {
	return async (req: NextRequest) => {
		if (process.env.NODE_ENV === "development") {
			const res = await handler(req);
			const cloneResponse = res.clone();
 
			const outputBody = await cloneResponse.text();
 
			const logItems = [
				req.method,
				req.nextUrl.toString(),
				res.status,
				outputBody,
			];
 
			console.log(logItems.join(" - "));
 
			return res;
		} else {
			return handler(req);
		}
	};
};

This setup ensures that logging can be adjusted according to the environment, minimizing unnecessary overhead in production.

Add fields to logItems, but not secrets

You can add more details to the logItems array: headers, timing, a request id. Leave out tokens, passwords, and anything else that should not sit in a log file.

That is the whole setup. If you want structured output instead of a line of text, swap console.log for a Next.js logger such as pino or winston; the wrapper around the route handler does not change.

Join My Newsletter

Occasional notes on software, tools, and things I learn. No spam.

Unsubscribe anytime.

Continue Reading
  • Tuning Postgres and pgvector: the three knobs that matter08-18-2026 · Most pgvector performance problems come down to three settings. This post shows how to read an ANN query plan and tune ef_search, shared_buffers, and work_mem in the right order.
  • AI text watermarking: how it works and what it can't do08-16-2026 · Claude now watermarks its text. The watermark changes where the randomness in word choice comes from, not what the model can say. Here is the whole pipeline, with simulations you can poke at.
  • HNSW vs IVFFlat: choosing and building your pgvector index08-14-2026 · Past a few hundred thousand rows, an exact scan stops being fast enough. Here is how to pick between HNSW and IVFFlat and build the index without locking the table.
  • Vector search relevance: chunking, metadata, and the 0.81 problem08-11-2026 · Most bad vector search results come from one of three failure modes: chunking, modality mismatch, or a confused model. Each one has a specific diagnostic and a specific fix.
  • pgvector setup: your first multimodal query in TypeScript08-03-2026 · One Postgres table can hold text and screenshot embeddings in the same vector column. This post sets up the schema, the Voyage embedding call, and the first query that returns both.