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
- github.com/remorses/server-actions-for-next-pages, same thing as server actions but works in both pages and app directory, supports concurrency
- github.com/remorses/next-superjson, plugin to support non JSON serializable values like
Date
ingetServerSideProps
and friends.
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.
javascript// 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:- If you return jsx code you will have pass
as
equal to*.tsx
to your loader
- You have to use a glob instead of a regex
- Options must be serialiazable to JSON
- To run a loader differently on server, client, app, pages, etc you will have to use
conditions
javascript/** * @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.javascriptfunction 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:
- The sourcemap passed down is a json string and not an object, could change in the future
- You have to return the sourcemap as a json string and not an object
- Remember to call
callback(error)
when there is an error or it will be difficult to debug any issues
- If you use babel remember to pass the input sourcemap so sourcemaps will be correct in case there are multiple loaders
javascript// 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
.