Philip Snyder
September 23, 2026
·
Engineering
Writing good quality SDKs is harder than it seems, and writing one that plays nicely alongside everyone else’s is, as we discovered, even harder. Our code doesn’t run in isolation - sharing a build with other plugins, each making reasonable assumptions about what the others will do. When those assumptions don’t line up, tools can end up trampling each other without anyone noticing.
That’s what happened to one of our customers. They shipped a Next.js app with a green build and a clean deploy, while our SDK, the thing watching their app for production errors, was quietly running disabled. Nothing was broken in our code, in Next.js, or in the other plugin they were using but rather the underlying issue lived in the space between them. If you use more than one plugin in next.config, or you maintain one, the same thing can happen to you, and nothing will warn you. Here’s what we found and how to avoid it.
Your config can be an object or a function
If you haven’t used it before, next.config.ts is where you tell Next.js how to build and run your app: redirects, image settings, experimental features. Most apps export an object, like this one that turns on typedEnv for environment variable autocomplete:
12345const nextConfig = { experimental: { typedEnv: true },};
export default nextConfig;Next.js also accepts a function that returns those settings. Here’s the same example written both ways:
1234567const nextConfig = { experimental: { typedEnv: true, },};
export default nextConfig;1234567const nextConfig = () => ({ experimental: { typedEnv: true, },});
export default nextConfig;Both examples enable typedEnv. If you export a function, Next.js calls it to get the settings.
You can see this in the latest Next.js source:
1234567async function normalizeConfig(phase, config) { if (typeof config === "function") { config = config(phase, { defaultConfig }); } // Support `new Promise` and `async () =>` as return values of the config export return await config;}The two phases relevant here are PHASE_DEVELOPMENT_SERVER for next dev and PHASE_PRODUCTION_BUILD for next build. defaultConfig contains Next.js’s default settings.
How our SDK plugs into the build
Alongside experimental flags, next.config gives you access to build lifecycle hooks, which run code at specific points during a build. We use them to upload source maps and inject global values for our SDK to read at runtime.
Our plugin handles this build setup in one line, so customers don’t have to write or maintain it:
1export default withInterfere(nextConfig);Why our plugin returns a function
Returning a function lets us wait for Next.js to pass in the phase. We use it to fetch SDK settings for production builds and skip that fetch during development.
The function adds our build hooks and loader rules when Next.js calls it. Returning it from withInterfere doesn’t run that setup yet.
- withInterferereturns F
- F(phase, context)deferred setupexport default
- Next.jscalls F
- settingswith SDK settings
What if you need more than one plugin?
One of our customers also used General Translation. They combined its withGTConfig plugin with ours:
1export default withGTConfig(withInterfere(nextConfig));Their app built successfully, but we immediately noticed that source maps and observability metadata were missing from the deployment. Normally, our source map upload hook prints output during the build but nothing appeared in their logs - meaning the hook wasn’t being called. After some back and forth, we found that moving withInterfere to the outside fixed it.
That got their app working, but we wanted to understand the cause and prevent other customers from running into it.
Where the function disappeared
In the customer’s setup, withInterfere(nextConfig) runs first. General Translation receives the function it returns.
In the version they were using, General Translation stored the input as internalNextConfig without calling it. It then spread that value into a new config object. With unrelated settings omitted:
123456const config: NextConfig = { ...internalNextConfig, transpilePackages: Array.from( new Set([...(internalNextConfig.transpilePackages || []), "gt-next"]), ),};Here, internalNextConfig is our function. Spreading it copies properties attached to the function itself, not the settings it would return.
For example:
1234const configFunction = () => ({ reactStrictMode: true });
const settings = configFunction();const copied = { ...configFunction };settings contains reactStrictMode: true. copied is empty because we haven’t attached any properties to configFunction.
General Translation did the same thing to our function, returning an object with its own settings, meaning Next.js had no function left to call.
- withInterferereturns F
- F(phase, context)deferred setuppassed to
- withGTConfigspreads { …F }
- plain objecttranslation settings onlyexport default
- Next.jsreads an object
- build passesF never called
That explains why the build passed. Spreading a function is valid JavaScript, and the object General Translation returned was a valid Next.js config.
We also compared the generated server files in .next/. Our SDK settings were missing from the broken build.
An agreement nobody wrote down
Next.js accepts either settings or a function that returns them - but plugins don’t have to. When you stack plugins, each one hands its result to the next, and only the outermost one talks to Next.js. So every plugin in the chain has to understand what the one inside it returns.
Unfortunately, nothing checks this. Our plugin handed General Translation a function, it expected settings, and neither the build nor TypeScript complained. That’s the shadow contract: a rule every plugin relies on that isn’t written down anywhere.
General Translation wasn’t the only one. Older versions of @next/bundle-analyzer and next-intl had the same gap. Newer versions may behave differently.
Call first, then combine
Sentry fixed this in 2021. Its plugin calls a function input before combining the returned settings with its own.
For a config that returns its settings immediately, a simplified version looks like this:
12345const settings = typeof userNextConfig === "function" ? userNextConfig(phase, context) : userNextConfig;return { ...settings, ...pluginConfig };This runs inside the function Next.js calls, so we can pass its phase and context through to the input function.
If the input returns a promise, wait for the settings before copying them.
Why changing the order worked
We updated our plugin to handle both objects and functions, including promised results. But that couldn’t change how General Translation handled our output.
Putting our plugin on the outside let its function reach Next.js:
1export default withInterfere(withGTConfig(nextConfig));General Translation creates its settings first. Our plugin then returns a function that adds our setup to those settings when Next.js calls it.
- withGTConfigreturns an object
- plain objecttranslation settingspassed to
- withInterferereturns F
- F(phase, context)deferred setupexport default
- Next.jscalls F
- settingstranslation + SDK settings
This worked with the customer’s plugins. It doesn’t mean plugin order never matters, since plugins can also overwrite each other’s settings. To make this easier to catch next time, we since added a runtime check, and our build setup injects a marker into the app with the client SDK looking for it when it starts - If that marker is missing, the SDK logs a warning that our build setup didn’t run and suggests moving withInterfere to the outside.
