Skip to content
Back to Blog

Writing SDKs That Play Nice Together

Nested boxes labeled Feature Flags, Plugin Wrapper, and Next.js Server Bundle, with an orange warning marker at the plugin boundary
PS

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:

next.config.ts

Next.js also accepts a function that returns those settings. Here’s the same example written both ways:

next.config.ts
Object
next.config.ts
Function

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:

next/src/server/config-shared.ts

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:

next.config.ts

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.

  1. withInterferereturns F
  2. F(phase, context)deferred setupexport default
  3. Next.jscalls F
  4. 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:

next.config.ts

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:

packages/next/src/config.ts

Here, internalNextConfig is our function. Spreading it copies properties attached to the function itself, not the settings it would return.

For example:

config-function.ts

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.

  1. withInterferereturns F
  2. F(phase, context)deferred setuppassed to
  3. withGTConfigspreads { …F }
  4. plain objecttranslation settings onlyexport default
  5. Next.jsreads an object
  6. 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:

plugin.ts

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:

next.config.ts

General Translation creates its settings first. Our plugin then returns a function that adds our setup to those settings when Next.js calls it.

  1. withGTConfigreturns an object
  2. plain objecttranslation settingspassed to
  3. withInterferereturns F
  4. F(phase, context)deferred setupexport default
  5. Next.jscalls F
  6. 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.