Next.js Config: 121 Lines That Set Exactly One Option
What belongs in next.config.ts and what doesn't, from a real storefront's 121-line config — 11 CSP directives, and the MDX plugin Turbopack silently drops.
next.config.ts is where Next.js reads build- and routing-level settings that your application code can't express: response headers, redirects, image domains, bundler options. This storefront's config is 121 lines long and sets exactly one option — headers(). The other 68 lines are comments explaining what deliberately isn't there.
The short answer
Next.js looks for next.config.js, .mjs, or .ts in the project root and expects a default export of a config object (or a function returning one). It is evaluated once, in Node, before any request — which is both what makes it the right place for static routing rules and the reason most things people try to put there don't belong there. Everything request-dependent belongs in proxy.ts, a layout, or a Route Handler instead.
The whole file, by line type
121 lines total
68 comment
46 code
7 blank
A config that is 56% comment is not a documentation habit — it's a symptom of what this file actually accumulates. Almost every line of code here is a decision that looks wrong until you know why it was made, and two of them silently break if you change them in the obvious direction. The comments are load-bearing.
What the 46 lines of code do:
| Section | Code lines | What it produces |
|---|---|---|
import statements | 2 | createMDX, and the NextConfig type |
supabaseOrigin IIFE | 7 | One CSP origin, derived from NEXT_PUBLIC_SUPABASE_URL |
scriptSrcEval | 2 | 'unsafe-eval' in dev only |
contentSecurityPolicy | 13 | 11 CSP directives, joined with ; |
securityHeaders | 10 | 5 response headers |
nextConfig | 5 | async headers() — the only option set |
withMDX | 6 | The MDX compiler wrapper |
export default withMDX(nextConfig) | 1 | The wrapped config |
The config object itself is five lines
// next.config.ts
const nextConfig: NextConfig = {
// `.mdx` is NOT added to pageExtensions on purpose: blog posts are imported
// as modules from the typed registry in src/data/blog.ts, not routed
// file-by-file. Adding it here would make every stray .mdx a public route.
async headers() {
return [{ source: "/(.*)", headers: securityHeaders }];
},
};
export default withMDX(nextConfig);
No images, no webpack, no env, no redirects, no rewrites, no experimental, no output. That's on a commercial storefront with 111 products, 289 blog posts and 7 category hubs, running Next.js 16.2.9 and React 19.2.4. Each absence is a real decision:
- No
images— nothing here usesnext/image. Covers are pre-built WebP derivatives (-card.webpat 1060w,-view.webpat 1600w) resolved bysrc/lib/imageVariants.tsand rendered with plain<img>, so there is no request-time optimizer to configure and no remote pattern to allowlist. Why this repo skipsnext/imageentirely is its own post. - No
redirects— all thirteen redirects in this app are conditional on whether you're signed in, which doesn't exist when the config is evaluated. The three ways to redirect in Next.js covers why config-level redirects have no slot for "only if logged out." - No
webpack— the build runs Turbopack, which doesn't read awebpackcallback. Adding one would be silently ignored. - No
env—NEXT_PUBLIC_*vars are read directly fromprocess.env; theenvkey is a legacy inlining mechanism that predates that convention.
pageExtensions is the sharp one. Adding "mdx" to it is step one of nearly every "MDX blog in Next.js" tutorial, and here it would turn all 289 files under src/content/blog/ into public routes at whatever path they happen to sit on. The posts are imported as modules from a typed registry instead, so the extension stays out of the routing table.
headers() and the source pattern
async headers() {
return [{ source: "/(.*)", headers: securityHeaders }];
}
source is a path-to-regexp pattern, not a glob — which matters because the glob you'd reach for doesn't work here. Next's own docs are explicit that the wildcard goes after a parameter (/blog/:slug*), so /:path* is a catch-all and a bare /* is not one. /(.*) is the regex form of the same idea and what this repo uses. Headers set this way are applied by the Next.js routing layer to every response, including statically prerendered HTML, which is the property that makes it the right place for a CSP on a site that is almost entirely SSG.
The five headers it returns:
| Header | Value | Why this value |
|---|---|---|
Content-Security-Policy | 11 directives | Blocks injected script and framing |
X-Content-Type-Options | nosniff | No MIME sniffing on downloads |
Referrer-Policy | strict-origin-when-cross-origin | Origin only, cross-site |
X-Frame-Options | DENY | Old-browser fallback for frame-ancestors |
Strict-Transport-Security | max-age=63072000; includeSubDomains | No preload — see below |
preload is omitted on purpose. It only takes effect after manual submission to hstspreload.org and is hard to reverse, so it's a go-live step once every subdomain of the real domain is confirmed HTTPS-only — not a config default. max-age plus includeSubDomains is safe to ship now.
Config is build-time, so an env var in it needs a fallback
The CSP needs the Supabase origin in connect-src, and that origin differs per environment. The config can read process.env because it runs in Node — but it runs at build time, where the variable may be absent:
const supabaseOrigin = (() => {
try {
return new URL(process.env.NEXT_PUBLIC_SUPABASE_URL!).origin;
} catch {
return "https:";
}
})();
Without the try/catch, an unset variable makes new URL(undefined) throw and the build fails — not a request, the build. The fallback degrades the directive to https: rather than taking the whole deploy down. Any env var read in next.config.ts wants this shape, because the config has no request to fail gracefully on.
The same reasoning sets 'unsafe-eval' per environment:
const scriptSrcEval =
process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : "";
Turbopack's dev Fast Refresh evaluates modules via eval; next start is eval-free. Allowing it unconditionally would weaken the shipped policy to make HMR work locally.
The defect: Turbopack cannot receive a JavaScript function from this file
This is the one that cost real debugging time, and it's invisible — it builds clean and produces wrong output.
const withMDX = createMDX({
options: {
remarkPlugins: ["remark-gfm"],
rehypePlugins: ["rehype-slug"],
},
});
Plugins are named as strings. The documented-everywhere alternative — import remarkGfm from "remark-gfm" and pass remarkPlugins: [remarkGfm] — compiles, type-checks, starts the dev server, and silently drops the plugin. Turbopack runs the MDX pipeline in Rust and cannot be handed a JavaScript function reference across that boundary; it can only resolve a module specifier.
The symptom was that every comparison table in every post rendered as a paragraph of literal pipe characters. MDX defaults to CommonMark, which has no table syntax at all, so remark-gfm isn't a nicety here — it's the difference between a comparison post and a wall of | a | b |. rehype-slug has a quieter failure: no id on any heading, so every in-page anchor 404s and the scroll-mt-28 in src/mdx-components.tsx has nothing to act on.
There is no error for this. Nothing in next build, npm run lint, or TypeScript catches it, because passing a function to that option is perfectly valid under webpack. The only signal is the rendered output.
Comparison: what goes in the config vs. what goes next to the code
| Need | Config? | Where it actually lives here |
|---|---|---|
| Security headers on every response | Yes | headers() in next.config.ts |
| MDX compilation | Yes | withMDX wrapper, string plugin names |
| Session refresh on every request | No | src/proxy.ts (Next.js 16 renamed middleware) |
| Auth redirects | No | redirect() in layouts and pages |
| Per-route cache behaviour | No | Route segment config exports in the route file |
| Image sizing | No | Build-time WebP variants, src/lib/imageVariants.ts |
| Bundler tweaks | No | Not needed — Turbopack defaults |
The dividing line is request context. The config is evaluated once with no request in scope, so anything that has to know who is asking cannot be expressed there, however static the mapping looks.
Mistakes and how they show up
| Symptom | Cause | Fix |
|---|---|---|
| An MDX plugin has no effect; no error anywhere | Plugin passed as an imported function instead of a string, under Turbopack | Use the module specifier: remarkPlugins: ["remark-gfm"] |
Tables in MDX render as literal | pipes | remark-gfm missing or silently dropped | As above — CommonMark has no table syntax |
Every stray .mdx file became a public route | "mdx" added to pageExtensions | Remove it; import posts from a registry instead |
next build throws on new URL(...) | An env var read in the config was unset at build time | Wrap in try/catch with a safe fallback |
| Headers don't apply to the home page | source: "/*" — matches the literal path /* | Use /(.*) or /:path* |
A webpack: (config) => ... callback is ignored | The build runs Turbopack, which doesn't read it | Remove it, or run the webpack builder deliberately |
| CSP blocks a script that worked in dev | 'unsafe-eval' is dev-only; dev HMR needed it | Expected — don't add it to production to match |
| Config changes don't take effect | The config is read at server start | Restart npm run dev; it is not hot-reloaded |
Frequently asked questions
Should next.config be .js, .mjs, or .ts?
.ts if your project is TypeScript — Next.js supports it natively and import type { NextConfig } from "next" gives you completion and type errors on option names, which is the main practical benefit. This repo uses next.config.ts for exactly that. The format does not change what you can configure.
Why doesn't my next.config.ts change apply?
Because it is evaluated once when the server starts, not per request or per file change. Fast Refresh does not cover it. Restart the dev server. This also applies to anything derived from it, including the CSP string above.
Can I read environment variables in next.config.ts?
Yes — it runs in Node, so process.env is available. But it runs at build time, so treat every variable as possibly missing and guard it. An unguarded new URL(process.env.SOMETHING!) turns a missing variable into a failed build rather than a degraded feature.
Is an almost-empty next.config.ts a sign something is misconfigured?
No. Options in this file exist to override framework defaults; if the defaults are right, the correct config is empty. The useful audit is the reverse — for each option you have set, can you state what breaks without it? Here that is one option, headers(), and the answer is the entire CSP.
Templates in this post
ASoc Nova, ASoc Pip and ASoc Quest are Next.js 16 + Tailwind v4 landing page templates that ship with this same config shape — security headers wired in headers(), no bundler overrides, and nothing in the file that request-time code should own instead.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
