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 →Build with me →

# Basic SPA

A full working example you can copy verbatim. Three pages: home, posts list, individual post. Lazy-loaded chunks. Active-link styling. ~70 lines of your code.

## Project layout

    src/├── main.ts              # bootstrap├── routes.ts            # route table├── nav.ts               # active-link helper└── pages/    ├── home.ts    ├── posts.ts    └── post.ts

## `src/routes.ts`

    export const routes = [  { path: '/',           handler: () => import('./pages/home') },  { path: '/posts',      handler: () => import('./pages/posts') },  { path: '/posts/:id',  handler: () => import('./pages/post') },  { path: '/*',          handler: () => import('./pages/not-found') },] as const;

## `src/main.ts`

    import { createRouter } from 'routekit';import { routes } from './routes';import { updateActiveLinks } from './nav';
    const root = document.querySelector<HTMLElement>('#app')!;const router = createRouter(routes);
    router.on('navigate', async ({ match }) => {  const mod = await match.handler() as { default: PageRender };  mod.default(root, match.params);  updateActiveLinks(router.url);});
    router.start();
    type PageRender = (root: HTMLElement, params: Record<string, string>) => void;

## `src/nav.ts`

    import { isActive } from 'routekit';
    export function updateActiveLinks(currentUrl: string): void {  document.querySelectorAll<HTMLAnchorElement>('a[href]').forEach((a) => {    const href = a.getAttribute('href');    if (!href) return;    a.toggleAttribute('data-active', isActive(href, currentUrl));  });}

## `src/pages/home.ts`

    export default function render(root: HTMLElement) {  root.innerHTML = `    <h1>routekit demo</h1>    <nav>      <a href="/">Home</a>      <a href="/posts">Posts</a>    </nav>  `;}

## `src/pages/posts.ts`

    const posts = [  { id: 1, title: 'In praise of small libraries' },  { id: 2, title: 'Why your bundle size matters' },  { id: 3, title: 'Type-safe URLs' },];
    export default function render(root: HTMLElement) {  root.innerHTML = `    <h1>Posts</h1>    <ul>${posts.map((p) => `      <li><a href="/posts/${p.id}">${p.title}</a></li>    `).join('')}</ul>  `;}

## `src/pages/post.ts`

    export default function render(root: HTMLElement, params: { id: string }) {  root.innerHTML = `    <h1>Post #${params.id}</h1>    <p>This is post number ${params.id}.</p>    <a href="/posts">← Back to posts</a>  `;}

## `src/pages/not-found.ts`

    export default function render(root: HTMLElement) {  root.innerHTML = `    <h1>Not found</h1>    <p>That page doesn't exist.</p>    <a href="/">Go home</a>  `;}

## `index.html`

    <!doctype html><html lang="en">  <head>    <meta charset="utf-8" />    <meta name="viewport" content="width=device-width, initial-scale=1" />    <title>routekit demo</title>    <style>      body { font-family: system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; padding: 0 1rem; }      nav { display: flex; gap: 1rem; margin: 1rem 0; }      nav a { color: #2563EB; text-decoration: none; }      nav a[data-active] { font-weight: 600; text-decoration: underline; }    </style>  </head>  <body>    <div id="app"></div>    <script type="module" src="/src/main.ts"></script>  </body></html>

## Run it

With Vite:

Terminal window

    npm install vite routekitnpx vite

Visit `http://localhost:5173/`. Click “Posts,” then click a post. Hit the back button. Notice the page transitions are instant (chunks are cached after first visit) and the active link follows your current URL.

That’s it — a typed, code-split, framework-agnostic SPA in under 100 lines of your code.

Run it on StackBlitz

An interactive version of this example is available on StackBlitz (placeholder URL — adapt to your project’s actual demo). Fork it, change the routes, see the result.