Skip to content

Instantly share code, notes, and snippets.

@abegehr
Last active May 16, 2025 18:01
Show Gist options
  • Save abegehr/8eec812cf95a8f6c9c5f5db9eb5d3ba6 to your computer and use it in GitHub Desktop.
Save abegehr/8eec812cf95a8f6c9c5f5db9eb5d3ba6 to your computer and use it in GitHub Desktop.
Simple AppRouter with nested and guarded route definitions for Svelte SPA with browser history support in <200 lines backed by nanostores.
<script module>
/*
Sample usage:
```svelte
<script lang="ts>
import AppRouter, { type Router } from "./AppRouter.svelte";
import Login from "./pages/Login.svelte";
import Home from "./pages/Home.svelte";
import Messages from "./pages/Messages.svelte";
import Profile from "./pages/Profile.svelte";
const router: Router = [
{
path: "/login",
component: Login,
},
{
path: "/",
component: Home,
guard: async () => {
if (await isLoggedOut()) return "/login"; // redirect to /login, if logged out
},
children: [
{
path: "/messages/:id",
component: Messages,
},
{
path: "/profile",
component: Profile,
},
],
},
];
</script>
<AppRouter {router} />
```
*/
import { atom } from "nanostores";
import type { Component } from "svelte";
// types
export interface Route {
path: string;
component?: Component<any>;
children?: Route[];
guard?: () => Promise<void | string>;
}
export type Router = Route[];
// state
const pathname = atom<string | undefined>();
// actions
export function nav(p: string) {
console.debug(`nav(${p})`);
window.history.pushState({}, "", p);
pathname.set(p);
}
export function pop() {
console.debug("pop()");
history.back();
}
// logic
// Build the current route state based on the pathname by traversing the router.
function buildRoute(pathname: string | undefined, router: Router) {
if (!pathname) return undefined;
// recursively traverse router to find path matching pathname
const traverse = (
pathname: string,
router: Route[],
path: Route[] = [],
) => {
const segments = pathname.split("/").filter(Boolean);
for (const route of router) {
const routeSegments = route.path.split("/").filter(Boolean);
const isMatch = routeSegments.every((seg, i) =>
seg.startsWith(":") ? true : seg === segments[i],
);
if (isMatch) {
const rest = segments.slice(routeSegments.length).join("/");
if (!rest) return [...path, route];
else return traverse(rest, route.children ?? [], [...path, route]);
}
}
return null; // no match
};
const path = traverse(pathname, router);
if (!path) return null;
// build full pattern from path
const pattern = "/".concat(
path
.flatMap((r) => r.path.split("/"))
.filter(Boolean)
.join("/"),
);
// extract params from pathname
const params: Record<string, string> = {};
const segments = pathname.split("/");
pattern.split("/").forEach((seg, index) => {
if (seg.startsWith(":")) {
const paramName = seg.slice(1);
params[paramName] = segments[index];
}
});
// guards are used to check if a route is accessible
// if any guard returns a path, the user is redirected to that path
async function guard() {
if (!path) return;
for (const route of path) {
if (route.guard) {
const redirect = await route.guard();
if (redirect) {
nav(redirect);
return false;
}
}
}
return true;
}
return {
pathname,
path,
route: path[path.length - 1],
pattern,
params,
guard,
};
}
</script>
<script lang="ts">
import { onMount } from "svelte";
import Loading from "./components/common/Loading.svelte";
interface Props {
router: Router;
}
let { router }: Props = $props();
// update path on mount and popstate
function update() {
console.debug("->", window.location.pathname);
pathname.set(window.location.pathname);
}
onMount(() => {
update();
window.addEventListener("popstate", update);
return () => window.removeEventListener("popstate", update);
});
// build
const s = $derived(buildRoute($pathname, router));
</script>
{#if s === undefined}
<Loading />
{:else if s === null || !s.route}
<p>Not found: {$pathname}</p>
{:else}
{#await s.guard()}
<Loading />
{:then}
<s.route.component params={s.params} />
{/await}
{/if}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment