Skip to content
Chapter 34Lesson 1

The typed next.config.ts

How Next.js reads next.config.ts, the typed project-level configuration file that shapes how your app is built and served before any route runs.

The day you scaffold a Next.js project, next.config.ts is almost empty: a couple of flags and an export. It looks like a file you’ll never think about again.

A year into a real SaaS, that same file looks different. It now declares the image domains your CDN serves from, holds a permanent redirect from a URL scheme you retired, carries a stack of security headers, and has one stubborn line for an SDK that refuses to bundle. It grew because the product grew, one concern at a time.

So every time you open the file, it quietly asks the question an experienced engineer asks of any config: what earns a line here, and what does each line cost? Some entries are free, like flags you turn on at the start and never reconsider. Others are levers with a price, and you only reach for them when something specific breaks.

The last two chapters lived inside the route tree: how routes render under Cache Components, and how a request flows through cookies(), headers(), and proxy.ts. This file is the layer that wraps all of that. It is the project-level surface, read once before any of your routes run. The rest of the chapter walks it one key at a time, covering images, redirects, and headers, so this opening lesson does two jobs. First, it gives you a one-screen map of everything the surface touches and which lesson owns each piece. Second, it teaches one entry in real depth: serverExternalPackages, the standard fix for an SDK that won’t bundle.

next.config.ts is a plain Node module. Turbopack loads it once when you start the dev server and once at build time, reads the single config object it exports, and that’s it. It never ships to the browser; it’s pure server-and-build-time configuration. Nothing in the file affects a user’s bundle. It shapes how your app is built and served.

Here’s the whole shape:

next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// config goes here
};
export default nextConfig;

That’s the canonical skeleton: a typed object literal, annotated with NextConfig, exported as the default. Every key you’ll meet in this lesson goes inside those braces.

That type annotation is the whole reason this course always writes the config in TypeScript. Without it, a config typo is invisible. Misspell a key, say cacheComponent instead of cacheComponents, and nothing complains; Next just doesn’t see the option and silently ignores it. You’ve changed nothing, and you have no way to tell why. With NextConfig annotating the object, that same typo becomes a red squiggle in your editor and an error at build time. The type turns “silently does nothing” into “won’t compile,” which is exactly the trade you want for a file this load-bearing. As a bonus, you also get autocomplete over the entire surface, so you can discover what’s configurable without leaving the editor.

A few rules here aren’t yours to choose. The file is always .ts, never .js or .mjs, because this is an ESM project and TypeScript is the form you write everything else in. Next even refuses .cts and .cjs outright, a hint that the file format isn’t a free decision. Don’t keep both a .js and a .ts config side by side either: Next picks one and the other silently does nothing, the same invisible failure one level up. Use import type for NextConfig rather than a plain import, because it’s a type-only import and the course’s TypeScript settings enforce that distinction. You’ll copy this file into every project, so it’s worth getting the small things right once.

Before any single key, here’s the map. This section isn’t here to teach the keys, since most of them have their own lessons. It’s here so you always know where a given concern lives. When you need to add an image domain or a redirect six weeks from now, you should be able to picture exactly which box of the file you’re reaching into.

The keys are grouped by kind, because the kind determines how you think about each one, and the kind usually maps to the lesson too.

The diagram below is the chapter’s table of contents drawn as a map, so skim it rather than studying it.

next.config.ts
Always-on flags
cacheComponentstypedRoutes
this lesson, below
Platform pipeline
images
next lesson
Edge routing rules
redirectsrewritesheaderstrailingSlash
later this chapter — security headers later still
Bundling levers
serverExternalPackagestranspilePackages
this lesson, in depth
experimental
staging area for unstable options
changes between releases — check before copying
Everything `next.config.ts` touches in a 2026 SaaS, and where this chapter teaches each piece.

There are five kinds, and they behave very differently. Always-on flags you set once and leave on; they’re scaffolding rather than decisions, and the next section covers both of them. Platform-pipeline config (images) tunes how Next processes your assets, and it’s the subject of the next lesson. Edge routing rules (redirects, rewrites, headers, trailingSlash) describe how Next handles URLs and responses before they reach your route handlers, and a later lesson in this chapter owns those. The one exception is the contents of headers(), the security baseline of CSP, HSTS, and the like, which gets its full treatment later still, when we harden the app before launch. Bundling levers are the conditional power tools, and serverExternalPackages is the one we’ll go deep on below.

That last box, experimental, deserves a warning right here. It’s the holding pen for options Next hasn’t stabilized yet, and its keys come and go between minor releases. An experimental block you copy from a blog post can simply stop existing after an upgrade, because the option gets promoted to a top-level key, renamed, or dropped. So never paste an experimental block without checking that it still exists in the version you’re on. That’s exactly what happened to the two flags we’re about to set: both used to live under experimental and are now plain top-level keys, though older tutorials still show the old location.

