Migrate Next.js plugins to Turbopack with this weird little trick

How to make Next.js plugins work with Turbopack, the new Next.js --turbo option. No need to rewrite them in Rust!
profile photo
I have written 3 plugins that I use often in my Next.js based applications:
  • github.com/remorses/elacca, a plugin to decrease the size of your Next.js app when loaded in the server, essentially skipping the SSR pass

What we start with: Webpack rules

My plugins essentially consist of this code, which injects a Webpack rule that transforms and processes your Next.js files.
// plugin.ts export function plugin(config: PluginOptions = {}) { return (nextConfig: NextConfig = {}): NextConfig => { return { ...nextConfig, webpack(config: webpack.Configuration, options) { config.module = config.module || {}; config.module.rules = config.module.rules || []; config.module.rules.push({ test: /\.(tsx|ts|js|mjs|jsx)$/, use: [ options.defaultLoaders.babel, { loader: require.resolve("babel-loader"), options: { sourceMaps: dev, plugins: myBabelPlugins, }, }, ], }); if (typeof nextConfig.webpack === "function") { return nextConfig.webpack(config, options); } else { return config; } }, }; }; }

How to extend Next.js when using --turbo

When using Turbopack Next.js will ignore your custom webpack function.
Instead you can pass an experimental.turbo.rules object to implement the same thing, with a few differences:
  1. If you return jsx code you will have pass as equal to *.tsx to your loader
  1. You have to use a glob instead of a regex
  1. Options must be serialiazable to JSON
  1. To run a loader differently on server, client, app, pages, etc you will have to use conditions
/** * @type {import('next').NextConfig} */ const nextConfig = { experimental: { turbo: { rules: { "./pages/**/*.tsx": { as: "*.tsx", // tells turbopack to process file as tsx loaders: [require.resolve("./test-file-loader.js")], }, }, }, }, };

Writing a plugin that injects Turbopack rules

We can inject these turbo rules in the Next.js config when the plugin is called
Notice that you can pass conditions (named similarly to package.json export conditions) to run the loader differently if it has been called on client and server. There are more conditions available, you can find them in the Next.js codebase.
function applyTurbopackOptions(nextConfig: NextConfig): void { nextConfig.experimental ??= {}; nextConfig.experimental.turbo ??= {}; nextConfig.experimental.turbo.rules ??= {}; const rules = nextConfig.experimental.turbo.rules; const pagesDir = findPagesDir(process.cwd()); const options = { pagesDir }; const glob = "{./src/pages,./pages/}/**/*.{ts,tsx,js,jsx}"; rules[glob] ??= {}; const globbed: any = rules[glob]; globbed.browser ??= {}; globbed.browser.as = "*.tsx"; globbed.browser.loaders ??= []; globbed.browser.loaders.push({ loader: require.resolve("../dist/turbopackLoader"), options: { ...options, isServer: false }, }); globbed.default ??= {}; globbed.default.as = "*.tsx"; globbed.default.loaders ??= []; globbed.default.loaders.push({ loader: require.resolve("../dist/turbopackLoader"), options: { ...options, isServer: true }, }); }
Notice that the code assumes there is another loader defined on the same glob, this is required to make it work in case multiple plugins want to add loaders on the same glob

Writing the Turbopack loader

The turbopack loader has the same API as Webpack, you have to export a default function that takes as arguments the file code and calls a callback with the processed code and the output sourcemap.
Some things to notice when wiring the Turbopack loader:
  1. The sourcemap passed down is a json string and not an object, could change in the future
  1. You have to return the sourcemap as a json string and not an object
  1. Remember to call callback(error) when there is an error or it will be difficult to debug any issues
  1. If you use babel remember to pass the input sourcemap so sourcemaps will be correct in case there are multiple loaders
// turbopackLoader.ts import type webpack from "webpack"; import { transform } from "@babel/core"; import { plugins } from "."; import { logger } from "./utils"; export default async function ( this: LoaderThis<any>, source: string, map: any ) { if (typeof map === "string") { map = JSON.parse(map); } const callback = this.async(); try { const options = this.getOptions(); const { isServer, pagesDir } = options; if (shouldBeSkipped({ filePath: this.resourcePath || "", pagesDir })) { callback(null, source, map); return; } const res = transform(source || "", { babelrc: false, sourceType: "module", plugins: plugins({ isServer, pagesDir }) as any, filename: this.resourcePath, inputSourceMap: map, sourceMaps: true, }); callback(null, res?.code || "", JSON.stringify(res.map) || undefined); } catch (e: any) { logger.error(e); callback(e); } }

Will this make my Next.js app slow?

Your javascript plugin will be called only for files that match the glob, if your glob is strict enough the performance degradation will be negligible.
In this case my plugins only run for files in the pages directory, these files are usually less than 1% of your overall javascript code and in development they are loaded only 1 at a time.
Even if you can’t use a strict glob you can still skip the loader execution.

Why not write a Rust SWC plugin instead?

If you already have a javascript plugin that only runs for a subset of your files that uses Babel or other javascript packages it makes no sense to make them run 20ms faster by rewriting them in Rust.
This method allows you to use javascript to process some files while keeping most of the workload fast via turbopack.

Can I remove the webpack function from the next.config.js now?

If you migrate an existing plugin you must leave the webpack function and add the additional turbo.rules object, you can share the babel plugins between the two.

But why write a loader when I just want to transform a file?

Turbopack is written in Rust, this means it cannot efficiently interact with javascript the same way Webpack and Rollup do, instead you can process a file at the start of the chain for a specific glob pattern.
This is similar to how Esbuild plugins work, where there are only onLoad and onResolve hooks and not onTranform.
Related posts
post image
In this post i will show some tricks to decrease your Next.js functions cold starts
post image
This guide shows how you can use Holocron to let contributors suggest edits to your Markdown based website (like Docusaurus or Nextra) using an easy to use WYSIWYG editor.
post image
A list of my favourite Framer templates for landing page websites, free and paid. For digital businesses and startups.
Powered by Notaku