Skip to content

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 routekit
npx 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.