That’s the whole map. There are no deep dives in this section, because it’s the index. From here on we only open two of those boxes.

Two keys go on at the start of every new project in this course and stay on. They aren’t decisions you weigh per project; they’re defaults. Each still earns its place for a reason worth a quick look.

cacheComponents: true opts you into the Cache Components rendering model from an earlier chapter: routes become dynamic by default, and you cache deliberately with an explicit use cache where it pays off. That model is the whole subject of its own chapter, so the one thing to know here is operational. It’s a single flag, it’s on, and it stays on. We’re not re-teaching the model, only flipping its switch.

typedRoutes: true is the one worth seeing in action, because this small flag buys a real engineering safeguard for free. It takes every route in your app and turns the set of valid paths into a typed union, so the type system now knows that /dashboard is a real route and /dashbord isn’t. What makes that useful is where a broken link gets caught.

The two tabs below show the exact same line of code with the flag off and on.

<Link href="/dashbord">Dashboard</Link>

Compiles fine, breaks at runtime. href is just a string, so nothing checks the typo. The user discovers the dead link by clicking it and hitting a 404 in production, where you find out from an error report rather than an editor.

Same line, different moment of truth. Without the flag, a mistyped internal link is a runtime 404 your user finds. With it, it’s a build error you find, before the code is anywhere near production. Moving the failure from runtime to compile time is the entire value of the flag, and it costs you nothing but one line of config.

(<Link> is the App Router’s client-side navigation component you met in an earlier chapter. If it’s hazy, that’s fine: the only thing that matters here is that its href is what typedRoutes makes type-safe.)

Both flags now go into the config object, and it starts to accumulate:

next.config.ts
const nextConfig: NextConfig = {
cacheComponents: true,
typedRoutes: true,
};

When an SDK won’t bundle: serverExternalPackages

Section titled “When an SDK won’t bundle: serverExternalPackages”

This is the conditional power tool of the lesson. Everything so far has been a flag you turn on and forget. serverExternalPackages is the opposite: a line you add only when a specific failure shows up. What you need to learn is how to recognize that failure on sight and judge whether this is even the right fix. We’ll take it in order: the failure that triggers it, what the flag does and what it costs, then the rule for deciding when to reach for it.

Picture a normal afternoon. Your SaaS generates PDF invoices, so you install a vendor SDK that rasterizes documents. We’ll call it @acme/pdf-engine, a stand-in for the kind of native package real apps pull in, such as a PDF renderer, a barcode generator, or a native-crypto module. You import it in a route handler, run the dev server, hit the route, and it crashes. The error looks something like this:

Terminal window
Error: Could not load the native binding for @acme/pdf-engine
Cannot find module './pdf_engine.linux-x64.node'
Require stack:
- .next/server/chunks/[turbopack]/acme-pdf-engine.js
- .next/server/app/api/invoices/[id]/route.js

The exact wording varies by package. You’ll see “Could not load the native binding,” “Module not found,” “Cannot find module ’./something.node’,” and sometimes a raw MODULE_NOT_FOUND. What they share is the shape: at runtime, inside the .next build output, something the package needs alongside it can’t be found.

Here’s why it happens. Turbopack’s job is to take your Server Component or route-handler code, along with the packages it imports, and bundle them: trace every import, pull the relevant code together, and emit the files the server runs. That works beautifully for ordinary JavaScript, where every dependency is statically visible in the import graph. It breaks for two kinds of package. The first depends on a native binding , a .node file of compiled machine code that the bundler can’t read, rewrite, or fold into a JavaScript chunk, and can only load at runtime. The second reaches for its dependencies dynamically, through a require() computed at runtime instead of a static import the bundler can see ahead of time. Either way the bundler quietly loses the trail, and the missing piece surfaces as a crash when the code finally runs. You don’t need the bundler internals; the category is what matters. Native-binding and dynamic-require packages resist bundling.

serverExternalPackages is the escape hatch:

next.config.ts
const nextConfig: NextConfig = {
serverExternalPackages: ['@acme/pdf-engine'],
};

That line tells Next one thing: don’t bundle this package; leave it alone and require it natively at runtime. The package is opted out of Server Component bundling entirely. Instead of folding @acme/pdf-engine into a chunk, Next emits a thin require('@acme/pdf-engine') in the server output. Node’s ordinary module resolution then loads the real package from node_modules at runtime, native binding and all, exactly the way it would in a plain Node script. The package goes from “compiled into the bundle, broken” to “required from node_modules, working.”

The sequence below shows that relocation, so scrub through it.

next.config.ts // nothing yet
.next — server function
@acme/pdf-engine bundled
can't
reach
node_modules
@acme/pdf-engine + pdf_engine.node

The bundle holds a copy of the package, but the compiled .node binding stayed behind — so the function crashes when it runs.

Default: Turbopack bundles the package into the server function. The native binding it depends on doesn't come along, so the function crashes at runtime.
added serverExternalPackages: ['@acme/pdf-engine']
.next — server function
@acme/pdf-engine no longer bundled
node_modules
@acme/pdf-engine + pdf_engine.node

One line in next.config.ts opts the package out of bundling. The copy Turbopack was folding in is dropped.

Add the package to serverExternalPackages. Next stops bundling it.
in effect serverExternalPackages: ['@acme/pdf-engine']
.next — server function
require('@acme/pdf-engine') resolved at runtime
resolves
node_modules
@acme/pdf-engine + pdf_engine.node

Node loads the real package — native binding included — from node_modules, exactly as a plain Node script would. It works.

Now the function output holds only a thin require('@acme/pdf-engine'). At runtime Node resolves the real package — native binding included — from node_modules. It works.

The flag isn’t free, though, and this is where the judgment comes in. The package no longer gets bundled and tree-shaken with the rest of your server code, so the function output is a little larger. And because the require happens when the function runs rather than being inlined ahead of time, the cold start is marginally slower. When the SDK genuinely needs externalizing, that’s a fine trade: a slightly heavier function in exchange for a working SDK. When it doesn’t, you’ve paid the cost for nothing, which is exactly why the next part matters.

Here’s the part that trips people up, and the right habit for 2026. Next.js already ships with a large built-in list of packages it externalizes for you. Most of the popular Node SDKs a SaaS reaches for are already on it: @prisma/client, sharp, pg, mongodb, better-sqlite3, @aws-sdk/client-s3, puppeteer, and roughly eighty more. For any of those, you do nothing. You install the package, import it, and it works, because Next already knew it shouldn’t be bundled.

So the worked example matters precisely because @acme/pdf-engine is the rare package that isn’t on that list. That’s the only situation where you touch this config. The decision rule is a short procedure, and it runs in this order:

Should this package go in serverExternalPackages?

Two anti-patterns are worth naming, because both are common and both are costly. First, don’t externalize preemptively. Don’t add a package “to be safe” because you read somewhere that database drivers can be tricky. If it bundles fine, externalizing it only gives you a slower cold start for no reason, and if it’s a popular SDK, it’s probably on the default list anyway. Try it first, and configure only on a real failure.

Second, don’t use serverExternalPackages to silence a missing-dependency error. A package you forgot to install also crashes with “Cannot find module,” and externalizing it can look like it helps, because the error shape is similar. It doesn’t help: it just moves the same failure from build time to runtime, where it’s harder to diagnose. If the real problem is a package that isn’t installed, the fix is to install it, not to teach the bundler to skip it.

serverExternalPackages vs transpilePackages

Section titled “serverExternalPackages vs transpilePackages”

There’s a neighboring key that’s easy to confuse with this one, and naming the difference once will keep you from reaching for the wrong tool: transpilePackages. They live in the same part of the config and they both deal with how Next treats a dependency, but they solve opposite problems and push code in opposite directions.

serverExternalPackages is for a finished, compiled Node package that breaks when bundled, so you push it out of the bundle and require it at runtime. transpilePackages is for a raw, un-compiled package that Next must pull in and compile before it can bundle the rest of your app, typically one of your own monorepo packages shipped as plain .ts/.tsx that hasn’t been through a build step. One package is too finished to bundle; the other isn’t finished enough.

The exercise below asks you to classify a package by its symptom, which is exactly what you’ll do in real life.

Each package breaks Next's default bundling for a different reason. Sort it into the key that fixes it. Drag each item into the bucket it belongs to, then press Check.

serverExternalPackages Bundle out — require at runtime
transpilePackages Compile in before bundling
A native image-processing library with a .node binary
A database driver that does a dynamic require() at runtime
A vendor PDF SDK shipped with a compiled native addon
Your monorepo’s @acme/ui, shipped as raw .tsx
An internal @acme/utils package published as un-compiled TypeScript

Transitive dependencies are handled for you

Section titled “Transitive dependencies are handled for you”

One short forward-looking note, so an older pattern doesn’t trip you up when you read other people’s configs. When you externalize a package, you also want its dependencies externalized, because a native SDK usually pulls in helper packages of its own, and bundling those while externalizing the parent is its own kind of broken. In older Next.js versions you had to hand-list every one of those child dependencies, which was tedious and easy to get wrong.

