Using next-intl in a Non-Next.js App - Taming the React Server Condition
May 12, 2026Timeli.sh is a monorepo. The main customer-facing app and the admin dashboard are both Next.js, so using next-intl there is straightforward - install the package, wire up
getRequestConfig, add the plugin to next.config.js, done. But there is a third app in the repo: job-processor, a Node.js background worker that runs appointment reminders, SMS notifications, and other async tasks. It is built with esbuild and has no Next.js anywhere near it.The job-processor sends emails and SMS messages that contain translated strings. Those strings come from the same shared
@timelish/i18n package that the Next.js apps use, which is built on top of next-intl. So I needed next-intl's getTranslations to work inside a plain Node.js process, bundled by esbuild, with no React renderer, no request context, and no next/headers available at runtime.This post is about the three things that stood between me and a working build.
Why next-intl breaks outside Next.js
next-intl is designed around Next.js App Router's React Server Components model. Two things in its design cause problems the moment you step outside that environment.
Problem 1: the
react-server conditional export.When you import
next-intl/server, Node.js and bundlers resolve the import using the exports field in next-intl's package.json. That field has a conditional export with a react-server condition:{ "exports": { "./server": { "react-server": "./dist/server.react-server.js", "default": "./dist/server.node.js" } }}Next.js activates the
react-server condition during its webpack build when compiling Server Components. esbuild does not know about this condition by default. So when the job-processor tries to import next-intl/server, esbuild resolves to server.node.js - the non-server-component entry point - which is missing getRequestConfig and related APIs that only exist in the react-server variant.Problem 2:
next/headers is imported at the module level.Even if you land on the right entry point,
server.react-server.js imports next/headers internally. That module is a Next.js internal that does not exist outside a Next.js runtime. esbuild will either fail to resolve it entirely, or bundle it and blow up at runtime when headers() tries to call into Next.js async storage that was never initialized.Problem 3: CSS imports in shared packages.
The shared packages in the monorepo import CSS files (
.css, .scss) for component styling. esbuild for a Node.js target has no idea what to do with a CSS import and will throw an error. This is less about next-intl specifically and more about the reality of bundling packages that are primarily built for a browser/Next.js context.The solution: a custom esbuild plugin and two aliases
All three problems are solved in
apps/job-processor/esbuild.config.js. Here it is in full:import { spawn } from "child_process";import { build as _build, context as _context } from "esbuild";import { createRequire } from "module";import { dirname, join } from "path";import { fileURLToPath } from "url";const require = createRequire(import.meta.url);const nextIntlServerPlugin = { name: "next-intl-server", setup(build) { build.onResolve({ filter: /^next-intl\/server$/ }, (args) => { const pkgMain = require.resolve("next-intl"); const pkgDir = dirname(pkgMain); const target = join(pkgDir, `server.react-server.js`); return { path: target }; }); },};const buildConfig = { entryPoints: ["src/index.ts"], bundle: true, platform: "node", target: "node21", format: "cjs", outdir: "dist", sourcemap: true, logLevel: "info", plugins: [nextIntlServerPlugin], loader: { ".css": "empty", ".scss": "empty", ".sass": "empty", ".less": "empty", ".styl": "empty", }, absWorkingDir: process.cwd(), external: [ "lucide-react/dynamic", "next/navigation", "next/image", "next/link", "@resvg/resvg-js", ], alias: { "next-intl/config": "./src/i18n/config.ts", "next/headers": "./src/i18n/headers.ts", },};Let me walk through each decision.
Fix 1: force the react-server entry point with a plugin
The esbuild plugin intercepts every import of
next-intl/server and resolves it directly to server.react-server.js, bypassing the conditional exports machinery entirely:const nextIntlServerPlugin = { name: "next-intl-server", setup(build) { build.onResolve({ filter: /^next-intl\/server$/ }, (args) => { const pkgMain = require.resolve("next-intl"); const pkgDir = dirname(pkgMain); const target = join(pkgDir, `server.react-server.js`); return { path: target }; }); },};The logic is:
require.resolve("next-intl") gives the absolute path to next-intl's main entry file on disk - something like .../node_modules/next-intl/dist/index.js.dirname() strips the filename, leaving the dist/ directory.join(pkgDir, "server.react-server.js") constructs the full path to the file that next-intl would normally only serve under the react-server condition.The
onResolve filter only matches the exact string next-intl/server, so sub-path imports like next-intl/config are unaffected and handled separately by the alias config.Why not just set
conditions: ["react-server"] in the esbuild config? Because esbuild's conditions option applies globally to all packages, and activating react-server across the entire bundle breaks other packages that use that condition to serve browser-only code. A targeted plugin that intercepts only next-intl/server is safer and more explicit.Fix 2: stub next/headers with an alias
server.react-server.js imports next/headers to read the current request's locale from incoming HTTP headers. In a Next.js App Router request, headers() is backed by React's async storage - each request has its own isolated store that carries the headers through the render tree without being passed explicitly as props.The job-processor has no HTTP request. It has no React render. When a notification job fires, the locale comes from the organization's configuration, not from a request header. So all I need is for
next/headers to not crash - I don't need it to return anything meaningful.The alias in the esbuild config points every import of
next/headers to a local stub:alias: { "next/headers": "./src/i18n/headers.ts",}And the stub itself is four lines:
// apps/job-processor/src/i18n/headers.tsexport const headers = async () => { return new Map<string, string>();};It exports an async function that returns an empty
Map. next-intl calls headers() when it needs to detect the locale from the Accept-Language header or from a custom header set by middleware. Returning an empty map means locale detection from headers returns nothing - which is exactly what I want, because the job-processor supplies the locale explicitly through the config instead.The key insight is that next-intl's
getRequestConfig callback receives a requestLocale parameter, but that parameter is just a promise that resolves to whatever the headers stub or middleware returns. If it returns nothing (as the empty map does), requestLocale resolves to undefined and the config callback can provide a fallback - which is what the config.ts does.Fix 3: skip CSS imports entirely
The shared packages in the monorepo import CSS files because they contain React components that are also used in the browser. esbuild on a
platform: "node" target has no CSS pipeline. The fix is a set of loader entries that map every stylesheet extension to "empty":loader: { ".css": "empty", ".scss": "empty", ".sass": "empty", ".less": "empty", ".styl": "empty",},The
"empty" loader tells esbuild to treat any import of those file types as an empty module - no content, no error. The job-processor never renders to a browser, so losing the CSS has zero runtime impact.The i18n config itself
With the esbuild plumbing in place, the job-processor needs its own
i18n/config.ts that next-intl will use in place of the one in the Next.js apps. This is also aliased in the esbuild config:alias: { "next-intl/config": "./src/i18n/config.ts",}The config loads translations from the shared
@timelish/app-store package, collecting per-app translation files for both the public booking flow and the admin side:// apps/job-processor/src/i18n/config.tsimport { AppsTranslations } from "@timelish/app-store/translations";import { getConfig } from "@timelish/i18n/request";const config = getConfig( async (baseLocale: string | undefined) => { const locale = baseLocale || "en"; return { locale, includeAdmin: true }; }, async () => { const allApps = Object.keys(AppsTranslations); const messages = { public: async (locale: string) => { const promises = allApps .filter((app) => AppsTranslations[app]?.public) .map(async (app) => [ `app_${app}_public`, await AppsTranslations[app]?.public?.(locale), ]); const entries = await Promise.all(promises); return Object.fromEntries(entries); }, admin: async (locale: string) => { const promises = allApps .filter((app) => AppsTranslations[app]?.admin) .map(async (app) => [ `app_${app}_admin`, await AppsTranslations[app]?.admin?.(locale), ]); const entries = await Promise.all(promises); return Object.fromEntries(entries); }, overrides: async (locale: string) => { const promises = allApps .filter((app) => AppsTranslations[app]?.overrides) .map(async (app) => await AppsTranslations[app]?.overrides?.(locale)); const overrides = await Promise.all(promises); return overrides; }, }; return messages; },);export default config;The first callback provides the locale. Because the job-processor runs jobs on behalf of specific organizations that each have their own locale setting, that locale is passed down through the job context rather than read from headers. The
baseLocale parameter carries it, with "en" as the fallback.The second callback builds the messages object. Rather than loading a single monolithic JSON file, it dynamically imports per-app translation files from the app store. Each app in the system can define its own
public, admin, and overrides translation namespaces, and this config assembles them all into a flat object keyed by app_{appId}_{namespace}. The job-processor needs both public and admin translations because notification templates can reference either.What the alias approach means for the monorepo
The
"next-intl/config" alias is worth calling out specifically. The @timelish/i18n shared package calls getRequestConfig and references the config file at next-intl/config - that is how next-intl's plugin convention works in Next.js. In a Next.js app, the next-intl webpack plugin rewrites that import to point at i18n.ts in the app root.Outside Next.js there is no plugin doing that rewriting. The esbuild alias takes its place. By aliasing
next-intl/config to ./src/i18n/config.ts, the job-processor provides its own config implementation while the shared @timelish/i18n package remains unmodified and works identically in all three apps.This means the same
getTranslations call works in a Next.js server component, in the admin API routes, and in the job-processor's notification handlers - the call site is identical, the config lookup is what differs per app.A note on next/navigation and other Next.js internals
The esbuild config marks several Next.js modules as
external rather than aliasing them:external: [ "lucide-react/dynamic", "next/navigation", "next/image", "next/link", "@resvg/resvg-js",],next/navigation is in the external list rather than the alias list because the job-processor never imports it directly or transitively through the i18n path. It appears in other shared packages that get bundled, but those code paths are never exercised at runtime in the job-processor context. Marking it external tells esbuild to leave the require("next/navigation") call in the output as-is, which is fine because those code paths are never reached. If they were reached, the process would crash - but they are not.next/headers, by contrast, is reachable through server.react-server.js during the normal i18n initialization path. That is why it needs a real stub rather than just being marked external.The distinction is: external is for dead code paths where you just need the bundler to not fail. Alias-to-stub is for live code paths where you need the import to resolve and execute without crashing.
The full picture
Three changes, each solving one problem:
Problem | Fix |
|---|---|
esbuild resolves next-intl/server to the wrong entry point (missing react-server condition) | Custom onResolve plugin that hard-wires the path to server.react-server.js |
server.react-server.js imports next/headers, which crashes outside Next.js | Alias next/headers to a stub that returns an empty Map |
Shared packages import CSS files that esbuild can't handle on a Node.js target | Set all stylesheet extensions to the "empty" loader |
And one bonus fix that is more architectural than esbuild-specific: alias
next-intl/config to a job-processor-specific config file so the shared i18n package gets locale and messages from the job context rather than from a Next.js request.The result is that the job-processor uses
getTranslations from next-intl exactly the same way the Next.js apps do, with no runtime differences and no forked translation files. When a new app adds translations to the app store, they are available in the job-processor's notification templates automatically on the next build.