DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our next.config.ts configures nothing, and its only comment describes a mechanism we never used

Here is the entire next.config.ts for Notifio's website, a Next 16 application with 16 pages, a Stripe checkout, three Google Fonts, structured data on every route and a presigned download endpoint:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  // Stripe webhooks need the raw body: disable body parsing for that route
  // handled per-route via export const config
};

export default nextConfig;
Enter fullscreen mode Exit fullscreen mode

An empty object with a type annotation and a comment. I think the empty object is a genuinely good sign about a Next.js codebase, and I think the comment is a small and instructive mistake. Both are worth a post.

What is not in there, and why

Run down what a config file on a project this shape usually accumulates:

images.remotePatterns. Not needed, because exactly one component imports next/image and the file it points at is in public/. Every other image on the site is an inline SVG or a static asset, and the only generated image is an Open Graph card produced ahead of time by a script rather than at request time.

Font configuration. Three families are loaded, Space_Grotesk and DM_Sans through next/font/google and GeistMono from its package, all declared in the root layout with display: "swap" and preload: true. next/font downloads them at build time and self hosts them, so there is no external stylesheet to allowlist and nothing for the config to say.

redirects and rewrites. There is exactly one piece of request-level logic on the whole site, and it is not in the config. It is in src/proxy.ts, which is what Next 16 calls the file that used to be middleware.ts. That file decides what happens during a maintenance window, and it is the subject of Our maintenance switch is 14 lines, and the interesting part is the three API routes it refuses to turn off. Request-level behaviour belongs in a file that can run code, not in a static table.

headers. Nothing to add. The site sets no custom security headers in the config, which is a gap I am aware of rather than a decision I am proud of, and the honest reason is that the hosting platform supplies the ones that matter most and nobody has sat down to tighten the rest.

Rendering behaviour. This is the interesting absence. A global setting here would be the wrong tool, because the site is deliberately split: seven page files are force-static and three are force-dynamic, and the thing deciding which is whether a price appears on screen. That split has a post of its own at Three of our pages render per request and thirty-five do not, and the only thing deciding that is whether a price is on screen. The declaration sits at the top of each page, three lines from the metadata it belongs with, where somebody editing that page will see it.

That is the pattern across all of these. The App Router moved almost everything that used to be global configuration down to the file it affects, and the result is that a healthy next.config.ts on a modern project is close to empty. If yours is long, it is worth asking which entries are there because they have to be global and which are there because that is where the Pages Router made you put them.

The comment, and the mechanism it describes

// Stripe webhooks need the raw body: disable body parsing for that route
// handled per-route via export const config
Enter fullscreen mode Exit fullscreen mode

The first clause is true and important. Stripe signs the exact bytes it sent, so verifying a webhook means hashing the raw body. If a framework has already parsed that body into an object and you re-serialize it, the signature fails, usually with key ordering or whitespace as the culprit, and the debugging session that follows is unpleasant.

The second clause describes the Pages Router. In pages/api, Next parses bodies for you by default and you opt out with:

export const config = { api: { bodyParser: false } };
Enter fullscreen mode Exit fullscreen mode

In the App Router there is nothing to opt out of. A route handler gets a standard Request, and nothing reads the body until you ask it to. Our webhook asks for it as text:

export async function POST(req: NextRequest) {
  const rawBody = await req.text();
  const sig = req.headers.get("stripe-signature");
  // ...
  event = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET!);
Enter fullscreen mode Exit fullscreen mode

That is the whole technique. req.text() gives you the bytes as they arrived, and constructEvent verifies against them. export const config appears nowhere in this project. I checked across every route and page file: the only route-level declarations are export const dynamic, thirteen of them, which is a different thing entirely.

So the one piece of guidance in the config file points at an API that does not exist in the router the project uses. It is harmless, which is exactly why it survived: it is a comment in an empty object in a file nobody opens. It is also the kind of thing that costs somebody an hour one day, when they read it, believe it, and go looking for the export const config it promises them.

The fix is to delete it, or better, to move the true half of it to where it matters, as a line above await req.text() saying why the body is read as text and not JSON. A comment explaining a non-obvious line belongs next to the line.

Why an empty config file is worth keeping

There is an argument for deleting the file entirely, since Next does not require it. I keep it for two reasons.

The first is the type annotation. const nextConfig: NextConfig means that the moment somebody does need to add something, the editor tells them what the options are and the compiler catches a misspelt key. A config added later to a file with no types is a config where experimental typos fail silently.

The second is that an empty file with a type is a statement. It says somebody looked, and the answer was nothing. A missing file says nobody has thought about it yet. Those read identically to the framework and very differently to a person.

If you want to see the result of all these decisions living somewhere other than a config file, the statically rendered pages are the ones like the alerts hub and the guides, and the per-request ones are the three with a price on them, starting with pricing. The same derivation discipline shows up in the sitemap, which is 38 URLs with only ten of them typed out by hand, and which I wrote about at Our sitemap is 38 URLs, and only ten of them are typed out by hand.

Top comments (0)