has created something impressive with Qwik, along with their other tools. I started experimenting with Qwik a while back and chose to rebuild my personal site at brecht.io using it. I'm also considering Qwik for more substantial projects down the road, but shipping my own website first felt like the right proving ground.
When I began the rework, no existing solution handled in-page SPA (single page application) routing in a way that matched my experience with other client-side frameworks. My background includes working with routing in AngularJS, Angular, and React, so I was curious to see how those principles could translate to Qwik applications.
Qwik-city exists and delivers impressive performance with MPA (multiple page application) routing, but at the time of this writing, no complete client-side router for SPA-style navigation was available. For someone like me, that was an invitation. I decided to build a custom SPA router for Qwik. The journey turned out to be deeply instructive—it gave me a much better feel for how Qwik thinks and forced me to reason about problems I hadn't anticipated.
Before going further, let's clarify what separates MPA-routing from SPA-routing. With MPA-routing, every navigation triggers a full page reload. SPA-routing, on the other hand, leverages the history property of the window object to keep routing state, avoiding full page refreshes and re-rendering only the necessary parts of the page. In older stacks, SPA-routing was clearly faster, but Qwik-city has narrowed that gap considerably.
### Why share this?
Honestly, it's a great exercise. I learned a tremendous amount, ran into obstacles I never saw coming, and gained clarity on pain points I've felt with routers in other frameworks. But curiosity alone wouldn't have been enough to justify the effort. I genuinely believe SPA routers bring real advantages, and I think Qwik deserves to shine in that context as well.
#### State
A key benefit of SPA-routing is that application state survives navigation. Your application instance persists, so state lives on. This means you can share state not just between components, but across entire pages. Many users prefer their sidebar collapsed; others don't. Navigating to a new page and watching the sidebar jump back open because state wasn't shared is frustrating. SPA-routing eliminates that problem.
#### Routing state
I'm a strong advocate for storing state (params and searchParams) in routes. Not everything belongs in the URL, but routing state offers several advantages:
- You can bookmark a page and retain all its state.
- You can copy paste URLs to share with others without losing what's encoded in the route.
- It costs nothing to manage—no complex framework, no state invalidation headaches.
- The browser's back and forward buttons work naturally with your application states.
One caveat: MPA-routing can also put state in the URL, but the more state you push into the URL, the more page refreshes you trigger, and that hurts usability.
#### Usability
Full page refreshes on every navigation introduce several usability annoyances.
- The cursor position gets lost.
- Selected text becomes unselected.
- A video call in progress or a movie you're watching would be terminated.
- Background audio gets cut off.
- Dialogs, snackbars, banners, and success messages become difficult to show or keep alive across a refresh. For example, after submitting a form on page A and navigating to page B on success, how would you even display a success message? MPA frameworks rely on "flash" messaging for this, but it's much simpler to handle in a SPA.
#### Performance
- Qwik's core idea is loading only what you need. Does re-fetching the same DOM make sense when you already have it?
- Does re-rendering DOM that's already rendered, like a menu, add any value?
- Qwik has built-in lazy-loading that works beautifully. Why not apply it to routing as well?
#### Architecture
A router outlet is essentially a component that renders a page inside a placeholder. The rest of the DOM stays untouched; only the contents of the outlet change. Router outlets get truly powerful when they can be nested, as with the Angular router. My routing system doesn't support nested outlets yet, but imagine the architectural possibilities: attach a dialog to a route, and close it with the native browser Back button. No need to manage dialog state—the outlet renders the component and disposes of it when the time comes.
#### Eventing
With SPA-routing, you get notified whenever something changes in the URL. Consider a user management page with a search box, where you want the search query to be bookmarkable. As the user types "Brecht," you'd want the URL to reflect that as /users/search?q=Brecht. But you absolutely do not want a full page refresh on every keystroke—that would wreck the search input's cursor. And think about debouncing. Instead, you want to be notified when that q parameter changes, trigger an XHR call, and on success, re-render just the results section. And here's the beautiful part: if a full refresh does happen, Qwik renders the exact same result on the server. That's the kind of capability that makes Qwik special.
Building a SPA Router for Qwik
This router implementation is still rough around the edges, but the core ideas are solid. Let's walk through the code together.
The configuration
Every router starts with its config. We need a file that maps URL paths to components, and those paths can include parameters. Just like Angular and Nest, we use the : prefix to mark dynamic segments.
// routing/routing-types.ts
export type RoutingConfigItem = {
component: any;
path: string;
}
export type RoutingConfig = RoutingConfigItem[];
// routing-config.tsx
export const routingConfig:RoutingConfig = [
{
path: '',
component: <Home/>
},
{
path: 'users',
component: <Users/>
},
{
path: 'users/:id',
component: <UserDetail/>
}
]
The root path / points to the home page, users maps to the <Users/> component, and users/:id resolves to <UserDetail/>.
The state
Qwik ships with its own state management. Our goal is to sync the current URL with that state. On the server we grab the URL, hand it to the render function, then pass it down to <Root/>, which forwards it to <App/>. That component is where the router gets initialized with the URL.
// entry.dev.tsx
...
render(document, <Root url={''}/>);
// entry.ssr.tsx
export function render(opts: RenderOptions) {
return renderToString(<Root url={opts.url as string || ''} />, {
manifest,
...opts,
});
}
// root.tsx
export default (opts: { url: string }) => {
return (
<html>
...
<body>
<App url={opts.url}/>
</body>
</html>
);
};
What we've done here is guarantee that <App/> receives the URL regardless of the environment. That's the setup done. Now for the state itself.
// routing/routing-state.ts
import {createContext} from '@builder.io/qwik';
export interface RoutingState {
// we don't want to store `new URL()` because it is not serializable
url: string;
segments: string[];
}
export const ROUTING = createContext<RoutingState>('Routing');
// routing/routing.ts
import {ROUTING, RoutingState} from './routing-state';
import {useContextProvider, useStore} from '@builder.io/qwik';
// this one will be called by the <App/> component and initialize
// the state once for the entire lifecycle of the application
export function initializeRouter(url: string): RoutingState {
// create a store and state
const routingState = useStore<RoutingState>(
getRoutingStateByPath(url)
);
useContextProvider(ROUTING, routingState);
return routingState;
}
// this will retrieve the routingstate by the path (the current url)
export function getRoutingStateByPath(path: string): RoutingState {
const url = new URL(path);
const segments = url.pathname.split('/');
segments.splice(0, 1); // remove empty segment
return {
url: path,
segments
}
}
The first half of the state is in place — we just need to kick off the router inside <App/>.
// containers/app/app.tsx
export const App = component$((opts: { url: string | undefined }) => {
initializeRouter(opts.url);
...
});
So far so good. The next step is updating the router state whenever the route changes. Two scenarios need handling:
- The user taps a link inside our app to move to another page:
navigateTo() - The browser's back/forward buttons are pressed, and we need to react to those:
listenToRouteChanges()
Both of these are strictly browser-side concerns; they make no sense on the server. We've used isServer here, but flipping it to isBrowser would work just as well.
// routing/routing.ts
import {isServer} from '@builder.io/qwik/build';
// safely get the window object
export function getWindow(): Window | undefined {
if (!isServer) {
return typeof window === 'object' ? window : undefined
}
return undefined;
}
export function navigateTo(path: string, routingState: RoutingState): void {
if (!isServer) {
// we don't actually navigate, but push a new state to
// the history object
getWindow()?.history?.pushState({page: path}, path, path);
setRoutingState(path, routingState);
}
}
export function listenToRouteChanges(routingState: RoutingState): void {
if (!isServer) {
// when the navigation buttons are being used
// we want to set the routing state
getWindow()?.addEventListener('popstate', (e) => {
const path = e.state.page;
setRoutingState(path, routingState);
})
}
}
export function setRoutingState(path: string, routingState: RoutingState): void {
const oldUrl = new URL(routingState.url);
const newUrl = new URL(path, oldUrl);
const {segments, url} = getRoutingStateByPath(newUrl.toString())
routingState.segments = segments;
routingState.url = url;
}
The router outlet
We now have a config object, router state that can be read and written, and listeners that keep the state fresh on navigation. Plus a navigateTo() that pushes into the history object without forcing a page reload. What's left is rendering the matching component for the current path inside a router outlet.
Here's what our app component looks like:
// containers/app/app.tsx
export const App = component$((opts: { url: string | undefined }) => {
const routingState = initializeRouter(opts.url);
return (
<section>
... here comes the menu
<RouterOutlet/>
</section>
);
});
Time to build the <RouterOutlet/> component. It takes the URL segments and the routing config and matches them to a component.
// routing/router-outlet.tsx
import {component$, useContext} from '@builder.io/qwik';
import {ROUTING} from './routing-state';
import {getMatchingConfig,} from './routing';
import {routingConfig} from '../routing-config';
export const RouterOutlet = component$(
() => {
const routingState = useContext(ROUTING);
// render the correct component
return getMatchingConfig(routingState.segments, routingConfig)?.component
}
);
The getMatchingConfig() function does the heavy lifting: it takes the segments and config and returns the component to render. Matching isn't just about the path — it also needs to capture any params. Remember the config we wrote earlier?
{
path: 'users/:id',
component: <UserDetail/>
}
We won't dissect the next function line by line. Trust that it handles the mapping for us:
// routing/routing.ts
...
// go over all the RoutingConfigItem objects and if they match return the config
// so we know which compnent to render
export function getMatchingConfig(segments: string[], config: RoutingConfig): RoutingConfigItem {
const found = config.find(item => segmentsMatch(segments, item))
if (found) {
return found;
}
return null;
}
export function segmentsMatch(pathSegments: string[], configItem: RoutingConfigItem): boolean {
const configItemSegments = configItem.path.split('/');
if (configItemSegments.length !== pathSegments.length) {
return false;
}
const matches = pathSegments.filter((segment, index) => {
return segment === configItemSegments[index] || configItemSegments[index].indexOf(':') === 0
});
return matches.length === pathSegments.length;
}
At this point the app should work — the right component shows up for the right URL — but we're not done yet. That listenToRouteChanges() function still needs to be invoked. We could call it from <RouterOutlet/>, but only on the client, since the window object doesn't exist server-side. Qwik gives us useClientEffect$ exactly for this purpose. The outlet now looks like this:
import {component$, useClientEffect$, useContext} from '@builder.io/qwik';
import {ROUTING} from './routing-state';
import {getMatchingConfig, listenToRouteChanges} from './routing';
import {routingConfig} from '../routing-config';
export const RouterOutlet = component$(
() => {
const routingState = useContext(ROUTING);
useClientEffect$(() => {
listenToRouteChanges(routingState);
});
return getMatchingConfig(routingState.segments, routingConfig)?.component
}
);
The link component
A plain <a> tag triggers a full page refresh, which defeats the purpose of SPA routing. We need to use our navigateTo() instead. So we create a <Link/> component that renders an anchor tag, blocks the browser's default jump, and calls our navigation function on click. The preventdefault:click syntax stops the native navigation, but we still keep the href attribute for SEO. Inside the <a> we use a <Slot/> for content projection. Since navigateTo() depends on the routingState, we pull it in with useContext.
// routing/link.tsx
import {component$, Slot, useContext} from '@builder.io/qwik';
import {navigateTo} from './routing';
export const Link = component$((opts: { path: string }) => {
const routingState = useContext(ROUTING);
const {path} = opts;
// check whether the link should be active or not
const isActive = `/${routingState.segments.join('/')}` === path;
return (
<a
// This will prevent the default behavior of the "click" event.
preventdefault:click
// set the correct class when the link is active
className={isActive ? 'link--active' : ''}
href={path} onClick$={(e) => {
navigateTo(path, routingState)
}}><Slot/></a>
);
});
The .tsx file for the app component looks like this now:
<section>
<ul>
<li>
<Link path={'/'}>Home</Link>
</li>
<li>
<Link path={'/users'} >users</Link>
</li>
<li>
<Link path={'/users/1'}>Brecht</Link>
</li>
</ul>
<RouterOutlet/>
</section>
There it is — client-side SPA routing with param support, and it didn't take a mountain of code to get there. Two bits are still missing: extracting path params and search params.
The config has {path: 'users/:id'}, and a URL like users/1 should let us call getParams(routingState).id to get the string "1".
Two more functions go into routing/routing.ts:
// routing/routing.tsx
export function getParams(routingState: RoutingState): { [key: string]: string } {
const matchingConfig = getMatchingConfig(routingState.segments, routingConfig);
const params = matchingConfig.path.split('/')
.map((segment: string, index: number) => {
if (segment.startsWith(':')) {
return {
index,
paramName: segment.replace(':', '')
}
} else {
return undefined
}
})
.filter(v => !!v);
const returnObj: { [key: string]: string } = {};
params.forEach(param => {
returnObj[param.paramName] = routingState.segments[param.index]
})
return returnObj;
}
export function getSearchParams(routingState: RoutingState): URLSearchParams {
return new URL(routingState.url).searchParams;
}
Conclusion
And that's it — a full client-side SPA router in surprisingly few lines, and lazy loading comes along for free thanks to Qwik. I enjoyed building this and walked away having learned a lot. You'll likely see more Qwik content from me soon.
- The key insight was building a custom
Linkcomponent to intercept navigation and update the history object instead of letting the browser handle it. - Storing the URL object itself in state won't work — it's not serializable. Keeping the plain string is all you need.
- Matching a component to a route seemed tricky at first, but it turned out to be simple and clean.
- Routers don't need to be complicated. We got a working solution with minimal code.
useClientEffect$is perfect for running code only in the browser.- Nested router outlets looked easy initially, but I suspect they carry real complexity. Definitely something I want to explore next.
If you want to poke around, feel free to browse the demo source. I hope this sparked your interest too.
Thanks to the people who reviewed this:
