Preact without a build step, including routing and signals
Table of contents
Build tools add a lot of complexity. They solve real problems. But for small projects, they add layers of abstraction I don’t need. I want to write code and run it. No waiting for builds, no watching for changes, no debugging why the bundler failed.
I wanted to test the React API without the build step. Preact is a good candidate: lightweight, same API, and it ships ES modules you can load directly in the browser. Combined with import maps, you get clean imports without a bundler.
This post explains how to use Preact without any build step, including routing.
Setup
You can either import directly from a CDN URL or install the packages locally. I prefer local files so I can work offline and have more control.
npm install preact preact-iso htm @preact/signals @tailwindcss/browser
Then serve these files alongside your HTML and JavaScript assets. Use import maps to tell the browser where to find each module:
<script type="importmap">
{
"imports": {
"preact": "./node_modules/preact/dist/preact.mjs",
"preact/": "./node_modules/preact/",
"preact/hooks": "./node_modules/preact/hooks/dist/hooks.mjs",
"htm": "./node_modules/htm/dist/htm.mjs",
"htm/preact": "./node_modules/htm/preact/index.mjs",
"preact-iso": "./node_modules/preact-iso/src/index.js",
"@preact/signals": "./node_modules/@preact/signals/dist/signals.mjs",
"@preact/signals-core": "./node_modules/@preact/signals-core/dist/signals-core.mjs"
}
}
</script>
For CSS, I use Tailwind from the CDN. Avoids another build step, and performance is perfectly fine for most apps that don’t require very customized CSS setups.
Components and HTM
JSX requires a build step. HTM gives you the same syntax using JavaScript tagged templates:
import { render } from "preact";
import { html } from "htm/preact";
import { useState } from "preact/hooks";
function Counter() {
const [count, setCount] = useState(0);
return html`
<div class="counter">
<button onClick=${() => setCount(count - 1)}>-</button>
<span>${count}</span>
<button onClick=${() => setCount(count + 1)}>+</button>
</div>
`;
}
render(html`<${Counter} />`, document.getElementById("app"));
Components go inside ${}. The closing tag is <//>. Event handlers and props work like regular JSX.
You can compose components the same way:
function Button({ onClick, children }) {
return html` <button class="btn" onClick=${onClick}>${children}</button> `;
}
function App() {
return html`
<div>
<${Button} onClick=${() => alert("clicked")}>Click me<//>
</div>
`;
}
Routing
The Preact docs have a great no-build tutorial, but they don’t cover routing. It can be done too.
preact-iso provides a router that works without a build step. Import it and wrap your app:
import { render } from "preact";
import { html } from "htm/preact";
import { LocationProvider, Router, Route } from "preact-iso";
function Home() {
return html`<h1>Home</h1>`;
}
function About() {
return html`<h1>About</h1>`;
}
function NotFound() {
return html`<h1>404</h1>`;
}
function App() {
return html`
<${LocationProvider}>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
<${Router}>
<${Route} path="/" component=${Home} />
<${Route} path="/about" component=${About} />
<${Route} default component=${NotFound} />
<//>
<//>
`;
}
render(html`<${App} />`, document.getElementById("app"));
That’s a full single-page app with client-side routing. No bundler involved.
Signals
Preact Signals also work without a build step. Import them and use for reactive state:
import { signal, computed } from "@preact/signals";
import { html } from "htm/preact";
const count = signal(0);
const doubled = computed(() => count.value * 2);
function Counter() {
return html`
<div>
<p>Count: ${count}</p>
<p>Doubled: ${doubled}</p>
<button onClick=${() => count.value++}>Increment</button>
</div>
`;
}
Signals auto-update the DOM when values change. No need to call setState or re-render manually.
JS bundle size
To measure just the JavaScript assets, filter out browser extensions and Tailwind in DevTools:
-url:chrome-extension:/ -url:/@tailwindcss/browser
Regular (no compression):
- 39.8 kB transferred
- 37.2 kB resources
With gzip:
- 18.3 kB transferred
- 37.2 kB resources
This is tiny compared to current React bundles. The browser caches each module separately, so repeated visits are even faster.
Code
You can see a more complete example of this at polyrand/preact-nobuild-example.
Conclusion
Modern browsers can do a lot. Import maps give you module resolution without bundlers. ES modules work everywhere. Libraries like Preact and HTM work great with this setup. And you can have routing too.
You don’t need a build step for every project. Sometimes the simplest solution is the best one.
![rand[om]](/img/bike_m.png)