Next.js Webpack to Turbopack: Port Your next.config

Next.js 16 made Turbopack the default for both next dev and next build, and a next.config that still exports a webpack() function now fails the production build rather than quietly ignoring it. The fix is to move loaders into turbopack.rules, aliases and fallbacks into turbopack.resolveAlias, delete anything that was a webpack plugin, and pass --webpack only for the cases that genuinely cannot be ported.

The rest of this post is the detail: what maps one to one, what changes shape, and what has no equivalent. Everything here is written against the Next.js 16.4 docs as of October 2026: the Turbopack reference, the turbopack config page and the version 16 upgrade guide.

Why does next build fail after upgrading to Next.js 16?

Because the upgrade guide says it should. If Next.js finds a custom webpack key in your config and you run next build without a flag, the build stops on purpose, on the grounds that silently discarding a config people have maintained for years is worse than an error. Turbopack never reads the webpack() function. It sits in the unsupported list alongside webpack plugins, Yarn PnP and experimental.urlImports.

There is a trap in the error. If you are sure you never wrote a webpack() function, a dependency probably did: wrapper packages that take your config and return a new one often compose a webpack key onto it, and the guide calls this out. Grep node_modules for config.webpack before you assume the error is wrong.

You have three ways out, and they are not equal. next build --turbopack ignores the config, which is only sensible if the function did nothing you still need. next build --webpack puts you back on the old bundler, and Next.js’s own advice is to use Turbopack for both dev and build and to say why on the opt-out discussion thread if you cannot. Or you port the config, which is what the rest of this post assumes.

One housekeeping step first. experimental.turbo became a top-level turbopack key in 15.3, and experimental.turbopack moved to the top level in 16. The upgrade codemod handles the rename:

npx @next/codemod@canary upgrade latest

How do webpack loaders map to turbopack.rules?

Most of a typical webpack() function is module.rules entries that wire up a loader. Turbopack runs webpack loaders through the same loader-runner library webpack uses, so the loader package itself usually works unchanged. What changes is where and how you declare it.

Here is the kind of config that fails the build, followed by its Turbopack equivalent:

// next.config.ts, before: fails `next build` on Next.js 16
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  webpack(config) {
    config.module.rules.push({
      test: /\.svg$/,
      use: [{ loader: '@svgr/webpack', options: { icon: true } }],
    })
    config.resolve.alias['@legacy'] = './src/legacy'
    config.resolve.fallback = { ...config.resolve.fallback, fs: false }
    return config
  },
}

export default nextConfig
// next.config.ts, after
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  turbopack: {
    rules: {
      '*.svg': {
        condition: { not: 'foreign' },
        loaders: [{ loader: '@svgr/webpack', options: { icon: true } }],
        as: '*.js',
      },
    },
    resolveAlias: {
      '@legacy': './src/legacy',
      fs: { browser: './empty.ts' },
    },
  },
}

export default nextConfig

The key is a glob, not a RegExp. A glob without a / matches on file name; one containing / matches the full project-relative path. The as: '*.js' field tells Turbopack what the loader produces, and it is required because the rule system only supports loaders that return JavaScript.

The condition field arrived in 16.0 and replaces webpack’s include and exclude. foreign is the built-in name for node_modules and Next.js internals, so { not: 'foreign' } is the usual guard, and it keeps the loader off thousands of files it would otherwise touch. The other built-ins are browser, development, production and node; combine them with all, any and not, or match on path, content and query.

Loader options must survive JSON. Turbopack evaluates loaders in a Node.js process it controls, so an option that is a require()d plugin instance or a function cannot cross that boundary.

Since 16.2 you can often skip the loader entirely. type: 'raw' returns a file’s contents as a string, type: 'bytes' as a Uint8Array, and type: 'asset' emits the file and returns its URL, which covers most of what raw-loader and asset/resource rules were doing.

What replaces resolve.alias and resolve.fallback?

