@emkodev/emroute
v1.12.4
Published
File-based (but storage-agnostic) router with triple rendering (SPA, SSR HTML, SSR Markdown). Zero dependencies.
Maintainers
Readme
Every route renders three ways from the same component: as a Single Page App
in the browser, as server-rendered HTML, and as plain Markdown. No
separate API layer needed — prefix any route with /md/ and get text that LLMs,
scripts, and curl can consume directly.
GET /projects/42 → SPA (hydrated in browser)
GET /html/projects/42 → pre-rendered HTML
GET /md/projects/42 → plain MarkdownInstall
npm add @emkodev/emroute # or bun add, pnpm add, yarn addWorks on Node, Bun, and Deno. Node uses compiled JS; Bun and Deno use TypeScript source directly.
For markdown rendering, add @emkodev/emkoma (built for emroute) or bring your own — marked and markdown-it both work.
How It Works
One component, three rendering paths:
The SPA and SSR HTML flows both call renderHTML() — same output, different
delivery. The SSR Markdown flow calls renderMarkdown() instead, bypassing
HTML entirely for plain text output.
Routes are files. The filesystem is the config.
routes/
index.page.md → /
about.page.html → /about
projects.page.md → /projects
projects/
[id].page.ts → /projects/:id
[id]/
tasks.page.ts → /projects/:id/tasks
404.page.html → not found
index.error.ts → root error handlerA route can be a .md file, an .html template, a .ts component, or a
combination. When a .page.ts exists, it controls data fetching and rendering.
When it doesn't, the framework renders the .html or .md file directly.
import { PageComponent } from '@emkodev/emroute';
class ProjectPage extends PageComponent<{ id: string }, ProjectData> {
override readonly name = 'project';
override async getData({ params }: this['DataArgs']) {
const res = await fetch(`/api/projects/${params.id}`);
return res.json();
}
override renderHTML({ data, params, context }: this['RenderArgs']) {
// context.files.html has the companion .page.html template if it exists
const template = context.files?.html ?? `<h1>${data.name}</h1>`;
return template.replaceAll('{{id}}', params.id) + '<router-slot></router-slot>';
}
override renderMarkdown({ data, context }: this['RenderArgs']) {
// context.files.md has the companion .page.md content if it exists
return context.files?.md ?? `# ${data.name}\n\nStatus: ${data.status}`;
}
}
export default new ProjectPage();Features
- File-based routing with dynamic segments (
[id]), catch-all directories, and nested layouts via<router-slot>. Routes follow REST conventions: exact routes are terminal resources, catch-all directories own their namespace - Triple rendering — SPA, SSR HTML, SSR Markdown from one component
- Companion files —
.page.html,.page.md,.page.cssloaded automatically and passed through context - Widgets — interactive islands with their own data lifecycle, error handling, and optional file companions (
.html,.md,.css). Auto-discovered from awidgets/directory or registered manually.this.elementgives opt-in DOM access in the browser.<widget-foo lazy>defers loading until visible viaIntersectionObserver - View Transitions — SPA route changes animate via
document.startViewTransition(). Progressive enhancement with CSS-only customization - Scoped CSS — companion
.widget.cssfiles auto-wrapped in@scope (widget-{name}) { ... } - Shadow DOM — unified Declarative Shadow DOM architecture for SSR and SPA. Widgets render into shadow roots for true CSS encapsulation and Web Components spec compliance
- SSR hydration — server-rendered HTML adopted by the SPA without re-rendering. Widgets can implement
hydrate(args)to attach event listeners after SSR adoption, receiving{ data, params, context } - Error boundaries — scoped error handlers per route prefix, plus status pages (
404.page.html) and a root fallback - Extensible context — inject app-level services (RPC clients, auth, feature flags) into every component via
extendContexton the router. Type-safe access through module augmentation or a per-component generic - Declarative overlays — popovers, modals, and toasts with zero JS via Invoker Commands API and CSS keyframe animations. Programmatic API available for dynamic content
- Zero dependencies — native APIs only (URLPattern, custom elements, Navigation API). No framework runtime, no virtual DOM, no build-time magic
- Pluggable markdown —
<mark-down>custom element with a swappable parser interface; bring your own renderer - Redirects — declarative
.redirect.tsfiles with 301/302 support - Configurable base paths —
/html/and/md/prefixes are configurable viaBasePath - SPA modes —
'root'(default),'leaf','none', or'only'to control how the server handles non-file requests and SSR endpoints - Sitemap generation — opt-in
sitemap.xmlfrom the routes manifest with support for dynamic route enumerators - On-the-fly transpilation —
BunFsRuntimeserves.tsfiles as transpiled JavaScript with companion files inlined. No build step required for development.buildClientBundles()is an optional production optimization
Runtimes
The router is storage-agnostic — it reads routes through a Runtime abstraction,
not the filesystem directly. emroute ships four runtimes:
BunFsRuntime— Bun-native APIs (Bun.file(),Bun.write(),Bun.Transpiler). The default choice for Bun projects.UniversalFsRuntime—node:APIs only. Works on Node, Deno, and Bun.BunSqliteRuntime— stores routes in a SQLite database. Proves the storage-agnostic design: no filesystem needed.FetchRuntime— browser runtime that fetches files from a remote server. Powers the SPA inrootandonlymodes.
Bun runs TypeScript source directly. Node and Deno use the compiled JS from
dist/. See ADR-0017 for
the full analysis.
Getting Started
Pick your runtime: Bun | Node | Deno
Documentation
- Setup — Bun, Node, Deno
- First route — route files and rendering modes
- Pages — page components, companion files, data fetching
- Routing — dynamic segments, catch-all, redirects
- Nesting — layouts, slots, passthrough pages, tips and tricks
- Widgets — interactive islands with data lifecycle
- Server —
Emroute.create, composition, static files - Markdown renderers — pluggable parser interface and setup
- Runtime — abstract runtime, UniversalFsRuntime, BunFsRuntime, BunSqliteRuntime
- SPA modes — none, leaf, root, only
- Error handling — widget errors, boundaries, status pages
- Shadow DOM — unified architecture, SSR hydration
- Hono integration — using emroute with Hono
For contributors and architects
- Architectural decisions — ADR-0001 through ADR-0017
