Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/react-router-v8-noexternal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": patch
---

Fix `clerk init` for React Router v8 projects by adding `@clerk/react-router` to `ssr.noExternal` in the Vite config, preventing dev-mode SSR from failing with "useNavigate() may be used only in the context of a <Router>".
115 changes: 115 additions & 0 deletions packages/cli-core/src/commands/init/frameworks/react-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,3 +434,118 @@ export default [index("routes/home.tsx")] satisfies RouteConfig;
'route("($locale)/sign-up/*", "routes/($locale).sign-up.tsx")',
);
});

test("adds ssr.noExternal to vite.config.ts for React Router 8", async () => {
await mkdir(join(tempDir, "app"), { recursive: true });
await Bun.write(
join(tempDir, "vite.config.ts"),
`import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";

export default defineConfig({
plugins: [reactRouter()],
});
`,
);

const plan = await reactRouter.scaffold(makeCtx({ deps: { "react-router": "8.0.0" } }));
const viteAction = plan.actions.find((action) => action.path === "vite.config.ts");

expect(viteAction?.type).toBe("modify");
if (viteAction?.type !== "modify") throw new Error("Expected vite config modify action");

expect(viteAction.content).toContain("noExternal");
expect(viteAction.content).toContain("@clerk/react-router");
expect(viteAction.content.match(/ssr\s*:/g)?.length).toBe(1);
});

test("appends to an existing ssr.noExternal array for React Router 8", async () => {
await mkdir(join(tempDir, "app"), { recursive: true });
await Bun.write(
join(tempDir, "vite.config.ts"),
`import { defineConfig } from "vite";

export default defineConfig({
ssr: {
noExternal: ["some-other-pkg"],
},
});
`,
);

const plan = await reactRouter.scaffold(makeCtx({ deps: { "react-router": "8.0.0" } }));
const viteAction = plan.actions.find((action) => action.path === "vite.config.ts");

expect(viteAction?.type).toBe("modify");
if (viteAction?.type !== "modify") throw new Error("Expected vite config modify action");

expect(viteAction.content).toContain("some-other-pkg");
expect(viteAction.content).toContain("@clerk/react-router");
Comment thread
manovotny marked this conversation as resolved.
expect(viteAction.content.match(/ssr\s*:/g)?.length).toBe(1);
});

test("skips vite config when @clerk/react-router is already in ssr.noExternal", async () => {
await mkdir(join(tempDir, "app"), { recursive: true });
await Bun.write(
join(tempDir, "vite.config.ts"),
`import { defineConfig } from "vite";

export default defineConfig({
ssr: {
noExternal: ["@clerk/react-router"],
},
});
`,
);

const plan = await reactRouter.scaffold(makeCtx({ deps: { "react-router": "8.0.0" } }));
const viteAction = plan.actions.find((action) => action.path === "vite.config.ts");

expect(viteAction?.type).toBe("skip");
});

test("does not touch vite config for React Router 7", async () => {
await mkdir(join(tempDir, "app"), { recursive: true });
await Bun.write(
join(tempDir, "vite.config.ts"),
`import { defineConfig } from "vite";

export default defineConfig({
plugins: [],
});
`,
);

const plan = await reactRouter.scaffold(makeCtx({ deps: { "react-router": "7.9.0" } }));
const viteAction = plan.actions.find((action) => action.path === "vite.config.ts");

expect(viteAction).toBeUndefined();
expect(plan.postInstructions.join(" ")).not.toContain("noExternal");
});

test("emits manual noExternal instruction when vite config is missing on React Router 8", async () => {
await mkdir(join(tempDir, "app"), { recursive: true });

const plan = await reactRouter.scaffold(makeCtx({ deps: { "react-router": "8.0.0" } }));

expect(plan.postInstructions.join(" ")).toContain("noExternal");
});

test("emits manual noExternal instruction when vite config is a function form", async () => {
await mkdir(join(tempDir, "app"), { recursive: true });
await Bun.write(
join(tempDir, "vite.config.ts"),
`import { defineConfig } from "vite";

export default defineConfig(({ command }) => ({
plugins: [],
}));
`,
);

const plan = await reactRouter.scaffold(makeCtx({ deps: { "react-router": "8.0.0" } }));
const viteAction = plan.actions.find((action) => action.path === "vite.config.ts");

expect(viteAction).toBeUndefined();
expect(plan.postInstructions.join(" ")).toContain("noExternal");
});
87 changes: 86 additions & 1 deletion packages/cli-core/src/commands/init/frameworks/react-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,83 @@ function scaffoldConfig(ctx: ProjectContext): Promise<FileAction | null> {
});
}

