diff --git a/contributors.yml b/contributors.yml
index df521ac7f3..457f383bc3 100644
--- a/contributors.yml
+++ b/contributors.yml
@@ -51,6 +51,7 @@
- bobziroll
- bravo-kernel
- Brendonovich
+- brettburley
- briankb
- BrianT1414
- brockross
diff --git a/integration/file-path-config-test.ts b/integration/file-path-config-test.ts
new file mode 100644
index 0000000000..8b2d004c6d
--- /dev/null
+++ b/integration/file-path-config-test.ts
@@ -0,0 +1,192 @@
+import { expect } from "@playwright/test";
+import type { Files } from "./helpers/vite.js";
+import { test, viteConfig, build, createProject } from "./helpers/vite.js";
+
+const js = String.raw;
+
+const simpleFiles: Files = async ({ port }) => ({
+ "vite.config.ts": await viteConfig.basic({ port }),
+ "react-router.config.ts": js`
+ export default {
+ rootRouteFile: "custom/root.tsx",
+ routesFile: "custom/app-routes.ts",
+ clientEntryFile: "custom/entry.client.tsx",
+ serverEntryFile: "custom/entry.server.tsx",
+ };
+ `,
+ "app/custom/root.tsx": js`
+ import { Links, Meta, Outlet, Scripts } from "react-router";
+
+ export default function Root() {
+ return (
+
+
+
+
+
+
+
+
Custom Root
+
+
+
+
+
+ );
+ }
+ `,
+ "app/custom/app-routes.ts": js`
+ import { type RouteConfig, index } from "@react-router/dev/routes";
+
+ export default [
+ index("index.tsx"),
+ ] satisfies RouteConfig;
+ `,
+ "app/index.tsx": js`
+ export default function IndexRoute() {
+ return {}}>Custom IndexRoute
+ }
+ `,
+ "app/custom/entry.client.tsx": js`
+ import { HydratedRouter } from "react-router/dom";
+ import { startTransition, StrictMode } from "react";
+ import { hydrateRoot } from "react-dom/client";
+
+ window.__customClientEntryExecuted = true;
+
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+
+ );
+ });
+ `,
+ "app/custom/entry.server.tsx": js`
+ import * as React from "react";
+ import { ServerRouter } from "react-router";
+ import { renderToString } from "react-dom/server";
+
+ export default function handleRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ remixContext
+ ) {
+ let markup = renderToString(
+
+ );
+ responseHeaders.set("Content-Type", "text/html");
+ responseHeaders.set("X-Custom-Server-Entry", "true");
+ return new Response('' + markup, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ });
+ }
+ `,
+});
+
+test.describe("File path configuration", () => {
+ test("uses custom file paths", async ({ page, dev, request }) => {
+ let { port } = await dev(simpleFiles);
+ const response = await page.goto(`http://localhost:${port}/`);
+
+ // Verify custom root.tsx and app-routes.ts is being used.
+ await expect(page.locator("h1")).toHaveText("Custom Root");
+ await expect(page.locator("#content div")).toHaveText("Custom IndexRoute");
+
+ // Verify client entry is being used.
+ expect(
+ await page.evaluate(() => (window as any).__customClientEntryExecuted)
+ ).toBe(true);
+
+ // Verify server entry is used by checking for the custom header.
+ expect(response?.headers()["x-custom-server-entry"]).toBe("true");
+ });
+
+ test("fails build when custom rootRouteFile doesn't exist", async () => {
+ let cwd = await createProject({
+ "react-router.config.ts": js`
+ export default {
+ rootRouteFile: "custom/nonexistent-root.tsx"
+ };
+ `,
+ });
+ let buildResult = build({ cwd });
+ expect(buildResult.status).toBe(1);
+ expect(buildResult.stderr.toString()).toContain(
+ 'Could not find "root" entry file at'
+ );
+ expect(buildResult.stderr.toString()).toContain("nonexistent-root.tsx");
+ });
+
+ test("fails build when custom routesFile doesn't exist", async () => {
+ let cwd = await createProject({
+ "app/root.tsx": js`
+ export default function Root() {
+ return Root
;
+ }
+ `,
+ "react-router.config.ts": js`
+ export default {
+ routesFile: "custom/nonexistent-routes.ts"
+ };
+ `,
+ });
+ let buildResult = build({ cwd });
+ expect(buildResult.status).toBe(1);
+ expect(buildResult.stderr.toString()).toContain(
+ 'Could not find "routes" entry file at'
+ );
+ expect(buildResult.stderr.toString()).toContain("nonexistent-routes.ts");
+ });
+
+ test("fails build when custom clientEntryFile doesn't exist", async () => {
+ let cwd = await createProject({
+ "app/root.tsx": js`
+ export default function Root() {
+ return Root
;
+ }
+ `,
+ "app/routes.ts": js`
+ export default [];
+ `,
+ "react-router.config.ts": js`
+ export default {
+ clientEntryFile: "custom/nonexistent-entry.client.tsx"
+ };
+ `,
+ });
+ let buildResult = build({ cwd });
+ expect(buildResult.status).toBe(1);
+ expect(buildResult.stderr.toString()).toContain(
+ 'Could not find "entry.client" entry file at'
+ );
+ expect(buildResult.stderr.toString()).toContain("nonexistent-entry.client.tsx");
+ });
+
+ test("fails build when custom serverEntryFile doesn't exist", async () => {
+ let cwd = await createProject({
+ "app/root.tsx": js`
+ export default function Root() {
+ return Root
;
+ }
+ `,
+ "app/routes.ts": js`
+ export default [];
+ `,
+ "react-router.config.ts": js`
+ export default {
+ serverEntryFile: "custom/nonexistent-entry.server.tsx"
+ };
+ `,
+ });
+ let buildResult = build({ cwd });
+ expect(buildResult.status).toBe(1);
+ expect(buildResult.stderr.toString()).toContain(
+ 'Could not find "entry.server" entry file at'
+ );
+ expect(buildResult.stderr.toString()).toContain("nonexistent-entry.server.tsx");
+ });
+});
diff --git a/packages/react-router-dev/config/config.ts b/packages/react-router-dev/config/config.ts
index 86b6833cd0..246d7b794c 100644
--- a/packages/react-router-dev/config/config.ts
+++ b/packages/react-router-dev/config/config.ts
@@ -116,6 +116,32 @@ export type ReactRouterConfig = {
*/
appDirectory?: string;
+ /**
+ * The path to the root route module file, relative to the app directory or absolute.
+ * If not specified, defaults to root.
+ */
+ rootRouteFile?: string;
+
+ /**
+ * The path to the routes configuration file, relative to the app directory or absolute.
+ * If not specified, defaults to routes.
+ */
+ routesFile?: string;
+
+ /**
+ * The path to the client entry file, relative to the app directory or absolute.
+ * If not specified, defaults entry.client in the app directory, or falls back
+ * to the default implementation.
+ */
+ clientEntryFile?: string;
+
+ /**
+ * The path to the server entry file, relative to the app directory or absolute.
+ * If not specified, defaults to entry.server in the app directory, or falls back
+ * to the default implementation.
+ */
+ serverEntryFile?: string;
+
/**
* The output format of the server build. Defaults to "esm".
*/
@@ -231,6 +257,22 @@ export type ResolvedReactRouterConfig = Readonly<{
* SPA without server-rendering. Default's to `true`.
*/
ssr: boolean;
+ /**
+ * The path to the root route module file.
+ */
+ rootRouteFile: string;
+ /**
+ * The path to the routes configuration file.
+ */
+ routesFile: string;
+ /**
+ * The path to a custom client entry file, if one exists.
+ */
+ clientEntryFile?: string;
+ /**
+ * The path to a custom server entry file, if one exists.
+ */
+ serverEntryFile?: string;
}>;
let mergeReactRouterConfig = (
@@ -392,9 +434,13 @@ async function resolveConfig({
basename,
buildDirectory: userBuildDirectory,
buildEnd,
+ clientEntryFile,
prerender,
+ rootRouteFile: userRootRouteFile,
+ routesFile: userRoutesFile,
serverBuildFile,
serverBundles,
+ serverEntryFile,
serverModuleFormat,
ssr,
} = {
@@ -422,7 +468,8 @@ async function resolveConfig({
let appDirectory = path.resolve(root, userAppDirectory || "app");
let buildDirectory = path.resolve(root, userBuildDirectory);
- let rootRouteFile = findEntry(appDirectory, "root");
+ let rootRouteFile = findEntry(appDirectory, "root", userRootRouteFile);
+
if (!rootRouteFile) {
let rootRouteDisplayPath = path.relative(
root,
@@ -434,10 +481,10 @@ async function resolveConfig({
}
let routes: RouteManifest = {
- root: { path: "", id: "root", file: rootRouteFile },
+ root: { path: "", id: "root", file: path.relative(appDirectory, rootRouteFile) },
};
- let routeConfigFile = findEntry(appDirectory, "routes");
+ let routeConfigFile = findEntry(appDirectory, "routes", userRoutesFile);
try {
if (!routeConfigFile) {
@@ -450,9 +497,7 @@ async function resolveConfig({
setAppDirectory(appDirectory);
let routeConfigExport = (
- await viteNodeContext.runner.executeFile(
- path.join(appDirectory, routeConfigFile)
- )
+ await viteNodeContext.runner.executeFile(routeConfigFile)
).default;
let routeConfig = await routeConfigExport;
@@ -513,6 +558,10 @@ async function resolveConfig({
serverBundles,
serverModuleFormat,
ssr,
+ rootRouteFile,
+ routesFile: routeConfigFile,
+ clientEntryFile,
+ serverEntryFile,
});
for (let preset of reactRouterUserConfig.presets ?? []) {
@@ -553,9 +602,7 @@ export async function createConfigLoader({
mode: watch ? "development" : "production",
});
- let reactRouterConfigFile = findEntry(root, "react-router.config", {
- absolute: true,
- });
+ let reactRouterConfigFile = findEntry(root, "react-router.config");
let getConfig = () =>
resolveConfig({ root, viteNodeContext, reactRouterConfigFile });
@@ -670,7 +717,7 @@ export async function resolveEntryFiles({
rootDirectory: string;
reactRouterConfig: ResolvedReactRouterConfig;
}) {
- let { appDirectory } = reactRouterConfig;
+ let { appDirectory, clientEntryFile, serverEntryFile } = reactRouterConfig;
let defaultsDirectory = path.resolve(
path.dirname(require.resolve("@react-router/dev/package.json")),
@@ -679,18 +726,17 @@ export async function resolveEntryFiles({
"defaults"
);
- let userEntryClientFile = findEntry(appDirectory, "entry.client");
- let userEntryServerFile = findEntry(appDirectory, "entry.server");
+ let entryClientFilePath = findEntry(appDirectory, "entry.client", clientEntryFile);
+ let entryServerFilePath = findEntry(appDirectory, "entry.server", serverEntryFile);
- let entryServerFile: string;
- let entryClientFile = userEntryClientFile || "entry.client.tsx";
+ if (!entryClientFilePath) {
+ entryClientFilePath = path.resolve(defaultsDirectory, "entry.client.tsx");
+ }
- let pkgJson = await PackageJson.load(rootDirectory);
- let deps = pkgJson.content.dependencies ?? {};
+ if (!entryServerFilePath) {
+ let pkgJson = await PackageJson.load(rootDirectory);
+ let deps = pkgJson.content.dependencies ?? {};
- if (userEntryServerFile) {
- entryServerFile = userEntryServerFile;
- } else {
if (!deps["@react-router/node"]) {
throw new Error(
`Could not determine server runtime. Please install @react-router/node, or provide a custom entry.server.tsx/jsx file in your app directory.`
@@ -719,17 +765,9 @@ export async function resolveEntryFiles({
});
}
- entryServerFile = `entry.server.node.tsx`;
+ entryServerFilePath = path.resolve(defaultsDirectory, "entry.server.node.tsx");
}
- let entryClientFilePath = userEntryClientFile
- ? path.resolve(reactRouterConfig.appDirectory, userEntryClientFile)
- : path.resolve(defaultsDirectory, entryClientFile);
-
- let entryServerFilePath = userEntryServerFile
- ? path.resolve(reactRouterConfig.appDirectory, userEntryServerFile)
- : path.resolve(defaultsDirectory, entryServerFile);
-
return { entryClientFilePath, entryServerFilePath };
}
@@ -738,12 +776,22 @@ const entryExts = [".js", ".jsx", ".ts", ".tsx"];
function findEntry(
dir: string,
basename: string,
- options?: { absolute?: boolean }
+ customPath?: string
): string | undefined {
+ if (customPath) {
+ const file = path.resolve(dir, customPath);
+ if (!fs.existsSync(file)) {
+ throw new Error(`Could not find "${basename}" entry file at ${file}.`);
+ }
+ return file;
+ }
+
+ // Try all supported extensions.
for (let ext of entryExts) {
- let file = path.resolve(dir, basename + ext);
+ const file = path.resolve(dir, basename + ext);
+
if (fs.existsSync(file)) {
- return options?.absolute ?? false ? file : path.relative(dir, file);
+ return file;
}
}