3. Routing & layouts
Most routers swap the whole page when the URL changes. LegoDOM's router doesn't insist on that. You decide which parts follow the URL and which parts persist. Once you see it work, it's hard to want to go back.
By the end of this chapter you'll have a dashboard with a sidebar that never reloads, even as the content area swaps between three categories.
The shape we're building
┌──────────────────────────────────────────────┐
│ │
│ app-shell (mounts once, never reloads) │
│ ┌──────────┐ ┌──────────────────────────┐ │
│ │ sidebar │ │ #content │ │
│ │ │ │ │ │
│ │ All │ │ task-list / work-tasks │ │
│ │ Work │ │ / personal-tasks │ │
│ │ Personal │ │ │ │
│ └──────────┘ └──────────────────────────┘ │
└──────────────────────────────────────────────┘The sidebar is part of the shell. The content area is bound to the URL. Three URLs, three blocks, one shell.
Build the shell
Create src/app-shell.lego:
<script>
export default {
mounted() { console.log('shell mounted'); }
}
</script>
<template>
<aside class="sidebar">
<h2>Tracker</h2>
<nav>
<a href="/" b-link>All</a>
<a href="/work" b-link>Work</a>
<a href="/personal" b-link>Personal</a>
</nav>
</aside>
<lego-router id="content"></lego-router>
</template>
<style>
self { display: flex; height: 100vh; font-family: system-ui, sans-serif; }
.sidebar {
width: 220px; background: #1a1a2e; color: white; padding: 1.5rem;
display: flex; flex-direction: column;
}
.sidebar h2 { font-size: 1.25rem; margin: 0 0 2rem; color: #667eea; }
nav { display: flex; flex-direction: column; gap: .25rem; }
nav a {
color: rgba(255,255,255,.7); text-decoration: none;
padding: .6rem .75rem; border-radius: 6px;
}
nav a:hover { background: rgba(255,255,255,.1); color: #fff; }
main { flex: 1; padding: 2rem; overflow-y: auto; background: #f8f9fa; }
</style>The crucial element is this:
<lego-router id="content"></lego-router><lego-router> is the default outlet. When a link uses b-link (or you call $go(path).get() with no targets), the runtime renders the matched route's block into every <lego-router> it finds in the document. The id="content" is optional, but useful: it lets you also address this same outlet surgically later (b-target="#content") if a different code path needs to.
There's nothing magical about the tag name. It's literally a querySelectorAll('lego-router') under the hood. You can have many of them, or none and route every link with b-target="#selector" instead. We'll come back to that.
Two category blocks
src/work-tasks.lego:
<script>
export default {
tasks: [
{ title: 'Finish quarterly report', done: false },
{ title: 'Review PRs', done: true },
{ title: 'Update roadmap', done: false }
]
}
</script>
<template>
<h1>Work</h1>
<ul>
<li b-for="t in tasks">
<label>
<input type="checkbox" b-sync="t.done">
<span class="[[ t.done ? 'done' : '' ]]">[[ t.title ]]</span>
</label>
</li>
</ul>
</template>
<style>
h1 { color: #333; margin: 0 0 1.5rem; }
ul { list-style: none; padding: 0; }
li { padding: .75rem 0; border-bottom: 1px solid #eee; }
label { display: flex; gap: .75rem; align-items: center; cursor: pointer; }
.done { text-decoration: line-through; opacity: .5; }
</style>Copy that file to src/personal-tasks.lego with personal tasks (groceries, dentist, whatever). The markup is identical.
Wire up the routes
Update src/app.js:
import { Lego } from 'lego-dom';
import registerBlocks from 'virtual:lego-blocks';
import './style.css';
registerBlocks();
Lego.route('/', 'task-list');
Lego.route('/work', 'work-tasks');
Lego.route('/personal', 'personal-tasks');
Lego.init();Update index.html:
<body>
<app-shell></app-shell>
<script type="module" src="/src/app.js"></script>
</body>Click around
Click between the three sidebar links. Three things happen:
- The URL changes (
/,/work,/personal). - The content area swaps to the matched block.
- The shell never re-renders. Open the console and watch
shell mountedfire exactly once, no matter how many times you click.
That third point is the difference between this and most routing setups you've used. The sidebar's scroll position, any focus state, any in-progress form input: none of it moves. The shell is a single mount that lives for the lifetime of the page. Only the content swaps.
Programmatic navigation
Inside any block, this.$go(path, ...targets).get() does the same thing as a b-link click:
{
goWork() {
this.$go('/work').get();
}
}get(false) performs the swap without updating the URL, useful for tab-like UIs you don't want to bookmark. The verb (get, post, put, patch, delete) only changes what gets stored in history.state for back-button replay; the route table still resolves by path.
Dynamic routes
URL segments can be captured:
Lego.route('/task/:id', 'task-detail');// task-detail.lego
mounted() {
const id = this.$route.params.id;
this.task = this.allTasks.find(t => String(t.id) === id);
}$route is reactive. Any block reading [[ $route.params.id ]] re-renders automatically when navigation changes the value, so a single <task-detail> block can survive across /task/1 → /task/2 without unmounting. (It will still re-render the bindings that read the param.)
Building something complex: the persistent shell
When the app gets bigger you'll typically want one main stage plus a few surgical regions that update independently. The architecture maps directly to three different selectors:
<aside id="left">
<file-tree></file-tree> <!-- never reloads -->
</aside>
<lego-router id="main"></lego-router> <!-- default outlet, swaps with the URL -->
<aside id="right"></aside> <!-- contextual tools, surgical --><!-- main navigation: uses the default outlet (which is also #main) -->
<a href="/dashboard" b-link>Dashboard</a>
<a href="/files" b-link>Files</a>
<!-- contextual tools: surgical, no URL change -->
<a href="/tools/inspector" b-target="#right" b-link="false">Inspector</a>
<!-- two outlets at once: a master/detail layout -->
<a href="/orders/42" b-target="#main #right">Order 42</a>Three rules of thumb when you build a real shell:
- One
<lego-router>for the URL-bound stage. Cold entry needs it (the initial_matchRoutecall defaults tolego-router). If you don't have one, you'd have to seed the cold-entry render yourself frommounted(). b-targetfor anything not URL-driven. Side panels, tool panes, modals. Pair withb-link="false"to keep the URL clean.- Don't make routes conditional on selectors.
Lego.route('/x', 'foo-page')registers a path → block mapping. The selector lives on the link or the$go()call. The same route can render into different outlets from different callsites.
Quick reference
| I want… | Code |
|---|---|
Default link (renders into all <lego-router> elements) | <a href="/x" b-link> |
| Link that swaps a specific region | <a href="/x" b-target="#id"> |
| Link that updates the URL but renders elsewhere | <a href="/x" b-target="#id" b-link> |
| Link that swaps a region without changing the URL | <a href="/x" b-target="#id" b-link="false"> |
| Multiple targets at once | <a href="/x" b-target="#a #b"> |
| Programmatic equivalent | this.$go('/x', '#a', '#b').get(/* push? */) |
| Read URL params / query | this.$route.params.id, this.$route.query.tab |
That's routing. The hard part isn't the API; it's getting your head around "the route doesn't own the page, only the part you point it at". Once that lands, the rest is composition.