Skip to content

With data fetching

routekit doesn’t ship a data-loading primitive — by design. But you can build one in 30 lines using the route meta field. This pattern is what most apps converge on, and it’s how the routekit-loader companion package (planned, not yet released) will work.

The pattern

Each route declares its data dependency in meta.loader. The router’s 'navigate' handler awaits both the chunk and the loader, then calls the renderer with both.

type RouteMeta = {
loader?: (params: Record<string, string>) => Promise<unknown>;
};

Example: a post page that fetches its data

src/routes.ts

export const routes = [
{ path: '/',
handler: () => import('./pages/home'),
},
{ path: '/posts',
handler: () => import('./pages/posts'),
meta: {
loader: async () => {
const res = await fetch('/api/posts');
return res.json();
},
},
},
{ path: '/posts/:id',
handler: () => import('./pages/post'),
meta: {
loader: async ({ id }) => {
const res = await fetch(`/api/posts/${id}`);
if (!res.ok) throw new Error(`Post ${id} not found`);
return res.json();
},
},
},
] as const;

src/main.ts

import { createRouter } from 'routekit';
import { routes } from './routes';
const router = createRouter(routes);
const root = document.querySelector<HTMLElement>('#app')!;
router.on('navigate', async ({ match }) => {
const loader = match.meta?.loader;
// Kick off chunk + data fetches in parallel.
const [mod, data] = await Promise.all([
match.handler(),
loader ? loader(match.params) : Promise.resolve(null),
]);
const { default: render } = mod as { default: PageRender };
render(root, match.params, data);
});
router.start();
type PageRender = (
root: HTMLElement,
params: Record<string, string>,
data: unknown,
) => void;

The two awaits run in parallel — chunk and data fetch finish at roughly the same time (whichever is slower wins). For a post page with a small chunk and a slow API, the API is your bottleneck; for a big chunk on a fast API, the chunk is. Either way, you only wait for the slower of the two.

src/pages/post.ts

type Post = { id: number; title: string; body: string };
export default function render(
root: HTMLElement,
params: { id: string },
data: Post,
) {
root.innerHTML = `
<article>
<h1>${escape(data.title)}</h1>
<p>${escape(data.body)}</p>
</article>
`;
}
function escape(s: string): string {
return s.replace(/[&<>'"]/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[c]!),
);
}

Loading state

Show a placeholder while the loader runs:

let placeholderTimer: number | undefined;
router.on('navigate', async ({ match }) => {
placeholderTimer = setTimeout(() => {
root.innerHTML = '<p>Loading…</p>';
}, 150);
try {
const [mod, data] = await Promise.all([
match.handler(),
match.meta?.loader?.(match.params) ?? null,
]);
clearTimeout(placeholderTimer);
(mod as { default: PageRender }).default(root, match.params, data);
} catch (err) {
clearTimeout(placeholderTimer);
root.innerHTML = `<p>Failed to load: ${(err as Error).message}</p>`;
}
});

The 150ms debounce prevents the loading state from flashing on fast networks.

Loader errors

If the loader throws, you have three options:

  1. Render an error state in place (what the example above does).
  2. Redirect to an error route: router.replace('/error/' + encodeURIComponent(err.message)).
  3. Fall through to a wildcard error route by wrapping the loader and catching at the route level.

What we are not building

The future routekit-loader package will add:

  • Cached loaders (don’t re-fetch when navigating back to the same URL).
  • Stale-while-revalidate (show last data, refetch in background).
  • Loader composition (run a layout loader + a page loader together).

For now, build what you need. The pattern above scales cleanly to 80% of apps.