1. Project setup
We need a project to work in. Four commands and you'll have a dev server with hot reload and a working block on screen. After that, every chapter is just editing files.
Scaffold
npm create legodom@latest task-tracker
cd task-tracker
npm install
npm run devOpen http://localhost:5173. You should see the sample LegoDOM app. Click the count button. It increments. That's a .lego block in action, and it's about as straightforward as it looks.
What you got
task-tracker/
├── index.html ← entry HTML
├── vite.config.js ← Vite + LegoDOM plugin
├── package.json
└── src/
├── app.js ← entry script (registers blocks, calls Lego.init)
├── style.css ← global CSS (light DOM only, doesn't enter blocks)
└── my-app.lego ← sample blockTwo things to remember about this layout:
.legofiles live anywhere undersrc/. The Vite plugin discovers them via glob.- Filename becomes the tag.
user-card.legoregisters as<user-card>.UserCard.legoanduser_card.legoboth kebab-case to the same tag. Any filename that doesn't produce a hyphenated name is rejected (browser custom-element rules).
src/app.js
import { Lego } from 'lego-dom';
import registerBlocks from 'virtual:lego-blocks';
import './style.css';
registerBlocks();
Lego.init();Two imports do the heavy lifting. virtual:lego-blocks is generated by the plugin; importing it pulls in every .lego file in src/, so they all register on startup. Lego.init() then boots the engine.
index.html
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Task Tracker</title>
</head>
<body>
<my-app></my-app>
<script type="module" src="/src/app.js"></script>
</body>
</html>Notice what's not there: no mount API, no virtual root, no ReactDOM.createRoot. The <my-app> tag goes in the HTML as if it were a built-in element, because as far as the browser is concerned, it is one.
Verify HMR
Open src/my-app.lego, change the heading text, save.
The change appears with no page reload. The existing <my-app> element is unsnapped, the new template is registered, and the same element is re-snapped in place. Routes, scroll position, persistent state (anything you'll add in later chapters) survive the swap. This is HMR done right, and you'll appreciate it more once you have an app big enough that a full reload would lose context.
What Lego.init() is doing under the hood
Briefly, in case you're curious. You don't need to memorize this:
- Pre-loads any stylesheets you registered (none yet).
- Runs an initial route match against the URL so
$routeis correct on first paint (only matters once you have routes). - Registers any
<template b-id>it finds in your HTML as custom elements (your.legofiles have already done their own registration via the plugin). - Wires up
popstate,<a b-link|b-target>clicks, and<form b-action>submits. - Starts cross-tab
$dbsync.
Most of these don't matter to you yet. They exist so that the things you'll add in later chapters all just work.
Ready
A project, a dev server, a sample block. In the next chapter you'll throw the sample away and build the first real component of the task tracker.