A fullstack web framework for web components, powered by Nitro.
- Framework adapters — choose Lit (default), FAST Element, or Elena via
--adapter - File-based routing —
pages/index.ts→/,pages/blog/[slug].ts→/blog/:slug - Server-side rendering — Declarative Shadow DOM (Lit/FAST) or light DOM (Elena)
- Client hydration —
LitroRouter(URLPattern-based) takes over after SSR with no flicker - Server-side data fetching —
definePageData()runs on the server before render - Content layer —
litro:contentvirtual module for Markdown blogs with 11ty-compatible frontmatter - Recipe-based scaffolding —
fullstack,11ty-blog, andstarlightrecipes vianpm create @beatzball/litro - API routes — plain
server/api/files, H3 handlers, no framework overhead - One port in dev — Vite and Nitro share a single HTTP port, no proxy
- Any deployment target — Node.js, Cloudflare Workers, Vercel Edge, static — via Nitro adapters
Status: Early development. Core SSR pipeline, content layer, scaffolding, and Playwright e2e tests are all working.
| Litro | Next.js 14 | Nuxt 3 | |
|---|---|---|---|
| Component model | Lit / FAST / Elena | React | Vue |
| File-based routing | ✓ | ✓ | ✓ |
| SSR / SSG | ✓ | ✓ | ✓ |
| Server engine | Nitro | custom | Nitro |
| Virtual DOM | — | ✓ | ✓ |
| W3C standard comps | ✓ | — | — |
| Metric | Litro (v0.5.0) | Next.js (v14.2) | Nuxt (v3.21) |
|---|---|---|---|
| Build time | 1.27s | 6.49s | 2.82s |
| Output size | 34.8 KB | 702.8 KB | 200.1 KB |
| Avg page weight | 1.4 KB gzip | 1.5 KB gzip | 0.6 KB gzip |
Measured on Apple M4 Max, Node v24.7.0. Run
pnpm bench:crossto reproduce. See litro.dev/benchmarks for full results including TTFB, Lighthouse, and streaming metrics.
litro/
packages/
framework/ ← npm package: @beatzball/litro
litro-router/ ← npm package: @beatzball/litro-router (standalone, zero-dependency)
create-litro/ ← npm create @beatzball/litro (scaffolding)
playground/ ← fullstack recipe test app (Lit)
playground-fast/ ← fullstack test app (FAST Element)
playground-elena/ ← fullstack test app (Elena)
playground-11ty/ ← 11ty-blog recipe test app
playground-starlight/ ← starlight recipe test app (Lit)
playground-starlight-fast/ ← starlight test app (FAST Element)
playground-starlight-elena/ ← starlight test app (Elena)
docs/ ← official documentation site (@beatzball/litro-docs, SSG)
docs-ssr/ ← SSR replica of docs site (@beatzball/litro-docs-ssr, fullstack)
benchmarks/ ← benchmark suite: SSG vs SSR + cross-framework (Litro/Nuxt/Next.js)
@beatzball/litro-router is also independently usable without the full Litro framework — see its package README.
# npm
npm create @beatzball/litro@latest my-app
# pnpm
pnpm create @beatzball/litro my-app
# yarn
yarn create @beatzball/litro my-app
# bun
bun create @beatzball/litro my-app
# deno
deno create npm:@beatzball/litro@latest -- my-appFollow the interactive prompts to choose a recipe, rendering mode, and framework adapter, or pass flags to skip them:
# Non-interactive — fullstack SSR app (Lit, default):
npm create @beatzball/litro@latest my-app -- --recipe fullstack --mode ssr
# Non-interactive — fullstack SSR app with FAST Element:
npm create @beatzball/litro@latest my-app -- --recipe fullstack --mode ssr --adapter fast
# Non-interactive — fullstack SSR app with Elena (light DOM):
npm create @beatzball/litro@latest my-app -- --recipe fullstack --mode ssr --adapter elena
# Non-interactive — Starlight docs + blog, static output:
npm create @beatzball/litro@latest my-docs -- --recipe starlight
# List all available recipes:
npm create @beatzball/litro@latest -- --list-recipesThen:
cd my-app
pnpm install
pnpm dev # dev server on http://localhost:3030
pnpm build # Stage 0: page scan → Stage 1: vite build → Stage 2: nitro build
pnpm preview # preview the production buildStep 1 — build the framework and scaffolder:
git clone <this-repo> litro
cd litro
pnpm install
pnpm --filter @beatzball/litro-router build # compiles packages/litro-router → dist/
pnpm --filter @beatzball/litro build # compiles packages/framework → dist/
pnpm --filter @beatzball/create-litro build # compiles packages/create-litro → dist/Step 2 — scaffold your app from the local build:
cd /path/to/your/projects
# Interactive (prompts for recipe + mode):
node /path/to/litro/packages/create-litro/dist/src/index.js my-app
# Non-interactive — fullstack SSR app:
node /path/to/litro/packages/create-litro/dist/src/index.js my-app --recipe fullstack --mode ssr
# Non-interactive — 11ty-compatible blog, static output:
node /path/to/litro/packages/create-litro/dist/src/index.js my-app --recipe 11ty-blog --mode ssg
# Non-interactive — Starlight docs + blog, static output:
node /path/to/litro/packages/create-litro/dist/src/index.js my-docs --recipe starlight
# List all recipes:
node /path/to/litro/packages/create-litro/dist/src/index.js --list-recipesStep 3 — point the app at the local litro package:
Open the generated my-app/package.json and replace the litro version with a file: reference:
"dependencies": {
"litro": "file:/path/to/litro/packages/framework",
...
}Step 4 — install, build, and run:
cd my-app
pnpm install
pnpm run build # Stage 0: page scan → Stage 1: vite build → Stage 2: nitro build
pnpm run preview # starts http://localhost:3030The fullstack scaffolded app includes:
pages/index.ts— home page withpageDataserver fetchingpages/blog/index.ts— blog listingpages/blog/[slug].ts— dynamic post page with route params andgenerateRoutes()server/api/hello.ts— JSON API endpoint- All config files (
nitro.config.ts,vite.config.ts,tsconfig.json)
The 11ty-blog recipe also includes a Markdown content layer:
content/blog/*.md— posts with YAML frontmatter (title, date, tags, draft)content/_data/metadata.js— global site data- Pages that import from
litro:contentfor post listing, individual posts, and tag filtering litro.recipe.json— tells the content plugin where to find posts
The starlight recipe scaffolds an Astro Starlight-inspired docs + blog site:
content/docs/*.md— documentation pages with sidebar ordering frontmattercontent/blog/*.md— blog posts (title, date, tags, description)- Layout components:
<starlight-page>,<starlight-header>,<starlight-sidebar>,<starlight-toc> - UI components:
<litro-card>,<litro-card-grid>,<litro-badge>,<litro-aside>,<litro-tabs> - Shoelace web components available (button, icon, badge, copy-button, details, tab-group) —
<sl-*>names are reserved for Shoelace; Litro's primitives uselitro-* server/starlight.config.js— site title, nav links, sidebar groupspublic/styles/starlight.css— full--sl-*CSS token layer with dark/light mode- SSG-only (no
--modeflag needed — hardcoded tossg)
# Install dependencies and build the framework
pnpm install
pnpm --filter @beatzball/litro-router build
pnpm --filter @beatzball/litro build
# Start the dev server from the playground directory
cd playground
litro devThe dev server starts on http://localhost:3030 serving both Vite (JS modules, HMR) and Nitro (API routes, HTML shell) on a single port. Use litro dev --port <n> to change the port, and litro dev --host to expose the server to the network (listen on 0.0.0.0).
my-app/
pages/
index.ts → GET /
about.ts → GET /about
blog/
index.ts → GET /blog
[slug].ts → GET /blog/:slug
[...all].ts → GET /* (catch-all)
server/
api/ ← H3 API handlers (e.g. server/api/hello.ts → GET /api/hello)
middleware/ ← Nitro middleware
public/ ← Static assets served at /
app.ts ← Client entry (hydration + router bootstrap)
vite.config.ts
nitro.config.ts
A page file exports a web component as the default export. The filename determines the route. The example below uses Lit (default adapter) — FAST and Elena use their own component APIs with the same routing convention.
// pages/index.ts → /
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("page-home")
export class HomePage extends LitElement {
render() {
return html`<h1>Hello from Litro</h1>`;
}
}pages/blog/[slug].ts → /blog/:slug
pages/[...all].ts → /* (catch-all)
pages/[[lang]]/index.ts → /:lang?
definePageData() runs on the server before the component renders. The result is serialized into the HTML shell as a JSON script tag and read by LitroPage on first load.
// pages/index.ts
import { customElement, state } from "lit/decorators.js";
import { LitroPage } from "litro/runtime";
import { definePageData } from "litro";
export const pageData = definePageData(async (event) => {
// event is the H3 event — access headers, cookies, params, etc.
return {
message: "Hello from the server!",
timestamp: new Date().toISOString(),
};
});
@customElement("page-home")
export class HomePage extends LitroPage {
override async fetchData() {
// Called on client-side navigation (not on the initial SSR load)
const res = await fetch("/api/hello");
return res.json();
}
render() {
// Cast serverData locally — do NOT use `@state() declare` (breaks jiti/SSG)
const data = this.serverData as { message: string; timestamp: string } | null;
return html` <h1>${data?.message ?? "Loading..."}</h1> `;
}
}On the first (SSR) load, serverData is populated from the injected JSON. On subsequent client-side navigations, fetchData() is called instead.
Files in server/api/ are plain H3 event handlers.
// server/api/hello.ts → GET /api/hello
import { defineEventHandler } from "h3";
export default defineEventHandler((event) => {
return { message: "Hello!", timestamp: new Date().toISOString() };
});# Build client (Vite) + server (Nitro)
pnpm build # or: litro build
# For SSG: configure ssgPreset() in nitro.config.ts, then:
pnpm build # output goes to dist/static/ instead of dist/server/Output:
dist/client/— Vite client bundle (JS, assets)dist/server/— Nitro server bundle (SSR mode)dist/static/— Prerendered HTML files (SSG mode)
Litro delegates all deployment to Nitro's adapter system. Set NITRO_PRESET or configure preset in nitro.config.ts:
| Target | Preset |
|---|---|
| Node.js server | node (default) |
| Cloudflare Workers | cloudflare-workers |
| Cloudflare Pages | cloudflare-pages |
| Vercel | vercel |
| Netlify | netlify |
| AWS Lambda | aws-lambda |
| Deno Deploy | deno-deploy |
| Bun | bun |
| Static / GitHub Pages | static (or LITRO_MODE=static) |
See Nitro deployment docs for the full list.
import { defineNitroConfig } from "nitropack/config";
import type { Nitro } from "nitropack";
import { ssgPreset } from "@beatzball/litro/config";
import pagesPlugin from "@beatzball/litro/plugins";
import ssgPlugin from "@beatzball/litro/plugins/ssg";
import contentPlugin from "@beatzball/litro/content/plugin";
export default defineNitroConfig({
...ssgPreset(), // omit for SSR mode (no spread)
srcDir: "server",
publicAssets: [
// Paths resolved relative to srcDir ('server/') — use '../' to reach root.
// Bare 'dist/client' resolves to 'server/dist/client' and 404s all /_litro/** assets.
{ dir: "../dist/client", baseURL: "/_litro/", maxAge: 31536000 },
{ dir: "../public", baseURL: "/", maxAge: 0 },
],
externals: { inline: ["@lit-labs/ssr", "@lit-labs/ssr-client"] },
esbuild: {
options: {
tsconfigRaw: {
compilerOptions: {
experimentalDecorators: true,
useDefineForClassFields: false,
},
},
},
},
ignore: ["**/middleware/vite-dev.ts"],
handlers: [
{ middleware: true, handler: "./server/middleware/vite-dev.ts", env: "dev" },
],
hooks: {
"build:before": async (nitro: Nitro) => {
await contentPlugin(nitro); // if using the content layer
await pagesPlugin(nitro);
await ssgPlugin(nitro); // if using ssgPreset()
},
},
});The litro:content virtual module provides a file-system Markdown content API compatible with the 11ty data cascade format.
Add litro.recipe.json to your project root to configure the content directory:
{ "contentDir": "content/blog" }Then import from the virtual module in any page or server route:
import { getPosts, getPost, getTags, getGlobalData } from 'litro:content';
// List posts (sorted by date descending, drafts excluded)
const posts = await getPosts({ tag: 'tutorial', limit: 5 });
// Single post by slug
const post = await getPost('hello-world'); // null if not found
// All tags (sorted alphabetically)
const tags = await getTags();
// Global site data from content/_data/metadata.js
const meta = await getGlobalData();Frontmatter fields: title (required), date, description, tags, draft.
Directory data: place a .11tydata.json file alongside your posts to set default tags or other fields for all posts in that directory — exactly as 11ty's data cascade works.
For TypeScript types, add to your project's tsconfig.json:
{ "compilerOptions": { "types": ["litro/content/env"] } }The content plugin must be registered in nitro.config.ts:
import contentPlugin from 'litro/content/plugin';
export default defineNitroConfig({
hooks: {
'build:before': async (nitro) => {
await contentPlugin(nitro);
await pagesPlugin(nitro);
await ssgPlugin(nitro);
},
},
});Export a generateRoutes() function from any dynamic page to tell the SSG which paths to prerender:
// pages/blog/[slug].ts
export async function generateRoutes(): Promise<string[]> {
// fetch from a CMS, database, or static data
return ["/blog/hello-world", "/blog/getting-started"];
}Static routes (/, /about, /blog) are automatically added to the prerender list by the pages plugin.
pnpm install # install all workspace deps
pnpm --filter @beatzball/litro-router build # compile litro-router (required once)
pnpm --filter @beatzball/litro build # compile framework (required once)
pnpm --filter @beatzball/litro-router test # run router unit tests (18 tests)
pnpm --filter @beatzball/litro test # run framework unit tests (228 tests)
pnpm --filter @beatzball/create-litro test # run scaffolding tests (17 tests)
pnpm test:docs # run docs unit tests (97 tests)
pnpm test:e2e # Playwright e2e tests (92 tests, 5 projects)
pnpm --filter @beatzball/litro dev # watch-compile framework
# Playgrounds
cd playground && litro dev # fullstack playground on :3030
pnpm dev:11ty # 11ty-blog playground
pnpm dev:starlight # starlight playground
# Docs site (SSG — litro.dev)
pnpm dev:docs # docs dev server
pnpm build:docs # build docs (SSG → docs/dist/static/)
pnpm preview:docs # preview built docs
# Docs site (SSR — fullstack replica)
pnpm dev:docs-ssr # SSR docs dev server on :3034
pnpm build:docs-ssr # build docs-ssr (SSR → docs-ssr/dist/server/)
pnpm preview:docs-ssr # preview built docs-ssr
# Docker (docs-ssr only — SSG site uses nginx, see docs/Dockerfile)
docker build -f docs-ssr/Dockerfile -t litro-docs-ssr .
docker run -p 3000:3000 litro-docs-ssr| Layer | Library | Role |
|---|---|---|
| Components | Lit 3, FAST Element 2, Elena | Web component authoring (via adapters) |
| SSR | @lit-labs/ssr, @microsoft/fast-ssr | Streaming Declarative Shadow DOM (Lit/FAST) |
| Hydration | @lit-labs/ssr-client | Client-side DSD hydration (Lit/FAST) |
| Client router | litro-router (URLPattern API) |
Web component-aware pushState router |
| Server | Nitro | Routing, API, SSR, deployment adapters |
| Client build | Vite 5 | Client bundle, HMR |
| Language | TypeScript 5 | Required throughout |
| Monorepo | pnpm workspaces | Package management |
- Fork the repo, create a branch, make your changes.
- If your change is user-facing (bug fix, new feature, breaking change), add a changeset:
Internal changes (docs, tests, tooling) don't need a changeset.
pnpm changeset # Select which packages changed, pick the semver bump type, write a short summary. # Commit the generated .changeset/<name>.md file with your PR.
- Open a PR against
main. CI runs tests, build, and a dependency audit automatically.
Releases are fully automated via Changesets:
- When a PR with a
.changeset/*.mdfile is merged tomain, a bot opens a "Version Packages" PR that bumpspackage.jsonversions and updates each package'sCHANGELOG.md. - When that PR is merged, the release workflow publishes changed packages to npm and creates GitHub Releases.
litro-routerbumps automatically propagate a patch bump tolitro(internal dep cascade).
Release scripts (maintainers):
pnpm changeset # create a changeset interactively
pnpm version-packages # apply pending changesets → bump versions + write CHANGELOGs
pnpm release # build all packages and publish to npmApache License 2.0 — see LICENSE for the full text.
Copyright 2026 beatzball