Skip to content

Routing

CAUTION

Experimental — not for production. Managed Data Mode SSR is available for evaluation and testing. Do not use it in production yet; the API and behaviour may change. In the meantime, continue using Webpack SSR.

This page covers the route tree, page modules, multi-site membership, and intent preloading.

SSR uses React Router Data Mode for routing. Export a routes array from routesEntry (default src/routes.tsx). sku wires that tree into React Router on the server and in the browser.

Prerequisite

Install React Router in your app (react-router@^8). For route API details (layouts, loaders, error boundaries), see React Router Data Mode routing.

Add a page

Compose the route tree

Each route can set a path (or index), optional site membership, and a lazy page import. Put loader, action, Component, and ErrorBoundary on the lazily imported page module — not on the route object in routes.tsx. Use React Router’s lazy factory so each page is a separate chunk:

tsx
import type { SkuRouteObject } from 'sku/runtime';

import { RootLayout } from './App/RootLayout';

export const routes: SkuRouteObject[] = [
  {
    Component: RootLayout,
    children: [
      { index: true, lazy: () => import('./pages/home/home') },
      { path: 'about', lazy: () => import('./pages/about/about') },
    ],
  },
];
tsx
import { Outlet } from 'react-router';

export const RootLayout = () => <Outlet />;
tsx
export function Component() {
  return <main>Home</main>;
}
tsx
export function Component() {
  return <main>About</main>;
}

Lazy page modules must export a named Component (not export default).

Use a pathless root layout for shared UI and providers (see Providers).

You’re set up when:

  • Pages load via lazy: () => import(...) (not static imports into routes.tsx)
  • Each page module exports a named Component
  • Shared UI lives on a pathless root layout

Keep pages lazy

Do not statically import page modules into routes.tsx, or you lose per-route chunking. Prefer the idiomatic form so sku can derive production modulepreload links automatically:

tsx
lazy: () => import('./pages/about/about');

Automatic modulepreload

Idiomatic lazy: () => import(...) lets sku set handle.moduleId to the Vite client manifest key (for example src/pages/about/about.tsx). That is how production document responses emit modulepreload links for the matched route.

If you use another lazy shape, set handle.moduleId yourself to that same manifest key. sku warns in development when a lazy route is missing moduleId, and skips that route’s production preloads.

When to use loaders

For page content, prefer render-time data loading. Use loaders when you need document redirects, response headers, or to start work above a suspending tree. Export those loaders from the same page module as Component.

Multi-site routes

When different sites need different path sets, set optional sites on a route. sku only includes that route when the active site is in the list. If you omit sites, the route is available on every configured site.

Resolve the active site in the server entry with getSite (required when config has more than one site; omit on single-site apps):

tsx
import type { SkuRouteObject } from 'sku/runtime';

import { RootLayout } from './App/RootLayout';

export const routes: SkuRouteObject[] = [
  {
    Component: RootLayout,
    children: [
      { index: true, lazy: () => import('./pages/home/home') },
      {
        path: 'au-only',
        sites: ['au'], 
        lazy: () => import('./pages/au-only/au-only'),
      },
      {
        path: 'nz-only',
        sites: ['nz'], 
        lazy: () => import('./pages/nz-only/nz-only'),
      },
    ],
  },
];
tsx
import { defineServerEntry } from 'sku/runtime';

const server = defineServerEntry({
  getSite({ req }) {
    return req.get('x-site') === 'nz' ? 'nz' : 'au';
  },
});

export default server;

Strictly typed sites in route objects

When defining sites in a route, you can narrow down the type of sites by defining SiteName in SkuRouteObject<SiteName>.

You can get the site name directly from the return of your getSite method using SkuRouteObject<SiteOf<typeof server>>.

A good practice is to export route types from the same file as createSkuContexts, then import them wherever you define routes. Do not import the server entry into those files.

tsx
import {
  createSkuContexts,
  type SiteOf,
  type SkuRouteObject,
} from 'sku/runtime';

import type client from './client';
import type server from './server';

export const { useSite, useClientContext, useReactContext } = createSkuContexts<
  typeof server,
  typeof client
>();

export type AppRouteObject = SkuRouteObject<SiteOf<typeof server>>;
tsx
import type { AppRouteObject } from './skuContext';

export const routes: AppRouteObject[] = [
  { path: 'au-only', sites: ['au'] },
  { path: 'nz-only', sites: ['nz'] },
];

Multiple paths with mapRoutePath

When the same page should match more than one concrete path (for example /about and /fr/about, or / and /fr), export optional mapRoutePath from routesEntry. sku calls it while pre-building each site tree and clones the route for each returned path. Index homes are called with path: '' — return '' to keep index: true, or a non-empty string for a prefixed home without index.

tsx
import type { MapRoutePath, SkuRouteObject } from 'sku/runtime';

export const mapRoutePath: MapRoutePath = ({ path, site, parentSegments }) => {
  if (parentSegments.length > 0) {
    return [path];
  }
  if (path === '' && site === 'au') {
    return ['', 'au'];
  }
  if (path === 'about' && site === 'au') {
    return ['about', 'au/about'];
  }
  return [path];
};

export const routes: SkuRouteObject[] = [
  {
    Component: RootLayout,
    children: [
      { index: true, lazy: () => import('./pages/home/home') },
      { path: 'about', lazy: () => import('./pages/about/about') },
    ],
  },
];

See Multi-language → Languages in the path for the localisation-prefix case.

Case-sensitive paths

By default, sku matches route paths case-sensitively. If a route omits React Router’s caseSensitive, sku sets caseSensitive: true while pre-building the site tree. So /about matches a route with path: 'about', and /About does not.

Opt out on a specific route when you need case-insensitive matching:

tsx
{ path: 'about', caseSensitive: false, lazy: () => import('./pages/about/about') },

Intent preloading with usePreloadRoute

On the initial document, sku already emits modulepreload links for the matched route’s chunks. To warm chunks for a route the user is about to visit, use usePreloadRoute:

tsx
import { Link, type LinkProps } from 'react-router';
import { usePreloadRoute } from 'sku/runtime';

export function PreloadingLink({ to, ...rest }: LinkProps) {
  const preload = usePreloadRoute(to); 

  return (
    <Link
      to={to}
      onMouseEnter={preload}
      onFocus={preload}
      onTouchStart={preload}
      {...rest}
    />
  );
}

Calling the returned function loads matched lazy route modules for the current site. It is fire-and-forget — a failed warm-up never throws; navigation reports the real error. Loader data is not prefetched — only route modules.

React Router route middleware

React Router Data Mode supports a middleware array on routes for isomorphic behaviour on matched routes. That is separate from Express middleware on the server entry: use Express for HTTP-level work, and route middleware for behaviour tied to the matched route tree. See Middleware for when to use each.

See also