Skip to main content
ASoc
Tutorial

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.

The ASoc Team9 min read

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:

SectionCode linesWhat it produces
import statements2createMDX, and the NextConfig type
supabaseOrigin IIFE7One CSP origin, derived from NEXT_PUBLIC_SUPABASE_URL
scriptSrcEval2'unsafe-eval' in dev only
contentSecurityPolicy1311 CSP directives, joined with ;
securityHeaders105 response headers
nextConfig5async headers() — the only option set
withMDX6The MDX compiler wrapper
export default withMDX(nextConfig)1The 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 uses next/image. Covers are pre-built WebP derivatives (-card.webp at 1060w, -view.webp at 1600w) resolved by src/lib/imageVariants.ts and rendered with plain <img>, so there is no request-time optimizer to configure and no remote pattern to allowlist. Why this repo skips next/image entirely 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 a webpack callback. Adding one would be silently ignored.
  • No env — NEXT_PUBLIC_* vars are read directly from process.env; the env key 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:

HeaderValueWhy this value
Content-Security-Policy11 directivesBlocks injected script and framing
X-Content-Type-OptionsnosniffNo MIME sniffing on downloads
Referrer-Policystrict-origin-when-cross-originOrigin only, cross-site
X-Frame-OptionsDENYOld-browser fallback for frame-ancestors
Strict-Transport-Securitymax-age=63072000; includeSubDomainsNo 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

NeedConfig?Where it actually lives here
Security headers on every responseYesheaders() in next.config.ts
MDX compilationYeswithMDX wrapper, string plugin names
Session refresh on every requestNosrc/proxy.ts (Next.js 16 renamed middleware)
Auth redirectsNoredirect() in layouts and pages
Per-route cache behaviourNoRoute segment config exports in the route file
Image sizingNoBuild-time WebP variants, src/lib/imageVariants.ts
Bundler tweaksNoNot 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

SymptomCauseFix
An MDX plugin has no effect; no error anywherePlugin passed as an imported function instead of a string, under TurbopackUse the module specifier: remarkPlugins: ["remark-gfm"]
Tables in MDX render as literal | pipesremark-gfm missing or silently droppedAs above — CommonMark has no table syntax
Every stray .mdx file became a public route"mdx" added to pageExtensionsRemove it; import posts from a registry instead
next build throws on new URL(...)An env var read in the config was unset at build timeWrap in try/catch with a safe fallback
Headers don't apply to the home pagesource: "/*" — matches the literal path /*Use /(.*) or /:path*
A webpack: (config) => ... callback is ignoredThe build runs Turbopack, which doesn't read itRemove it, or run the webpack builder deliberately
CSP blocks a script that worked in dev'unsafe-eval' is dev-only; dev HMR needed itExpected — don't add it to production to match
Config changes don't take effectThe config is read at server startRestart 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.

Keep reading

Tutorial11 min read

A Next.js Contact Form with Server Actions, Zod and Resend

No API route, no client fetch, and it still submits with JavaScript off. Validation, a honeypot, a rate limit that survives serverless, and the from-address trap that kills deliverability.

Read more
Tutorial10 min read

A Next.js Content Security Policy That Keeps Static Rendering

The documented nonce recipe turns every route it touches dynamic. The static-safe policy we ship instead, what 'unsafe-inline' really costs, and what the header still blocks.

Read more