Client-Side Routing
Everything you’ve built with marko-run so far is a multi-page app: each <a>
is a fresh request, the server renders a whole new page, and the browser replaces
the document. That’s the default, and a good one — fast first paint, no client
router to ship.
Sometimes you want a single-page app instead: after the first load, clicking a link should swap only the part of the page that changed, update the address bar, and never round-trip to the server. marko-run has no built-in client router — so you build one, in a few lines, on the browser’s History API. And because this is Marko, you keep server rendering: the first page renders on the server and then hydrates, so a deep link or a refresh still works.
The setup (already written)
One catch-all route. src/routes/$$path/+handler.js runs for every URL, so
the server can render the right starting view whether you land on /, /completed,
or /archived:
export function GET(context) { const url = new URL(context.request.url); context.route = matchRoute(url.pathname); context.serializedGlobals = ["route"];}It uses matchRoute to turn the URL into a route name, stashes it on context
(which is $global), and lists it in serializedGlobals so the value crosses to
the browser for hydration.
The shell. src/routes/$$path/+page.marko reads that route into a reactive
<let>, renders the matching view, and turns each nav link into a client-side
navigation:
<let/route=$global.route>
<nav> <for|name| of=ROUTES> <a href=`/${name}` onClick(e) { e.preventDefault(); navigate(name); route = matchRoute(location.pathname); }>${name}</a> </for></nav>
<main> <if=(route === "active")><ActiveView/></if> <else-if=(route === "completed")><CompletedView/></else-if> <else-if=(route === "archived")><ArchivedView/></else-if></main>e.preventDefault() stops the browser’s normal full-page navigation. Then
navigate updates the URL, and matchRoute(location.pathname) reads it back to
choose the view — the URL is the single source of truth, which is exactly why
the back button will work too. One consequence worth noticing up front: until you
implement the router, clicking a tab does nothing. Nothing is changing the URL, so
matchRoute keeps returning the same view. Making the tabs come alive is the
exercise.
Your job — the router
Open src/router.js. It has three stubbed functions; fill them in with the
History API. None of this is Marko-specific — it’s the plumbing every SPA needs.
matchRoute(pathname)— take the first segment of the path (/completed→"completed"), return it if it’s a known route, else"active". The server handler and the browser both use this, so implementing it also makes deep links render correctly on the server.navigate(name)— the heart of it. Callhistory.pushState(state, "", "/" + name)to change the address bar without a reload. That one call is the whole difference between an SPA and a full page load.onPopState(callback)— the back and forward buttons fire apopstateevent. Add a listener that callscallbackwith the freshly-matched route (the shell uses it to syncrouteback to the URL), and return a function that removes the listener again.
What you’ll see
Before you touch src/router.js, the tabs are dead — clicking them does nothing,
and the address bar above the preview stays on / no matter which tab you click.
Once the three functions are in, click a tab and the view swaps instantly, with
no full-page reload — and the address bar above the preview updates to match. The
back button walks you through the views too. A URL that changes with no page reload
is the tell: that’s a client-side route change, served from one small file that still
renders on the server for the first load.
The view is chosen with a plain <if> / <else-if> chain over static component
tags. It’s tempting to keep a lookup object instead —
<const/views={ active: ActiveView, ... }> and then <${views[route]}/>.
Don’t. That works in the dev server but silently breaks in a production build:
Marko’s fine-grained bundler only ships the view that rendered first, so navigating
to the others shows an empty page. Referencing each component directly — in an
<if> chain, a conditional expression, or a module-level constant — is what tells
the compiler to bundle them all.
- Installing dependencies
- Starting dev server