
HTML Zero-Build: Web Components + Import Map Without a Bundler
Every new frontend project starts the same way: install a bundler, configure plugins, wait for builds. For big apps, that cost is worth it. For a landing page, a demo, or a small internal tool, it’s pure overhead.
Modern browsers already handle most of what bundlers used to be mandatory for. Native ES modules, import maps, and Web Components cover simple pages with no router, reusable UI, and third-party dependencies. No npm run dev, no dev server to babysit, no 200 MB node_modules.
Below is the smallest setup that actually works day to day, plus an honest take on when to switch to Vite.
Why Zero-Build Works Now
Three browser features changed the math:
- ES modules in HTML.
<script type="module">loadsimport/exportdirectly. No transpiling for modern syntax. - Import maps. A small JSON block maps bare specifiers like
litto real URLs. No resolver needed. - Web Components.
CustomElementRegistry, Shadow DOM, and<template>/<slot>give scoped, reusable components without a framework.
All evergreen browsers support this trio. If your readers are on modern Chrome, Edge, Firefox, or Safari, there’s nothing to polyfill.
Project Shape: Three Files
No scaffolder needed:
zero-build/
├── index.html
├── components/
│ └── x-card.js
└── styles.css
index.html is the entry point, the manifest, and the dependency config all at once.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Zero-Build Demo</title>
<link rel="stylesheet" href="./styles.css" />
<script type="importmap">
{
"imports": {
"lit": "https://esm.sh/lit@3.2.1",
"lit/": "https://esm.sh/lit@3.2.1/"
}
}
</script>
</head>
<body>
<x-card title="Hello, no bundler">
<p>This card is a native custom element.</p>
</x-card>
<script type="module" src="./components/x-card.js"></script>
</body>
</html>
Serve the folder with anything static (npx serve, python3 -m http.server, VS Code Live Server) and it just runs. You can even double-click the file to open it, though fetch calls need a real http:// server.
Demo: A Scoped x-card in 30 Lines
Here’s the full component, no framework:
class XCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host { display: block; border: 1px solid #e5e7eb; border-radius: 12px; padding: 1rem; }
h2 { margin: 0 0 0.5rem; font-size: 1.125rem; }
</style>
<h2></h2>
<slot></slot>
`;
}
connectedCallback() {
this.shadowRoot.querySelector('h2').textContent =
this.getAttribute('title') ?? 'Untitled';
}
}
customElements.define('x-card', XCard);
The browser handles the boring parts for you:
- Style scoping via Shadow DOM. Page CSS can’t accidentally break the card.
- Composition via
<slot>. Any light-DOM children render inside. - Lifecycle via
connectedCallback/disconnectedCallback/attributeChangedCallback. Enough for most widgets.
Need reactivity? A tiny CustomEvent bus or a 10-line store beats pulling in a full state library:
// components/store.js
export const store = {
count: 0,
listeners: new Set(),
increment() {
this.count++;
this.listeners.forEach((fn) => fn(this.count));
},
subscribe(fn) {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
},
};
Import it from any component with a relative path. No alias config, no barrel files.
Import Maps: Dependencies Without node_modules
Import maps translate bare imports to URLs:
{
"imports": {
"date-fns": "https://esm.sh/date-fns@4.1.0",
"lit": "https://esm.sh/lit@3.2.1"
}
}
Then in your module:
import { format } from 'date-fns';
import { LitElement, html } from 'lit';
console.log(format(new Date(), 'yyyy-MM-dd'));
Three habits that keep this setup from biting you:
- Pin versions.
lit@3.2.1, neverlit@latest. Unpinned CDN URLs break on minor releases. - One CDN per project. Mixing
esm.sh,jsdelivr, andunpkgduplicates dependencies. - Vendor for prod. Before anything customer-facing, download the files into
./vendor/with a quickcurl. Then a CDN hiccup can’t take your page down.
Data Fetching Without a Framework
Most small pages boil down to a list, a form, and a fetch call. Native APIs cover all three:
const controller = new AbortController();
async function loadUsers(query) {
controller.abort();
const res = await fetch(`/api/users?q=${query}`, {
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
Pair with <template> cloning for rendering, or bring in Lit once templates get messy. Hold off on a router until you have real routes. Anchor links plus a couple of hidden toggles are plenty for simple pages.
Limits: When to Reach for Vite
Where zero-build falls short:
- No TypeScript / JSX. Browsers run JS + CSS + HTML. Types need a build step.
- No tree-shaking. You ship whole ESM files. Fine at 20 KB, painful at 2 MB.
- No image pipeline. No responsive
srcsetgeneration, no AVIF conversion, no hashing. Vite plus an image plugin wins here. - No SSR. SEO-critical or first-paint-critical pages still want server HTML.
Rule of thumb: one page, under five components, under 100 KB of JS. Past that, spinning up Vite is worth the setup.
Final Thoughts
Bundler when needed, not by default. For small stuff, three files plus an import map ship faster and teach the platform. Outgrow it and you’ll know exactly why Vite exists.