As of Next.js 16.1, you don’t. When you put a package in serverExternalPackages, Turbopack externalizes its transitive dependencies automatically: you name the one package, and its dependency tree comes along. So if you find a config or a blog post that carefully lists out an SDK’s internal dependencies one by one, you’re looking at an obsolete pattern. One entry is enough now.

A real config edit does nothing until you restart

Section titled “A real config edit does nothing until you restart”

This one isn’t about any single key. It applies to every line in the file, and it costs people real time, so it gets its own section.

next.config.ts is read once, at server startup. If you edit the file while the dev server is running, the server keeps using the config it loaded when it booted, so your change sits on disk doing nothing. The symptom is frustrating precisely because it’s silent: you add a flag, save, refresh, and the behavior is unchanged, so you assume you got the config wrong and start second-guessing a line that was perfectly correct.

You’ll meet this same behavior again when we get to redirects: “I added a redirect and it doesn’t fire” is, nine times out of ten, a dev server still running the old config. Same file, same rule.

Let’s put it together. Here’s the config you’d actually ship for a new SaaS at this stage: small, with every line defensible. Step through it, and each note explains why that line earns its place.

import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
cacheComponents: true,
typedRoutes: true,
serverExternalPackages: ['@acme/pdf-engine'],
// Security headers (CSP, HSTS) go here once the hardening pass lands.
// async headers() { ... },
// images / redirects / rewrites get added here as the product grows.
};
export default nextConfig;

The typed import. It’s import type because the import is type-only, which the course’s TypeScript settings require. NextConfig is what turns a misspelled key into a build error instead of a silently ignored option, and it’s the reason the whole file is .ts.

import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
cacheComponents: true,
typedRoutes: true,
serverExternalPackages: ['@acme/pdf-engine'],
// Security headers (CSP, HSTS) go here once the hardening pass lands.
// async headers() { ... },
// images / redirects / rewrites get added here as the product grows.
};
export default nextConfig;

The two always-on flags. cacheComponents opts into the Cache Components rendering model, and typedRoutes makes a bad <Link href> a build error. Set once, left on, with no per-project thought.

import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
cacheComponents: true,
typedRoutes: true,
serverExternalPackages: ['@acme/pdf-engine'],
// Security headers (CSP, HSTS) go here once the hardening pass lands.
// async headers() { ... },
// images / redirects / rewrites get added here as the product grows.
};
export default nextConfig;

The single conditional entry. It’s here only because @acme/pdf-engine is native, crashed when bundled, and isn’t on Next’s default list. If any of those weren’t true, the line wouldn’t exist. Orange marks it as a lever that earns its cost.

import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
cacheComponents: true,
typedRoutes: true,
serverExternalPackages: ['@acme/pdf-engine'],
// Security headers (CSP, HSTS) go here once the hardening pass lands.
// async headers() { ... },
// images / redirects / rewrites get added here as the product grows.
};
export default nextConfig;

Signposts, not code. The commented headers() marks where the security baseline will land later, and the trailing comment marks the seams where the image config and routing rules from the next lessons will grow the file. The config stays honest about what it doesn’t do yet.

1 / 1

That’s the mental model to carry out of here. next.config.ts is a typed object, read once at startup, holding a few always-on flags plus a handful of conditional levers. Every lever you add carries a cost, so you add it only when a real failure forces your hand. The typed import is what keeps the whole thing honest.

Two quick checks on the two things this lesson leaned on most: the map, and the serverExternalPackages judgment call.

An SDK you just installed crashes at build with “Could not load the native binding.” What’s the right first move?

Check whether Next already externalizes it on its default list, and confirm it actually needs externalizing, before touching the config.
Add it to serverExternalPackages straight away — a native-binding crash is exactly what that key is for.
Move it to transpilePackages so Next compiles the package for you.
Set the package to unoptimized in the config to skip the bundling step.

You add typedRoutes: true to a next.config.ts that’s typed with NextConfig, save, and refresh the page in your already-running next dev — but a <Link href="/dashbord"> still compiles without a squiggle. Before you touch anything else, what’s the one thing to check?

Whether you’ve bounced the dev server since the edit — it’s still serving the config it parsed when it booted.
Whether the key name got mistyped, which would let Next ignore it without complaint.
Whether the file should be .mjs, since .ts configs can’t carry stable flags.
Whether typedRoutes still needs to be nested under an experimental block.

The most useful page to bookmark is the serverExternalPackages reference. It carries the live default list, which is the thing you’ll actually want to check before adding a package by hand.