type ViteScaffoldResult = {
action: FileAction | null;
/** True when the noExternal entry is needed but couldn't be applied — user must wire manually. */
needsManualViteWire: boolean;
};

/**
* Add `ssr.noExternal: ["@clerk/react-router"]` to the vite config.
* React Router v8 ships development/production conditional exports; `react-router dev`
* externalizes @clerk/react-router for SSR, so it resolves react-router's production
* build while app code gets the development build — two Router contexts, and every
* SSR render throws "useNavigate() may be used only in the context of a <Router>".
* https://github.com/remix-run/react-router/issues/15232
*/
function addClerkNoExternal(content: string): string {
try {
const mod = parseModule(content);
const defaultExport = mod.exports.default;
if (!defaultExport || typeof defaultExport !== "object") return content;

// `export default defineConfig({...})` proxies the call expression; property writes on it
// throw, so operate on the call's first argument (the config object) instead.
const config = defaultExport.$type === "function-call" ? defaultExport.$args[0] : defaultExport;
if (!config || typeof config !== "object") return content;

if (!config.ssr) config.ssr = {};
if (config.ssr.noExternal === undefined) config.ssr.noExternal = [];
// boolean/string/regex noExternal: leave it and let the caller emit the manual instruction
if (!Array.isArray(config.ssr.noExternal)) return content;

if (!config.ssr.noExternal.includes("@clerk/react-router")) {
config.ssr.noExternal.push("@clerk/react-router");
}
return mod.generate().code;
} catch {
return content;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async function scaffoldViteConfig(ctx: ProjectContext): Promise<ViteScaffoldResult> {
// The dual-instance issue only exists in v8's conditional exports; v7 doesn't need this.
if (shouldEnableV8MiddlewareFlag(ctx)) return { action: null, needsManualViteWire: false };

const configPath = await findFirstFile(ctx.cwd, [
"vite.config.ts",
"vite.config.js",
"vite.config.mts",
"vite.config.mjs",
]);
if (!configPath) return { action: null, needsManualViteWire: true };

const content = await Bun.file(join(ctx.cwd, configPath)).text();
if (content.includes("@clerk/react-router")) {
return {
action: {
type: "skip",
path: configPath,
skipReason: "Already references @clerk/react-router in vite config",
},
needsManualViteWire: false,
};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const updated = addClerkNoExternal(content);
if (updated === content) return { action: null, needsManualViteWire: true };

return {
action: {
path: configPath,
type: "modify",
content: updated,
description: "Add @clerk/react-router to ssr.noExternal (React Router v8 dev SSR fix)",
},
needsManualViteWire: false,
};
}

export const reactRouter: FrameworkScaffold = {
name: "React Router",
dep: "react-router",
Expand All @@ -390,8 +467,9 @@ export const reactRouter: FrameworkScaffold = {
matches: (ctx) => ctx.framework.dep === "react-router",

async scaffold(ctx: ProjectContext): Promise<ScaffoldPlan> {
const [configAction, rootResult, localePrefix, envAction] = await Promise.all([
const [configAction, viteResult, rootResult, localePrefix, envAction] = await Promise.all([
scaffoldConfig(ctx),
scaffoldViteConfig(ctx),
scaffoldRoot(ctx),
detectLocalePrefix(ctx.cwd),
scaffoldEnvVars(ctx, SIGN_ROUTE_ENV_VARS.vite),
Expand All @@ -404,6 +482,7 @@ export const reactRouter: FrameworkScaffold = {
const rootAction = rootResult.action;
const actions = [
configAction,
viteResult.action,
rootAction,
...authActions,
routesResult.action,
Expand All @@ -424,6 +503,12 @@ export const reactRouter: FrameworkScaffold = {
);
}

if (viteResult.needsManualViteWire) {
postInstructions.push(
"React Router v8 needs `ssr: { noExternal: ['@clerk/react-router'] }` in your vite config — without it, dev SSR fails with \"useNavigate() may be used only in the context of a <Router>\". See: https://github.com/remix-run/react-router/issues/15232",
);
}

if (rootAction?.type === "modify" && rootResult.needsManualLoaderMerge) {
postInstructions.push(
"Update your existing app/root.tsx loader to import and call rootAuthLoader(args), then pass that loaderData to <ClerkProvider>.",
Expand Down