Both become turbopack.resolveAlias, and the shape is nearly identical to resolve.alias: a map from an import specifier to the module that should load instead. Glob keys work, which is how the docs suggest handling Sass’s legacy tilde imports if you cannot edit them: '~*': '*'.

resolve.fallback does not exist as such. Its common use was fs: false, to stop a Node-only import breaking a client bundle, and the Turbopack version is the conditional alias shown above, fs: { browser: './empty.ts' }, pointing at an empty module. browser is currently the only condition resolveAlias supports. The upgrade guide is blunt that this silences a real problem rather than fixing it; the better move is to stop client code importing the module at all.

resolve.extensions becomes turbopack.resolveExtensions, with one catch: it replaces the default list rather than extending it. The defaults are .tsx, .ts, .jsx, .js, .mjs and .json, with .node added on the server, so if you add .mdx you must list all of those too. tsconfig.json paths and baseUrl are read automatically and need no alias.

Which webpack config has no Turbopack equivalent?

Plugins. Turbopack has no plugin API and the docs say so plainly: anything that integrated through new SomePlugin() needs a Turbopack-aware alternative from its maintainer or it stays on webpack. Check the package’s changelog for a Next.js 16 note before you assume the worst.

Loaders that use the parts of the loader API Turbopack has not implemented. this.importModule, this.loadModule, this.emitFile and this.utils are unsupported, and this.fs only offers readFile. A loader that writes extra files to the output directory is the usual casualty.

sassOptions.functions. Custom Sass functions are JavaScript callbacks, and Turbopack’s Rust pipeline cannot call back into them. The docs tell you to use webpack if you need this.

Legacy CSS Modules syntax: standalone :local and :global, @value, and composes pulling from a plain .css file. Lightning CSS handles CSS under Turbopack and treats every .css file as global, so the fix is to rename the composed file to .module.css.

For any of these, the opt-out looks like this, and dev can stay on Turbopack while build does not:

{
  "scripts": {
    "dev": "next dev",
    "build": "next build --webpack",
    "start": "next start"
  }
}

Running two bundlers is a maintenance cost of its own. Treat it as a dated exception, not a destination.

What changes even when the config ports cleanly?

A green build is not the end of it. The docs list behaviour differences that only show up at runtime.

CSS Modules are ordered strictly by JavaScript import order under Turbopack. Webpack usually does the same but will reorder when it decides a module is side-effect free, so a project that accidentally relied on that will see rules win that used to lose. The fix is an explicit @import or a change to the conflicting selectors.

Lightning CSS rounds numeric values to five decimal places where webpack kept ten, so a line-height computed from a Sass division comes out as 1.47059 instead of 1.4705882353.

Babel is now picked up automatically if a config file exists. Under webpack, a Babel config turned SWC off. Under Turbopack, SWC still does Next.js’s own transforms and Babel runs on top for your source files, which is a slowdown you did not ask for. If the only reason a .babelrc exists is a plugin you dropped years ago, delete the file.

Module resolution stops at the project root, which Next.js infers from the nearest lockfile, so packages linked from outside it with npm link are not resolved by default. turbopack.root widens the root; experimental.turbopackAdditionalRoots, added in 16.4, follows one specific symlink out of it instead.

Finally, the filesystem cache is on by default for next build as well as next dev. On CI that does not preserve .next/cache between runs, set experimental.turbopackFileSystemCacheForBuild: false so the build is not paying to write a cache it will never read.

What I would do

Port the config in one sitting, not incrementally. Delete the webpack() function, write the turbopack block, run next build with no flags, and fix what breaks. Most of these functions hold a handful of rules, and some are loaders Turbopack already covers natively: CSS, PostCSS, Sass, JSON and static images need no rule at all.

Reach for --webpack only when a plugin you cannot replace or a Sass function you cannot inline is in the way, write down which one, and revisit it at the next minor. The same discipline applies to the proxy.ts rename in the same release, which I covered in the middleware to proxy post.

Whoooop upgrades existing Next.js applications for a living, including the ones where the webpack() function has grown a second page. If yours is blocking a move to 16, our Next.js development service covers the audit and the port.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch