Skip to content

fix(nextjs): make @clerk/nextjs ESM-safe for non-Node.js runtimes#7954

Open
nikosdouvlis wants to merge 5 commits intomainfrom
nk/fix-nextjs-esm-server-only
Open

fix(nextjs): make @clerk/nextjs ESM-safe for non-Node.js runtimes#7954
nikosdouvlis wants to merge 5 commits intomainfrom
nk/fix-nextjs-esm-server-only

Conversation

@nikosdouvlis
Copy link
Member

@nikosdouvlis nikosdouvlis commented Feb 27, 2026

Why

@clerk/nextjs crashes on import in pure ESM runtimes like Cloudflare Workers (via vinext). Three separate require() patterns fail because require is not defined in ESM-only environments.

Ref: cloudflare/vinext#73

What changed

  • auth(), auth.protect(), and currentUser() used require('server-only') which crashes in ESM runtimes. Replaced with assertServerOnly() that checks typeof require before calling it. The server-only package uses the react-server export condition, which vinext/Workers already enforce at the bundler level, so the guard is redundant there.

  • safe-node-apis.js (both node and browser variants) used require('node:fs') / module.exports, which crashes in Vite's ESM module runner. Converted to ESM import/export default.

  • usePathnameWithoutCatchAll used require('next/navigation') to lazily load hooks. Since @clerk/nextjs only supports Next.js 15.2.8+, next/navigation is always available as a static import.

Testing

Verified with a vinext smoke test app deployed to Cloudflare Workers (vinext 0.0.18 + Next.js 16.1.6):

  • / (home with auth()) - 200, renders auth state
  • /api/me (API route with auth()) - 200, returns userId/sessionId
  • /sign-in (<SignIn /> component) - 200, renders sign-in UI
  • /protected (auth.protect()) - correctly redirects unauthenticated users

Also added 12 Playwright e2e tests across 3 suites (quickstart, protect, auth-state) running against a local vinext dev server in CI.

Note on vinext CJS support

