@chris-briddock/create-lit-app
v0.2.0
Published
Scaffold a production-ready Lit application with best practices baked in and optional SSR.
Maintainers
Readme
create-lit-app
Scaffold a production-ready Lit application with best
practices baked in and optional server-side rendering — what create-next-app
is for React, for web components.
npm create @chris-briddock/lit-app my-appNo arguments are required; the CLI asks for what it needs. It has zero runtime dependencies, so there is nothing to download before the prompts appear.
What you get
- Lit 3 + Vite 8 — Vite 8 bundles with Rolldown and transforms with Oxc, so builds are fast without extra configuration.
- TypeScript 6, configured for the decorator flavour Lit and the bundler actually agree on (see Decorators).
- Optional SSR with
@lit-labs/ssr: streaming server rendering, declarative shadow DOM, and client hydration. - Optional routing, with an example page tree and a 404 route.
- Optional Tailwind CSS 4, wired through the
@tailwindcss/viteplugin and made to work inside shadow roots (see Tailwind and shadow DOM). - Vitest 4 in browser mode — component tests run in real Chromium, because shadow DOM and custom element upgrade timing are exactly what a DOM emulator approximates worst.
- ESLint 10 + Prettier, including
eslint-plugin-lit, which understandshtmltagged templates and catches binding mistakes a general-purpose linter cannot see. - A custom elements manifest for editor autocomplete and hover docs.
Every generated project passes its own lint, format:check, typecheck and
build on the first run.
Usage
npm create @chris-briddock/lit-app my-app
pnpm create @chris-briddock/lit-app my-app
yarn create @chris-briddock/lit-app my-app
bun create @chris-briddock/lit-app my-appThe package manager you invoke is detected and used for the install and for the commands printed at the end.
Options
Every prompt has a flag, so the CLI is fully scriptable:
| Flag | Description |
| --- | --- |
| --typescript / --javascript | Language (default: TypeScript) |
| --ssr / --spa | Rendering strategy (default: SPA) |
| --router / --no-router | Routing and example pages |
| --tailwind / --no-tailwind | Tailwind CSS (default: no) |
| --eslint / --no-eslint | ESLint + Prettier |
| --tests / --no-tests | Vitest browser tests |
| --git / --no-git | Initialise a git repository |
| --install / --no-install | Install dependencies |
| --use-npm, --use-pnpm, --use-yarn, --use-bun | Force a package manager |
| -y, --yes | Accept defaults, skip prompts |
| --force | Scaffold into a non-empty directory |
# Fully non-interactive
npm create @chris-briddock/lit-app my-app -- --ssr --router --yes
# Scaffold into the current directory
npm create @chris-briddock/lit-app . --javascript --no-testsPrompts are skipped automatically when stdin is not a TTY, so CI needs no special handling.
SSR
Choosing --ssr produces an Express server that runs Vite in middleware mode
during development — so you get server rendering and hot module replacement
— and serves pre-built assets in production.
npm run dev # server-rendered, with HMR
npm run build # type-check, then build client + server bundles
npm run start # production serverThe response is streamed: the document head is flushed before rendering begins, so the browser starts fetching CSS and JS while the server is still producing markup.
Two details the template gets right, and which are easy to get wrong by hand:
@lit-labs/ssr-client/lit-element-hydrate-support.jsis imported before anything that importslit. Otherwise elements discard the server-rendered shadow DOM and re-render from scratch.- With
--ssr --router, the route table is plain data shared by the server and the browser, and the active path is passed as an attribute. Attributes survive into the HTML, so the client hydrates with the same value the server rendered. A property binding would exist only on the server.
@lit-labs/router is used for SPA routing only — it reads window on connect,
so it cannot run during server rendering.
Tailwind and shadow DOM
--tailwind installs Tailwind 4 the documented way: the @tailwindcss/vite
plugin, and @import 'tailwindcss' in src/styles/global.css. No config file,
no PostCSS step.
That alone is not enough for Lit. Components render into a shadow root, and document stylesheets do not cross that boundary, so utility classes in a component template would silently do nothing. Generated projects bridge it with two small files:
/* src/styles/shadow.css — utilities, without the document-level reset */
@reference './global.css';
@import 'tailwindcss/utilities.css';// src/styles/tailwind.ts
import { unsafeCSS } from 'lit';
import sheet from './shadow.css?inline';
export const tailwind = unsafeCSS(sheet);Components then adopt it with static styles = [tailwind, css`...`], keeping
the handwritten block last for what utilities cannot express — :host and
::slotted().
Three decisions behind that, each verified against a real build:
- Preflight is excluded from the shadow sheet. It is a document-level
reset; re-applying it inside every shadow root is wasteful. The cost is that
components needing
font: inheriton form controls, or no underline on links, restate those rules themselves — and the templates do. - The theme comes from
@reference './global.css'. That loads the project stylesheet for its theme while emitting none of its CSS, so a token declared in its@themeblock generates utilities inside components as well as in the document. Compiling the shadow sheet against Tailwind's stock theme instead would leave custom tokens working inindex.htmland silently doing nothing in every component. Referenced values are inlined as fallbacks, so the sheet stays self-contained rather than depending on variables reaching it from:root. - Light DOM is not used instead. Overriding
createRenderRoot()would letglobal.cssreach components and remove the need for both files, but@lit-labs/ssrstill serialises output into a declarative shadow root. Under--ssrthat yields the server's copy sealed in a shadow root plus a second copy rendered on hydration, so it only works for--spa.
A note on decorators
Generated TypeScript projects use experimentalDecorators: true with
useDefineForClassFields: false.
This is deliberate and verified rather than inherited. Standard ES decorators
with the accessor keyword type-check under TypeScript 6, but Vite 8 emits
them untransformed — the resulting bundle contains @dec accessor x, which
is a syntax error in every current JavaScript engine. Experimental decorators
lower correctly to __decorate calls and honour useDefineForClassFields,
which Lit's reactive properties depend on.
The generated tsconfig.json says as much, so nobody "modernises" it and
ships a broken bundle.
Development
npm install
npm test # unit + filesystem integration tests
npm run smoke # scaffold, install and build every combination
npm run smoke -- --quick # TypeScript only, faster
npm run check-versions # verify every pinned dependency resolves
npm run format:templates # keep templates format-cleannpm test is offline and fast. npm run smoke hits the network and is the
check that matters before publishing.
How templates work
Templates are composed in ordered layers, each copied over the last:
base → lang/<ts|js> → app/<spa|ssr> → app/<spa|ssr>-<lang>
→ router/<rendering>-<lang>
→ tailwind/base → tailwind/lang/<lang> → tailwind/app/<rendering>-<lang>
→ tailwind/router/<rendering>-<lang>
→ tooling/eslint-<lang> → tooling/test-<lang>A later layer overrides a single file from an earlier one — routing replaces
app-root, for instance — which avoids needing a template per combination of
answers.
Within a layer:
*.tplfiles are rendered with{{variable}}interpolation and{{#if flag}}/{{#unless flag}}blocks, and lose the suffix._gitignore,_prettierrcand friends are renamed to their dotfile form, because npm refuses to publish files named.gitignore.
package.json is built in code (src/manifest.ts) rather than templated,
since its dependencies and scripts vary along all four axes at once. All
versions are pinned in one place, src/versions.ts.
Requirements
Node.js 20.19+ or 22.12+, matching Vite 8's supported range.
License
MIT
