Skip to content

refactor(core): use dynamic imports in client for next.js 16 compat#2905

Open
matthewvolk wants to merge 1 commit intocanaryfrom
CATALYST-1808-dynamic-imports
Open

refactor(core): use dynamic imports in client for next.js 16 compat#2905
matthewvolk wants to merge 1 commit intocanaryfrom
CATALYST-1808-dynamic-imports

Conversation

@matthewvolk
Copy link
Contributor

What/Why?

In Next.js 16 with pnpm, statically importing next/headers, next/navigation, or next-intl/server at the top of core/client/index.ts causes AsyncLocalStorage poisoning. When next.config.ts imports the client module, static imports trigger the full module graph (next-intl/servergetConfig.jsRequestLocale.jsnext/headers) to be evaluated during module resolution. With pnpm symlinks, this creates two separate singleton instances of the next/headers AsyncLocalStorage, breaking request-scoped context for subsequent runtime calls.

The Next.js team identified this as a bug and opened vercel/next.js#90711 to fix it upstream. However, regardless of that fix, the client module shouldn't eagerly import framework-specific APIs like next/headers or next/navigation at the module level — these are runtime concerns that belong inside the hooks that use them. This refactor improves separation of concerns and makes the client resilient to module evaluation order, independent of any upstream fix.

Solution

Replace all three static imports with dynamic import() calls inside the hooks that use them:

  • next-intl/server — dynamically imported in getLocale(). During config resolution, the import succeeds but getLocale() throws ("not supported in Client Components"), which the existing try/catch absorbs gracefully. At request time, it works normally.
  • next/headers — dynamically imported inside the fetchOptions.cache guard in beforeRequest. Config-time calls pass no fetchOptions, so this never executes during config resolution.
  • next/navigation — dynamically imported inside the BigCommerceAuthError check in onError. Config-time settings queries don't produce auth errors.

Dynamic imports defer module loading to call time (after Next.js has fully initialized), avoiding the module-graph-level poisoning that static imports cause.

Scope

Only core/client/index.ts changes. next.config.ts and all 30+ consumer files remain untouched.

Testing

We added temporary instrumentation (console.log with NEXT_RUNTIME value and call stack) to getLocale(), next.config.ts, and middlewares/with-routes.ts, then tested across all runtime contexts on Next.js 16.1.6 (Turbopack) by cherry-picking the Next.js 16 upgrade commits onto this branch.

NEXT_RUNTIME behavior (key finding)

We initially guarded getLocale() with a process.env.NEXT_RUNTIME check, expecting it to be undefined during config resolution. Testing revealed NEXT_RUNTIME="nodejs" in every context, including config resolution. The guard was dead code and was removed. The actual protection comes from:

  1. Dynamic import() vs static import — defers module loading past the initialization window where poisoning occurs
  2. The existing try/catch — absorbs getLocale() failures in contexts where next-intl isn't available

Runtime context matrix

Context NEXT_RUNTIME getLocale outcome Status
Config resolution (next.config.ts startup) "nodejs" Throws "not supported in Client Components" → caught by try/catch, falls back to defaultChannelId Pass
Middleware (module load) "edge" N/A Pass
Middleware (request handling) "edge" Throws NEXT_HTTP_ERROR_FALLBACK;404 for non-matching routes → caught Pass
RSC — homepage (GET /) "nodejs" Succeeds, locale resolved Pass
Server action — search (POST /) "nodejs" Succeeds, locale resolved Pass
RSC — search results (GET /search?term=spray) "nodejs" Succeeds, locale resolved Pass
RSC — product page (GET /spray-bottle) "nodejs" Succeeds, locale resolved Pass
RSC — locale switch (GET /es-MX/spray-bottle) "nodejs" Succeeds, correct locale resolved Pass
Build (next build) "nodejs" Succeeds for all pages Pass

Manual verification

  • Homepage renders with correct channel data and locale-aware content
  • Search bar triggers server action, returns results with correct locale
  • Product pages render with full data (metadata, pricing, inventory, reviews)
  • Locale switching serves correct localized content
  • No AsyncLocalStorage errors in any context
  • No regressions in existing functionality
  • Build completes successfully with all pages generated

Migration

No migration needed. Only core/client/index.ts was modified. All consumer imports remain unchanged.

@vercel
Copy link

vercel bot commented Mar 3, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
catalyst Ready Ready Preview, Comment Mar 4, 2026 7:49pm

Request Review

@changeset-bot
Copy link

changeset-bot bot commented Mar 3, 2026

🦋 Changeset detected

Latest commit: 1dcc379

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@bigcommerce/catalyst-core Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Contributor

github-actions bot commented Mar 3, 2026

Bundle Size Report

Comparing against baseline from 8b5fee6 (2026-03-04).

No bundle size changes detected.

@matthewvolk matthewvolk marked this pull request as ready for review March 3, 2026 23:45
@matthewvolk matthewvolk requested a review from a team as a code owner March 3, 2026 23:45
@@ -1,22 +1,30 @@
import { BigCommerceAuthError, createClient } from '@bigcommerce/catalyst-client';
import { headers } from 'next/headers';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are these (next/headers, next/navigation, next-intl/server) the only affected paths? or will we need to worry about others later?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question! Unfortunately, these Next.js API's rely on an AsyncLocalStorage store that is not resolving correctly when these API's are called in next.config.*. Any Next.js API statically imported into this file that rely on AsyncLocalStorage would re-introduce the bug... that said, Next.js opened vercel/next.js#90711 to fix this, and once released, I should be able to revert this PR 👍

@rtalvarez
Copy link
Member

also dumb question but should the E2E suite be passing? or are these tests flaky?

…age poisoning

Replace static imports of next/headers, next/navigation, and next-intl/server
with dynamic import() calls inside the hooks that use them. Static imports
cause these modules to be evaluated during module graph resolution when
next.config.ts imports this file, poisoning the process-wide AsyncLocalStorage
context. Dynamic imports defer module loading to call time, after Next.js has
fully initialized.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants