Skip to content

Free MIT template · Day 6 of 365

You're looking at a demo. Download the built static files (drop on any host) or grab the full source to customize.

↓ Download static ↓ Source (Astro) All 365 →Hire me →

# 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 `await`s 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.

Don't swallow loader errors silently

Failed fetches are real bugs. If you catch and ignore them, you’ll ship a “loading forever” UI and never know. At minimum, log to your error tracker:

    } catch (err) {  Sentry.captureException(err);  // or whatever you use  root.innerHTML = `<p>Failed to load.</p>`;}

## 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.