vinext 0.0.18 added vite-plugin-commonjs (cloudflare/vinext#198) to handle require() at build time. We tested: the build passes with canary @clerk/nextjs (without our fixes), but Cloudflare Workers deploy still fails because Workers statically rejects require() in the output bundle. The plugin also doesn't handle mixed ESM+CJS files (like usePathnameWithoutCatchAll.tsx which has both import and require()). Our ESM fixes remain necessary.

auth(), auth.protect(), and currentUser() call require('server-only')
at runtime. Cloudflare Workers is pure ESM with no require(), so this
crashes with "require is not defined" when running @clerk/nextjs on
vinext (Cloudflare's Vite-based Next.js reimplementation).

Replace the three bare require() calls with an assertServerOnly()
helper that checks typeof require before calling. On Next.js (where
require exists) behavior is identical. On pure ESM runtimes the guard
is skipped, deferring to the bundler's own RSC/client environment
separation.
@vercel
Copy link

vercel bot commented Feb 27, 2026

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

Project Deployment Actions Updated (UTC)
clerk-js-sandbox Ready Ready Preview, Comment Mar 2, 2026 2:02pm

Request Review

@changeset-bot
Copy link

changeset-bot bot commented Feb 27, 2026

🦋 Changeset detected

Latest commit: ff5650a

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

This PR includes changesets to release 1 package
Name Type
@clerk/nextjs Patch

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

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 27, 2026

📝 Walkthrough

Walkthrough

Adds an assertServerOnly utility and updates server-side modules to call it instead of using dynamic CommonJS require('server-only'). Converts runtime safe-node-apis modules to ES module default exports and switches some dynamic requires to static ESM imports. Adds a unit test for the server-only assertion. Introduces a Vinext integration (preset, template files, Vite config, package.json, tsconfig, pages, middleware, components), several Playwright integration tests, CI matrix entry, an npm script, and a turbo task for vinext integration tests.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The PR title accurately describes the main change: making @clerk/nextjs ESM-safe for non-Node.js runtimes through require() safety checks and ESM conversion, which is the core focus of this changeset.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@pkg-pr-new
Copy link

pkg-pr-new bot commented Feb 27, 2026

Open in StackBlitz

@clerk/agent-toolkit

npm i https://pkg.pr.new/@clerk/agent-toolkit@7954

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@7954

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@7954

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@7954

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@7954

@clerk/dev-cli

npm i https://pkg.pr.new/@clerk/dev-cli@7954

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@7954

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@7954

@clerk/express

npm i https://pkg.pr.new/@clerk/express@7954

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@7954

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@7954

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@7954

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@7954

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@7954

@clerk/react

npm i https://pkg.pr.new/@clerk/react@7954

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@7954

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@7954

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@7954

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@7954

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@7954

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@7954

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@7954

commit: ff5650a

safe-node-apis.js (node + browser) used require/module.exports which
crashes in Vite's ESM module runner. usePathnameWithoutCatchAll used
require('next/navigation') to lazily load hooks, which also fails in
pure ESM runtimes. Since @clerk/nextjs only supports Next.js 15.2.8+,
next/navigation is always available as a static import.
@nikosdouvlis nikosdouvlis changed the title fix(nextjs): make server-only guard ESM-safe for vinext/Workers fix(nextjs): make @clerk/nextjs ESM-safe for non-Node.js runtimes Feb 27, 2026
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/nextjs/src/client-boundary/hooks/usePathnameWithoutCatchAll.tsx`:
- Around line 27-35: The hook calls usePathname() and useParams() are currently
invoked only when pagesRouter is falsy, violating the Rules of Hooks; move both
usePathname() and useParams() to be called unconditionally at the top of
usePathnameWithoutCatchAll (so always call usePathname() and useParams() to get
pathname and params) and then keep the existing pagesRouter conditional logic to
decide which values to use or to short-circuit, using the unconditional values
rather than calling hooks inside branches; update any references to pathname,
pathParts, and catchAllParams accordingly (functions/identifiers:
usePathnameWithoutCatchAll, usePathname, useParams, pagesRouter, pathname,
params, catchAllParams).

ℹ️ Review info

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 7b67aae and f166d40.

📒 Files selected for processing (4)
  • .changeset/quiet-waves-guard.md
  • packages/nextjs/src/client-boundary/hooks/usePathnameWithoutCatchAll.tsx
  • packages/nextjs/src/runtime/browser/safe-node-apis.js
  • packages/nextjs/src/runtime/node/safe-node-apis.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • .changeset/quiet-waves-guard.md

Comment on lines +27 to +35
const pathname = usePathname() ?? '';
const pathParts = pathname.split('/').filter(Boolean);
// the useParams hook returns an object with all named and catch all params
// for named params, the key in the returned object always contains a single value
// for catch all params, the key in the returned object contains an array of values
// we find the catch all params by checking if the value is an array
// and then we remove one path part for each catch all param
const catchAllParams = Object.values(useParams() || {})
const params = useParams();
const catchAllParams = Object.values(params ?? {})
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Rules of Hooks violation: usePathname() and useParams() are called conditionally.

The early return on lines 11-20 when pagesRouter exists means these hooks are only called when in App Router mode. This violates React's Rules of Hooks and is flagged by Biome.

While this may work in practice because an app is typically either Pages Router or App Router (not both), this pattern can cause React warnings in development and potential issues if the router mode detection ever changes between renders.

Consider restructuring to call hooks unconditionally at the top of the function, then conditionally use their values.

Proposed restructure
 export const usePathnameWithoutCatchAll = () => {
   const pathRef = React.useRef<string>();
-
   const { pagesRouter } = usePagesRouter();
+  const pathname = usePathname() ?? '';
+  const params = useParams();

   if (pagesRouter) {
     if (pathRef.current) {
       return pathRef.current;
     } else {
       pathRef.current = pagesRouter.pathname.replace(/\/\[\[\.\.\..*/, '');
       return pathRef.current;
     }
   }

-  const pathname = usePathname() ?? '';
   const pathParts = pathname.split('/').filter(Boolean);
-  const params = useParams();
   const catchAllParams = Object.values(params ?? {})
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const pathname = usePathname() ?? '';
const pathParts = pathname.split('/').filter(Boolean);
// the useParams hook returns an object with all named and catch all params
// for named params, the key in the returned object always contains a single value
// for catch all params, the key in the returned object contains an array of values
// we find the catch all params by checking if the value is an array
// and then we remove one path part for each catch all param
const catchAllParams = Object.values(useParams() || {})
const params = useParams();
const catchAllParams = Object.values(params ?? {})
export const usePathnameWithoutCatchAll = () => {
const pathRef = React.useRef<string>();
const { pagesRouter } = usePagesRouter();
const pathname = usePathname() ?? '';
const params = useParams();
if (pagesRouter) {
if (pathRef.current) {
return pathRef.current;
} else {
pathRef.current = pagesRouter.pathname.replace(/\/\[\[\.\.\..*/, '');
return pathRef.current;
}
}
const pathParts = pathname.split('/').filter(Boolean);
const catchAllParams = Object.values(params ?? {})
🧰 Tools
🪛 Biome (2.4.4)

[error] 27-27: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.

(lint/correctness/useHookAtTopLevel)


[error] 34-34: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.

(lint/correctness/useHookAtTopLevel)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/nextjs/src/client-boundary/hooks/usePathnameWithoutCatchAll.tsx`
around lines 27 - 35, The hook calls usePathname() and useParams() are currently
invoked only when pagesRouter is falsy, violating the Rules of Hooks; move both
usePathname() and useParams() to be called unconditionally at the top of
usePathnameWithoutCatchAll (so always call usePathname() and useParams() to get
pathname and params) and then keep the existing pagesRouter conditional logic to
decide which values to use or to short-circuit, using the unconditional values
rather than calling hooks inside branches; update any references to pathname,
pathParts, and catchAllParams accordingly (functions/identifiers:
usePathnameWithoutCatchAll, usePathname, useParams, pagesRouter, pathname,
params, catchAllParams).

Add integration test infrastructure and 3 test suites (12 tests) to
validate @clerk/nextjs works on vinext (Cloudflare's Vite-based Next.js
reimplementation for Workers).

Template: integration/templates/vinext-app/ with minimal Clerk app
Preset: integration/presets/vinext.ts wired into test framework
Tests: quickstart (sign-in/out), protect (auth gating), auth-state
(cookie persistence, cross-tab, server/client consistency)

All tests tagged @vinext for filtering.
@nbardy
Copy link

nbardy commented Mar 2, 2026

@nikosdouvlis good work, would like to get this merged to deploy on vinext if I can.

Wondering if this merits changes in:
cloudflare/vinext#126

TBH mostly going blind here and letting codex guide me as I'm not an expert on nodejs internals

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@integration/templates/vinext-app/app/api/me/route.ts`:
- Line 3: Exported template functions are missing explicit return type
annotations; add them to the three functions: annotate GET in
integration/templates/vinext-app/app/api/me/route.ts as async GET():
Promise<Response> (or Promise<NextResponse> if you’re using NextResponse), and
annotate the React page components Page in
integration/templates/vinext-app/app/sign-in/[[...sign-in]]/page.tsx and
integration/templates/vinext-app/app/sign-up/[[...sign-up]]/page.tsx as function
Page(): JSX.Element (or async Page(): Promise<JSX.Element> if they are async),
updating the function signatures only to include these explicit return types.

ℹ️ Review info

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between f166d40 and 5774982.

📒 Files selected for processing (18)
  • integration/presets/index.ts
  • integration/presets/vinext.ts
  • integration/templates/index.ts
  • integration/templates/vinext-app/.gitignore
  • integration/templates/vinext-app/app/api/me/route.ts
  • integration/templates/vinext-app/app/auth-display.tsx
  • integration/templates/vinext-app/app/layout.tsx
  • integration/templates/vinext-app/app/page.tsx
  • integration/templates/vinext-app/app/protected/page.tsx
  • integration/templates/vinext-app/app/sign-in/[[...sign-in]]/page.tsx
  • integration/templates/vinext-app/app/sign-up/[[...sign-up]]/page.tsx
  • integration/templates/vinext-app/middleware.ts
  • integration/templates/vinext-app/package.json
  • integration/templates/vinext-app/tsconfig.json
  • integration/templates/vinext-app/vite.config.ts
  • integration/tests/vinext-auth-state.test.ts
  • integration/tests/vinext-protect.test.ts
  • integration/tests/vinext-quickstart.test.ts
✅ Files skipped from review due to trivial changes (1)
  • integration/templates/vinext-app/.gitignore

@@ -0,0 +1,14 @@
import { auth } from '@clerk/nextjs/server';

export async function GET() {
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if the mentioned files exist and inspect their function signatures
echo "=== Checking integration/templates/vinext-app/app/api/me/route.ts ==="
if [ -f "integration/templates/vinext-app/app/api/me/route.ts" ]; then
  cat -n "integration/templates/vinext-app/app/api/me/route.ts"
else
  echo "File not found"
fi

echo ""
echo "=== Checking integration/templates/vinext-app/app/sign-in/[[...sign-in]]/page.tsx ==="
if [ -f "integration/templates/vinext-app/app/sign-in/[[...sign-in]]/page.tsx" ]; then
  cat -n "integration/templates/vinext-app/app/sign-in/[[...sign-in]]/page.tsx"
else
  echo "File not found"
fi

echo ""
echo "=== Checking integration/templates/vinext-app/app/sign-up/[[...sign-up]]/page.tsx ==="
if [ -f "integration/templates/vinext-app/app/sign-up/[[...sign-up]]/page.tsx" ]; then
  cat -n "integration/templates/vinext-app/app/sign-up/[[...sign-up]]/page.tsx"
else
  echo "File not found"
fi

Repository: clerk/javascript

Length of output: 950


Add explicit return types on exported template functions.

Exported functions in these files lack explicit return type annotations:

  • integration/templates/vinext-app/app/api/me/route.ts line 3: GET()
  • integration/templates/vinext-app/app/sign-in/[[...sign-in]]/page.tsx line 2: Page()
  • integration/templates/vinext-app/app/sign-up/[[...sign-up]]/page.tsx line 2: Page()

Per TypeScript guidelines, always define explicit return types for public APIs.

Proposed fixes
-export async function GET() {
+export async function GET(): Promise<Response> {
   const authObj = await auth();
   return new Response(
-export default function Page() {
+export default function Page(): JSX.Element {
  return <SignIn />;
}
-export default function Page() {
+export default function Page(): JSX.Element {
  return <SignUp />;
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function GET() {
export async function GET(): Promise<Response> {
const authObj = await auth();
return new Response(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@integration/templates/vinext-app/app/api/me/route.ts` at line 3, Exported
template functions are missing explicit return type annotations; add them to the
three functions: annotate GET in
integration/templates/vinext-app/app/api/me/route.ts as async GET():
Promise<Response> (or Promise<NextResponse> if you’re using NextResponse), and
annotate the React page components Page in
integration/templates/vinext-app/app/sign-in/[[...sign-in]]/page.tsx and
integration/templates/vinext-app/app/sign-up/[[...sign-up]]/page.tsx as function
Page(): JSX.Element (or async Page(): Promise<JSX.Element> if they are async),
updating the function signatures only to include these explicit return types.

Add test:integration:vinext script, turbo task, and CI matrix entry
so the @vinext tagged integration tests run on every PR.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants