remix-run/react-router · error

Invalid route exports found when prerendering with…

Error message

Invalid route exports found when prerendering with `ssr:false`

What it means

With `ssr: false`, there is no runtime server, so prerendering validates route module exports before building: `headers` and `action` are never valid (nothing can handle requests), and `loader` is only valid on routes actually matched by a `prerender` path (their data is baked in at build time). Detailed per-route messages are logged first (with a link to the pre-rendering docs), then this aggregate error stops the build.

Solutions

  1. Remove `action` and `headers` exports from routes when `ssr: false` (forms must POST to an external API or use `clientAction`)
  2. Convert `loader` to `clientLoader` on routes that are not prerendered
  3. Add the affected paths to `prerender` so their `loader` runs at build time, or remove the loader
  4. Read the logged per-route errors above the throw — they name the exact route IDs and invalid exports

Example fix

// before (app/routes/contact.tsx, ssr:false)
export async function action({ request }: Route.ActionArgs) {
  await submitContact(await request.formData());
  return null;
}

// after — client-side submission in SPA Mode
export async function clientAction({ request }: Route.ClientActionArgs) {
  await submitContact(await request.formData());
  return null;
}
Defensive patterns

Strategy: validation

Validate before calling

// Scan route modules for server-only exports before enabling ssr:false
import glob from 'fast-glob';
const bad: string[] = [];
for (const file of await glob('app/routes/**/*.ts*')) {
  const src = fs.readFileSync(file, 'utf8');
  if (/^export\s+(async\s+)?(function|const)\s+(action|headers|loader)\b/m.test(src)) {
    bad.push(`${file}: server export found`);
  }
}
if (bad.length) throw new Error(bad.join('\n'));

Type guard

type ServerExport = 'loader' | 'action' | 'headers';
const isServerOnlyExport = (name: string): name is ServerExport =>
  name === 'loader' || name === 'action' || name === 'headers';

Try / catch

try {
  await build();
} catch (e) {
  if (e instanceof Error && e.message.includes('Invalid route exports')) {
    // per-route details were logged just above the throw — parse them from build logs
  }
}

Prevention

When it happens

Trigger: `ssr: false` plus any route exporting `action` or `headers`; a route with `loader` that is not covered by any path in the `prerender` config; a parent route with `loader` (and no `clientLoader`) above a prerendered child; enabling `prerender: true` only over part of the tree while other branches keep loaders.

Common situations: Flipping an SSR app to SPA Mode without stripping server exports; keeping form `action`s in routes during a SPA migration; prerendering only `/` while deeper routes still have loaders; inherited layout loaders.

Related errors


AI-assisted analysis of remix-run/react-router@6beaca3952 (2026-08-18). Data as JSON: /api/errors/c4b04bdd9eb0c110. Report an issue: GitHub.

Appendix: source

Thrown at packages/react-router-dev/vite/plugin.ts:3168

      while (parentRoute && parentRoute.id !== "root") {
        if (parentRoute.hasLoader && !parentRoute.hasClientLoader) {
          errors.push(
            `Prerender: 1 invalid route export in \`${parentRoute.id}\` when ` +
              "pre-rendering with `ssr:false`: `loader`. " +
              "See https://reactrouter.com/how-to/pre-rendering#invalid-exports for more information.",
          );
        }
        parentRoute =
          parentRoute.parentId && parentRoute.parentId !== "root"
            ? manifest.routes[parentRoute.parentId]
            : null;
      }
    }
  }

  if (errors.length > 0) {
    viteConfig.logger.error(colors.red(errors.join("\n")));
    throw new Error(
      "Invalid route exports found when prerendering with `ssr:false`",
    );
  }
}

function getAddressableRoutes(routes: RouteManifest): RouteManifestEntry[] {
  let nonAddressableIds = new Set<string>();

  for (let id in routes) {
    let route = routes[id];

    // We omit the parent route of index routes since the index route takes ownership of its parent's path
    if (route.index) {
      invariant(
        route.parentId,
        `Expected index route "${route.id}" to have "parentId" set`,
      );
      nonAddressableIds.add(route.parentId);

View on GitHub (pinned to 6beaca3952)