A zero-dependency web component framework with SSR, HMR, bundling, and i18n — built entirely on Node.js built-ins and native browser APIs.
No Vite. No Webpack. No React. No npm dependencies at all.
Takeover is a full-stack web application framework built around the Web Components standard (Custom Elements + Shadow DOM). It handles the full lifecycle:
- Dev server — native ESM serving with WebSocket-based HMR
- SSR — server-side rendering with Declarative Shadow DOM, client hydration (plus a static CSR-only build target — see Deployment)
- Bundler — traces static
importgraphs and emits a single hashed bundle - Minifier — token-aware JS minifier + CSS minifier, both pure Node.js
- i18n — reactive locale switching (EN / ES / FR) with SSR-first locale detection
- Routing — file-system routing from the
app/directory - State — reactive
EventTarget-based store with per-key subscriptions
Everything runs on node --version ≥ 18. There is no node_modules.
# Dev server with HMR
yarn dev # or: node core/server/index.js
# Production build (bundle + minify + hash assets)
yarn build # or: node core/server/build.js
# Serve the production build locally
yarn preview # or: NODE_ENV=production node core/server/index.js
# Remove dist/
yarn cleanDev server starts at http://localhost:3000. Set PORT=xxxx to change it.
takeover/
├── app/ # Pages (file-system routed)
│ ├── _Layout/ # Root layout (app-layout element; _ = not a route)
│ ├── Home/ # → /
│ └── NotFound/ # → /notfound (also the wildcard 404)
├── components/ # Shared components
│ ├── Router/ # <app-router> — client-side navigation + outlet
│ ├── Navigation/ Logo/ Footer/
│ ├── HeroCounter/ TriangleSeam/
│ └── HomeQuickStart/ HomeDemos/ HomePerformance/
│ HomeFeatures/ HomeStructure/ HomeArchitecture/ HomeCTA/
├── core/
│ ├── component.js # Base Component class
│ ├── config.js # app.config.yml loader (zero-dep YAML + autodiscovery)
│ ├── context.js # Store (EventTarget Proxy)
│ ├── loader.js # Auto-loader (MutationObserver + IntersectionObserver)
│ ├── locale.js # Shared locale negotiation (cookie + Accept-Language)
│ ├── routes.js # Route matching + path helpers
│ ├── scan.js # Directory scanner: routes + tag registry
│ ├── tags.js # Tag → source directory resolution
│ ├── template.js # Template engine (expressions, each, if)
│ └── server/
│ ├── index.js # Dev/prod HTTP server + HMR
│ ├── build.js # Production build pipeline
│ ├── bundle.js # Zero-dep ESM bundler
│ ├── minify.js # Zero-dep JS + CSS minifier
│ ├── static.js # Zero-dep static server (CSR preview, SPA catch-all)
│ ├── entry-client.js # Browser entry point
│ ├── entry-server.js # SSR entry point
│ ├── ssr.js # Shared rendering logic
│ └── ws.js # WebSocket server (no ws package)
├── lib/
│ ├── async.js # Async helpers
│ ├── i18n.js # Locale loading + t() helper
│ ├── index.js # Public API barrel
│ ├── meta.js # Head metadata utilities
│ ├── nav.js # Navigation helpers (navigate, replace, getQuery…)
│ ├── store.js # App-level store instance
│ └── validate.js # Validation helpers
├── locales/
│ ├── en.json # English
│ ├── es.json # Spanish
│ └── fr.json # French
├── deploy/
│ ├── cloudflare/_worker.js # Cloudflare Pages Worker (SSR)
│ └── netlify/functions/ssr.mjs # Netlify Function (SSR)
├── app.config.yml # Per-project config (locales, preloads)
├── globals.css # Global CSS custom properties + reset
└── index.html # Shell HTML (comment placeholders for SSR)
core/ is the framework and carries no project-specific values. Everything an
individual site tunes lives outside it — app/, components/, lib/, locales/,
globals.css, and app.config.yml. Treat core/ as a dependency you drop into a
new project unchanged.
Project-specific settings live in app.config.yml at the repo root. Core reads
it through core/config.js (a minimal zero-dependency YAML parser). Every key is
optional — omit one and the loader autodiscovers a sensible default. Delete the file
entirely and the framework still runs with discovered locales and no preloads.
locales:
# Supported locale codes. Omit, or set `auto`, to discover from locales/*.json.
supported: [en, es, fr]
# Locale used when a request matches none of the above.
default: es
preload:
# Same-origin woff2 fetched in parallel with HTML parse (@font-face in globals.css).
# Omit → no font preloads. Use `auto` to preload every woff2 under public/fonts/.
fonts:
- /fonts/geist-latin.woff2
- /fonts/geist-mono-latin.woff2
# Extra dev-server modulepreloads (production inlines the core bundle, so these
# only matter for `yarn dev`). Core's own /core/* modules are always included.
modules:
- /components/Router/Router.js
- /lib/store.js
- /lib/nav.js| Key | Default when omitted |
|---|---|
locales.supported |
discovered from locales/*.json |
locales.default |
first supported locale |
preload.fonts |
none (empty) — or every public/fonts/*.woff2 if set to auto |
preload.modules |
none (core /core/* modules are always preloaded) |
Font/module preloads are a curated performance decision (preloading every font hurts LCP), so they default to empty — list only what genuinely blocks first paint.
The build copies app.config.yml into dist/ so production SSR (and the Cloudflare /
Netlify adapters) negotiate locales from the same source. The supported/default
locales are also inlined as window.__LOCALE_CONFIG__ so the client i18n layer
matches the server without hardcoding the list.
Pages live in app/. The directory name maps directly to the route:
| Directory | Route |
|---|---|
app/Home/ |
/ |
app/NotFound/ |
/notfound + wildcard 404 |
app/About/ |
/about |
app/Users/[id]/User/ |
/users/:id/user |
(The first two ship with this repo; the last two show the naming rules.) Folders and
files starting with _ are skipped, which is what keeps _Layout out of the route table.
Each page is a folder with two files:
app/About/
About.html ← template (Shadow DOM content + optional <script> block)
About.js ← component class (optional if script is embedded in .html)
The script can live inside a <script> tag at the bottom of the .html file (extracted at build time) or in a separate .js file alongside it.
<!-- app/About/About.html -->
<style>
:host { display: block; }
h1 { color: var(--primary-color); }
</style>
<h1>{{t.nav.about}}</h1>
<p>{{description}}</p>
<script>
import { Component, define } from '/core/component.js';
export default class AboutPage extends Component {
static templateUrl = '/app/About/About.html';
static store = ['user'];
static metadata = { title: 'About' };
static ssrProps = { description: 'We build things.' };
}
define('about-page', AboutPage);
</script>Shared components live in components/. They follow the same two-file pattern as pages but are loaded lazily by core/loader.js via MutationObserver — as soon as a custom element tag appears in the DOM, the corresponding JS is fetched and registered.
Add loading="lazy" to defer that import until the element nears the viewport
(IntersectionObserver, 200px margin). Server-rendered content stays visible throughout;
only the JS upgrade waits.
<app-footer loading="lazy"></app-footer>Resolution is discovered, not configured. At dev-server start and at build time,
core/scan.js walks app/ and components/ for define('<tag>', …) calls and maps each
tag to the directory that declares it. The result is inlined as window.__TAGS__ for the
browser loader, passed directly to the Node SSR renderer, and written to
_ssr-config.json for the edge adapters — one registry, three consumers, no hand-kept
override table anywhere.
components/HomeCTA/HomeCTA.js → define('home-cta', …) → <home-cta>
components/HomeQuickStart/… → define('home-quickstart',…) → <home-quickstart>
app/NotFound/NotFound.js → define('notfound-page', …) → <notfound-page>
Because the folder is read off the define() call, names that don't round-trip through
kebab↔PascalCase — acronyms (HomeCTA), compound words (HomeQuickStart), internal
capitals (NotFound) — need no special handling. If a tag is missing from the registry
(defined at runtime, say), resolution falls back to the convention: <foo-bar> →
components/FooBar/FooBar.js, <foo-page> → app/Foo/Foo.js.
import { Component, define, store } from '/core/component.js';
export default class MyWidget extends Component {
// URL of the HTML template (Shadow DOM content)
static templateUrl = '/components/MyWidget/MyWidget.html';
// CSS Module — class names are scoped to this element's tag.
// Optional: a sibling MyWidget.module.css is picked up automatically.
static cssModule = '/components/MyWidget/MyWidget.module.css';
// Store keys to subscribe to — re-renders on change
static store = ['user', 'theme'];
// Initial local (per-instance) state — cloned for each instance
static local = { count: 0, open: false };
// Called after Shadow DOM is ready and template is rendered
bind() {
this.on('#btn', 'click', () => this.local.count++);
this.on('form', 'submit', e => this.handleSubmit(e));
this.delegate('click', '.item', (el, e) => console.log(el));
}
// Called when element connects to DOM
mount() {}
// Called when element disconnects
unmount() {}
handleSubmit(e) {
e.preventDefault();
const data = this.getFormData();
this.withLoading(() => submitData(data));
}
}
define('my-widget', MyWidget);| Method | Description |
|---|---|
this.$(sel) |
shadowRoot.querySelector |
this.$$(sel) |
shadowRoot.querySelectorAll → Array |
this.on(target, event, fn) |
Adds event listener, auto-removed on disconnect |
this.emit(name, detail) |
Dispatches a composed CustomEvent |
this.delegate(evt, sel, fn) |
Event delegation on shadow root |
this.cx(...args) |
CSS class helper: strings, objects, arrays → scoped class string |
this.batch(fn) |
Run multiple local mutations with a single re-render |
this.withLoading(fn, key?) |
Sets local[key] true while async fn runs |
this.bindForm(fields) |
Bind <input> elements to local state keys |
this.getFormData(sel?) |
Read form as plain object via FormData |
Two options, usable together:
Inline <style> in the template. Simplest, and what most components here do. The block
is hoisted into the shadow root, so selectors are already isolated — no scoping needed.
A CSS Module file. Put MyWidget.module.css next to MyWidget.html and it is
discovered automatically (no declaration required). Class names get the element's tag
appended — .inner → .inner_app-footer — so templates reference them through $css:
<div class="{{$css.inner}}">
<span class="{{$c('badge', 'active')}}">…</span>
</div>bind() { this.$('.x')?.classList.add(this.cx('active')); } // cx() maps names toocomponents/Footer/ is a worked example. A plain MyWidget.css (no .module) is also
picked up and used unscoped, and can coexist with a module sheet.
Element, :host and pseudo selectors are left alone by the scoping pass — the shadow
boundary already isolates those. The pass is a regex over the whole file and does not skip
comments, so avoid dotted names inside CSS comments.
In production the build folds the stylesheet's source into the component class, so a CSS Module costs no extra request at runtime; SSR embeds the same scoped CSS in the shadow root.
Templates use {{ }} expressions evaluated against props (store state + local state + pageProps + t for translations):
<!-- Interpolation (HTML-escaped) -->
<p>{{user.name}}</p>
<!-- Unescaped -->
<p>{{{rawHtml}}}</p>
<!-- Conditionals -->
{{#if isAuthenticated}}
<span>{{user.username}}</span>
{{else}}
<a href="/login" route>Login</a>
{{/if}}
<!-- Loops -->
{{#each items}}
<li>{{this.name}} — {{@index}}</li>
{{/each}}
<!-- Ternary -->
<span>{{theme === 'dark' ? '☀️' : '🌙'}}</span>
<!-- CSS Modules -->
<div class="{{$css.card}}">
<div class="{{$c('card', 'active')}}">
<!-- Translation -->
<span>{{t.nav.home}}</span>Prop bindings pass JavaScript values (not strings) to child custom elements:
<my-widget :count="localCount" :user="user"></my-widget>The store is a Proxy-wrapped EventTarget. Changes fire change and change:<key> events.
import store from '/lib/store.js';
// Read
store.get() // full state snapshot
store.get('user') // single key
// Write
store.set({ counter: 5, user: { name: 'Alice' } });
store.update('counter', n => n + 1);
store.toggle('sidebarOpen');
store.reset('counter'); // back to default
store.reset(); // all keys to defaults
// Subscribe (returns unsubscribe fn)
const unsub = store.on('counter', (value, oldValue) => {
console.log('counter changed', value);
});
unsub();
// Merge into the page metadata key (the only action lib/store.js adds)
store.setMeta({ title: 'My Page' });The store ships deliberately bare: lib/store.js defines the defaults
({ meta, locale, messages }) and setMeta. Application actions — auth, theme, anything
domain-specific — belong in your own module on top of it, not in the framework.
Nested paths work too: store.state.user.name = 'x' fires change:user.name.
Components subscribe declaratively via static store = ['key1', 'key2'] and re-render automatically when any subscribed key changes.
Three locales are included: English, Spanish, French.
t is the messages object for the current locale, injected into every component's props automatically:
<a href="/" route>{{t.nav.home}}</a>
<button>{{t.auth.login}}</button>
<span>{{theme === 'dark' ? t.theme.light : t.theme.dark}}</span>import { t, setLocale, getLocale, initLocale } from '/lib/i18n.js';
t('nav.home') // → 'Home'
t('footer.copyright', { year: 2026 }) // → '© 2026 Web Components App'
setLocale('es'); // async — fetches + updates store
getLocale(); // → 'es'- Create
locales/de.jsonfollowing the same shape asen.json - Add
detolocales.supportedinapp.config.yml(or rely on autodiscovery — omit the key / setsupported: autoand the new file is picked up automatically) - Add a switch control in
components/Navigation/Navigation.html+.js(the language buttons there callsetLocale('en' | 'es' | 'fr'))
The supported list and default locale are defined once in app.config.yml; the
server, the deploy adapters, and the client i18n layer all read from it (no
hardcoded locale array in lib/i18n.js).
core/locale.js implements negotiation once and every SSR host uses it — the dev/prod
server, the Netlify function and the Cloudflare worker — so all three resolve the same
language for the same request. The locale cookie (an explicit user choice) wins over
Accept-Language, which is ranked by q-value; anything unsupported falls back to
locales.default.
The negotiated locale pre-renders the page, and three globals are inlined so the client
never flashes: __INITIAL_STATE__ (active locale + its messages), __LOCALES__ (the other
supported catalogues, so a switch needs no fetch) and __LOCALE_CONFIG__ (the supported
list, so client negotiation matches the server's). Responses are sent with
Vary: Accept-Language, Cookie.
yarn buildOutput in dist/client/:
dist/client/
├── _assets/
│ └── core.[hash].js # Bundled framework (also inlined into the HTML)
├── _template.html # SSR HTML shell (per-request placeholders left empty)
├── index.html # CSR shell (placeholders pre-filled at build time)
├── _assets-manifest.json # original URL → content-hashed URL (inlined as __M__)
├── _ssr-config.json # locales + tag registry, for edge adapters
├── _manifest.json # Build manifest (debugging)
├── _worker.js # Cloudflare Pages Worker
├── app/ # Minified, content-hashed page modules
├── components/ # Minified, content-hashed component modules
├── core/ # Minified framework files
├── lib/ # Minified utilities
├── locales/ # Locale JSON files
└── routes.json
The bundler (core/server/bundle.js) traces all static import chains from entry-client.js and emits a single IIFE with a minimal module registry — eliminating 9+ separate module requests on the critical path. Dynamic import() calls (used by the router for route-level code splitting) are left intact with resolved paths.
The minifier (core/server/minify.js) uses a character-level tokenizer that correctly handles template literals, strings, regex literals, and comments. It produces ~35–50% size reductions without identifier mangling.
Templates and stylesheets are inlined into their component class at build time
(static template, static cssModuleText / static cssText), so a component costs no
template or CSS request once its module has loaded.
Assets get content-addressed filenames (core.d6d6fad4.js) for long-lived caching. Lazy
page/component modules are hashed too, and the original → hashed map is inlined as
window.__M__, which is what makes max-age=31536000, immutable safe for /app/* and
/components/*.
The dev server watches app/, components/, core/, and lib/ with fs.watch (recursive). On change it sends a WebSocket message to all connected browsers with three possible strategies:
| Change | Strategy |
|---|---|
.css file |
Hot-swap — refetches stylesheet/globals without reload |
core/ or lib/ file |
Full reload (browser already cached the module) |
| Component or app file | Re-imports with ?t= cache-bust, reconnects element; falls back to reload |
A 50ms debounce prevents duplicate triggers from editor temp-file writes.
Use the route attribute on <a> tags for client-side navigation (the Router intercepts clicks):
<a href="/about" route>About</a>From JavaScript:
import { navigate, replace, back, getQuery, setQuery } from '/lib/nav.js';
navigate('/dashboard');
replace('/login?from=/dashboard');
back();
getQuery(); // → { from: '/dashboard' }
setQuery({ tab: 'profile' });Route lifecycle hooks on the Router:
import Router from '/components/Router/Router.js';
Router.beforeEach = async (to, from) => {
if (needsAuth(to.path) && !isLoggedIn()) return '/login';
};
Router.afterEach = (to, from) => analytics.track(to.path);
Router.onError = (err, to) => console.error(err);yarn deploy:cloudflare # runs build then wrangler pages deployThe Cloudflare Worker (deploy/cloudflare/_worker.js) handles SSR at the edge and delegates
static assets to the Pages asset store. It has no node:fs, so it can't read
app.config.yml or walk the source tree — the build emits _ssr-config.json (supported
locales + tag registry) for it, and negotiation itself is the shared core/locale.js. Net
effect: the edge renders the same locale, the same components and the same 404 fallback as
the Node server.
Configure via netlify.toml. Static assets are served directly; all other requests hit the
SSR Netlify Function in deploy/netlify/functions/ssr.mjs, which runs against dist/server
(the .mjs copy of the tree).
SSR is the default, but the same yarn build also emits a client-rendered shell at
dist/client/index.html for hosts with no SSR runtime (GitHub Pages, S3, a CDN bucket).
It's the SSR template with the per-request placeholders pre-filled: empty app-layout,
no __INITIAL_STATE__, only window.__LOCALE_CONFIG__ inlined so client i18n negotiates
the same locales the server would. On load the Router finds an empty outlet and renders
the route in the browser — the identical code path SSR pages take after hydration
(core/component.js falls back to attachShadow() + update() when no Declarative
Shadow DOM is present).
Build and preview it locally with two commands:
yarn build # emits dist/client/index.html (the CSR shell) alongside the SSR template
yarn preview:csr # zero-dep static server → http://localhost:4399 (PORT/CSR_ROOT override)yarn preview:csr (core/server/static.js) serves dist/client/ with a SPA catch-all —
any extension-less path that isn't a real file falls back to /index.html, so deep links
like /about resolve and the Router renders them client-side. On a real host you express
the same rule as a rewrite: Netlify /* /index.html 200, Cloudflare Pages _redirects,
nginx try_files $uri /index.html. The SSR adapters keep using _template.html and are
unaffected — CSR is purely additive.
What you trade away vs. SSR: no prerendered HTML (blank first paint until the bundle
runs), no server locale negotiation (the client fetches /locales/<lang>.json on boot),
and crawlers without JS see an empty shell. Use it where an SSR runtime isn't available;
prefer SSR everywhere else.
- No virtual DOM. Components re-render their shadow root's
innerHTMLdirectly. Focus state is preserved across re-renders by trackingactiveElementbefore and restoring it after. - No hydration mismatch. SSR uses Declarative Shadow DOM (
<template shadowrootmode="open">). The browser attaches shadow roots before JS runs, so hydration is just event binding — the DOM is never replaced. - No build tool dependencies. The bundler, minifier, WebSocket server, file watcher, and HTTP server are all implemented against Node.js built-ins (
node:fs,node:http,node:crypto,node:path). - CSS Modules without PostCSS. Class names are scoped by appending the element's tag name as a suffix (
.card→.card_my-widget) via a regex pass at load time. The same transform runs server-side for SSR. - One implementation per cross-cutting concern. Tag→directory resolution (
core/tags.js, fed by a discovered registry), locale negotiation (core/locale.js) and the 404 fallback route (withNotFoundincore/routes.js) are each written once and shared by the browser, the Node server and every deploy adapter. Anything duplicated per host drifts. - Core stays project-agnostic. No component names, locale codes or route paths are
hardcoded in
core/— they're discovered from the source tree or read fromapp.config.yml, which is what makescore/droppable into a new project unchanged.