Create a new Litro app
npm install @beatzball/create-litro pnpm add @beatzball/create-litro
Scaffold a new Litro app.
npm create @beatzball/litro@latest my-app
# or
pnpm create @beatzball/litro my-app
# or
yarn create @beatzball/litro my-app
Follow the interactive prompts to choose a recipe and rendering mode, or pass flags directly to skip them:
# Fullstack SSR app (default)
npm create @beatzball/litro@latest my-app --recipe fullstack --mode ssr
# 11ty-compatible blog (SSG or SSR)
npm create @beatzball/litro@latest my-app --recipe 11ty-blog --mode ssg
# List all available recipes
npm create @beatzball/litro@latest -- --list-recipes
fullstack (default)A fullstack Lit + Nitro app with file-based routing and server-side rendering.
Generated app includes:
pages/index.ts — home page with definePageData() server fetchingpages/blog/index.ts — blog listing pagepages/blog/[slug].ts — dynamic post page with generateRoutes() for SSGserver/api/hello.ts — JSON API endpointnitro.config.ts, vite.config.ts, tsconfig.json11ty-blogA Markdown blog using the litro:content content layer, compatible with the 11ty data cascade format (frontmatter, .11tydata.json directory data, _data/metadata.js global data). Supports both SSR (dev server) and SSG (prerender to static HTML).
Generated app includes:
content/blog/ — Markdown posts with YAML frontmattercontent/_data/metadata.js — global site metadatapages/index.ts — home page showing recent postspages/blog/index.ts — post listing with all postspages/blog/[slug].ts — individual post page with generateRoutes()pages/tags/[tag].ts — tag-filtered post listingserver/api/posts.ts — JSON API for posts (optional ?tag= filter)litro.recipe.json — written to the project root so the content plugin knows where to find postscd my-app
pnpm install
pnpm dev # start dev server on http://localhost:3030
For static site generation:
LITRO_MODE=static pnpm build # prerenders all routes to HTML
litro:content virtual moduleThe 11ty-blog recipe uses litro:content, a virtual module provided by the Litro framework:
import { getPosts, getPost, getTags, getGlobalData } from 'litro:content';
// In a page file:
const posts = await getPosts({ tag: 'tutorial', limit: 5 });
const post = await getPost('hello-world');
const tags = await getTags();
const meta = await getGlobalData(); // reads _data/metadata.js
The content layer reads Markdown files from the directory specified in litro.recipe.json (contentDir). Posts are sorted by date descending. Draft posts (frontmatter draft: true) are excluded by default.
Add /// <reference types="litro/content/env" /> (or the equivalent tsconfig.json entry) for TypeScript type support.
Apache License 2.0 — Copyright 2026 beatzball.
Changelog
2456382: Add official documentation site and starlight recipe improvements
@beatzball/litro
LITRO_BASE_PATH env var support in create-page-handler.ts — prefixes the /_litro/app.js script URL for sub-path deployments (e.g. GitHub Pages project sites at owner.github.io/repo/)LitroPage.connectedCallback() now peeks at the __litro_data__ script tag to set serverData before Lit's first render, without consuming the tagLitroOutlet.firstUpdated() no longer eagerly clears SSR children — the router's atomic swap handles it@beatzball/litro-router
_resolve(): new element is appended hidden alongside old SSR content, waits for updateComplete + requestAnimationFrame, then old content is removed and new element revealed — eliminates blank flash and layout shift during navigation_lastPathname guard prevents re-render on hash-only popstate events (TOC / fragment link clicks)@beatzball/create-litro
sl-card, sl-card-grid, sl-badge, sl-tabs, sl-tab-item, sl-aside → litro-card, litro-card-grid, litro-badge, litro-tabs, litro-tab-item, litro-aside to avoid collision with Shoelace's registered custom element names@shoelace-style/shoelace) — tree-shaken component imports in app.ts, icon assets at /shoelace/assets/, theme CSS at /shoelace/themes/; sl-button and sl-icon-button now available in all scaffolded starlight siteslitro-card improvements — equal-height cards via flex column, icon + title rendered inline side-by-side, new iconSrc prop for image-based icons:host { position: sticky } (works correctly across shadow DOM boundary); sticky TOC matching sidebar behaviourprefers-color-scheme when no localStorage preference is setdocs/ workspace (@beatzball/litro-docs) — official Litro documentation site built on the starlight recipe, deployed to GitHub Pages via .github/workflows/docs.ymlplaywright.config.ts and e2e/index.spec.ts with 3 starter tests, and @playwright/test in devDependencies.78fdaf6: Add starlight recipe — Astro Starlight-inspired docs + blog site scaffolded as Lit web components with full SSG support.
npm create @beatzball/litro my-docs -- --recipe starlight scaffolds a static docs + blog site with:
<starlight-page>, <starlight-header>, <starlight-sidebar>, <starlight-toc><sl-card>, <sl-card-grid>, <sl-badge>, <sl-aside>, <sl-tabs>, <sl-tab-item>/ (splash), /docs/:slug, /blog, /blog/:slug, /blog/tags/:tag — all SSG-prerendered--sl-* CSS token layer with dark/light mode toggle and no flash of unstyled contentserver/starlight.config.js — site title, nav links, sidebar groups--mode flag needed)76d3bc7: fix: client-side navigation links do not work on first load
<litro-link> clicks were silently no-ops in scaffolded apps because of
three compounding bugs.
Bug 1 — Empty route table on init (LitroOutlet, app.ts)
app.ts set outlet.routes inside a DOMContentLoaded callback (a
macrotask). By that point Lit's first-update microtask had already fired,
so firstUpdated() ran with routes = [] and the router was initialised
with no routes.
Fix — LitroOutlet: Replace @property({ type: Array }) routes with a
plain getter/setter. The setter calls router.setRoutes() directly when
the router is already initialised, without going through Lit's render cycle
(which would crash with "ChildPart has no parentNode" because
firstUpdated() removes Lit's internal marker nodes to give the router
ownership of the outlet's subtree).
Fix — app.ts (fullstack recipe template + playground): Set
outlet.routes synchronously after imports rather than inside a
DOMContentLoaded callback. Module scripts are deferred by the browser;
by the time they execute the DOM is fully parsed and <litro-outlet> is
present.
Bug 2 — Click handler never attached on SSR'd pages (LitroLink)
@lit-labs/ssr adds defer-hydration to custom elements inside shadow
DOM. @lit-labs/ssr-client patches LitElement.prototype.connectedCallback
to block Lit's update cycle when this attribute is present. A @click
binding on the shadow <a> is a Lit binding — it is never attached until
defer-hydration is removed, which only happens when the parent component
hydrates. For page components that are never hydrated client-side (because
the router replaces the SSR content before they load), <litro-link>
elements inside them never receive a click handler.
This is why the playground appeared to work: its home page has no
<litro-link> elements. The fullstack generator template does, so clicks
on the SSR'd page were silently ignored.
Fix: Move the click handler from a @click binding on the shadow <a>
to the HOST element via addEventListener('click', ...) registered in
connectedCallback() (before super.connectedCallback()). The host
listener runs in LitroLink's own connectedCallback override, which
executes before the @lit-labs/ssr-client patch checks for
defer-hydration. This ensures the handler is active immediately after the
element connects to the DOM, even for SSR'd elements on first load.
The shadow <a> is kept without a @click binding — it exists for
progressive enhancement (no-JS navigation) and accessibility (cursor,
focus, keyboard navigation).
Bug 3 — _resolve() race condition (LitroRouter)
setRoutes() calls _resolve() immediately for the current URL. If the
user clicks a link before that initial _resolve() completes (e.g. while
the page action's dynamic import is in flight), a second _resolve() call
starts concurrently. If the first call (for /) completes after the second
(for /blog), it overwrites the blog page with the home page.
Fix: Add a _resolveToken monotonic counter. Each _resolve() call
captures its own token at the start and checks it after every await. If
the token has advanced, a newer navigation superseded this one and the call
returns without touching the DOM.
Bug 4 — @property() decorators silently dropped by esbuild TC39 transform (LitroLink)
esbuild 0.21+ uses the TC39 Stage 3 decorator transform. In that mode,
Lit's @property() decorator only handles accessor fields; applied to a
plain field (href = '') it is silently not applied. As a result href,
target, and rel were absent from observedAttributes, so
attributeChangedCallback was never called during element upgrade, leaving
this.href = '' forever regardless of what the HTML attribute said.
Fix: Replace the three @property() field decorators with a
static override properties = { href, target, rel } declaration. Lit reads
this static field at class-finalization time via finalize(), which runs
before the element is defined in customElements, ensuring the properties
are correctly registered in observedAttributes.
Adds a new LitroOutlet.test.ts test file (6 tests) covering the
synchronous and late-assignment code paths, the setter guard, SSR child
clearing, and the LitroRouter constructor call.
Updates LitroLink.test.ts (12 tests) to dispatch real MouseEvents on
the host element (exercising the addEventListener path) rather than
calling the private handler directly by name.
Template fix — @state() declare serverData incompatible with jiti/SSG
The fullstack recipe template used @state() declare serverData: T | null to
narrow the serverData: unknown type inherited from LitroPage. The declare
modifier emits no runtime code, but jiti's oxc-transform (used in SSG mode to
load page files) throws "Fields with the 'declare' modifier cannot be
initialized here" under TC39 Stage 3 decorator mode.
Fix: Remove @state() declare serverData from both page templates. Use a
local type cast in render() instead: const data = this.serverData as T | null.
The property is already reactive (declared as @state() serverData = null in
LitroPage). Updated LitroPage.ts JSDoc and DECISIONS.md to document this
pattern and warn against declare fields in subclasses.
bfd8f9a: Fix fullstack recipe: add base: '/_litro/' to vite.config.ts and extend LitroPage in [slug].ts
Without base: '/_litro/', Vite's compiled modulepreload URL resolver emits paths like /assets/chunk.js instead of /_litro/assets/chunk.js. These requests hit the Nitro catch-all page handler and return HTML, causing a MIME type error that leaves dynamic routes (e.g. /blog/hello-world) stuck on "Loading…".
Also fixes pages/blog/[slug].ts to extend LitroPage (not LitElement) and implement fetchData(), so client-side SPA navigation to different slugs correctly updates serverData.
litro/runtime/... imports instead of @beatzball/litro/runtime/..., and bump nitropack devDependency to ^2.13.1.@beatzball scoped package names following the rename in v0.1.0. Fixes install commands, pnpm --filter flags, npm create commands, and import paths.618a9b8: Rename all packages to @beatzball scope. The unscoped litro package was blocked by npm's name-similarity protection (too close to lit, listr, etc.). All three packages are now published under the @beatzball org scope:
litro → @beatzball/litrolitro-router → @beatzball/litro-routercreate-litro → @beatzball/create-litroThe previously published unscoped litro-router@0.0.2 and create-litro@0.0.2 are deprecated on npm with a redirect notice.
license, repository, and publishConfig fields to all published packages; configure Changesets for automated version management, per-package changelogs, and npm publishing via GitHub Actions.