Use Lit in Astro
One integration renders your Lit components in Astro and hydrates them, hot patches them while you edit, and puts the Lit panel in the dev toolbar. Read Server-side rendering first if hydration is new to you.
Set it up
Section titled “Set it up”Install the plugin with Lit’s server renderer and its hydrate support. The integration needs Astro 7.3 or later.
npm install lit @lit-labs/ssr @lit-labs/ssr-clientnpm install -D @oddsquad/vite-plugin-litAdd lit() from @oddsquad/vite-plugin-lit/astro. It takes the plugin’s
options, turns the timeline on, since
the panel needs it, and has three of its
own.
import {defineConfig} from 'astro/config';import lit from '@oddsquad/vite-plugin-lit/astro';
export default defineConfig({ integrations: [lit()],});Import each component in the page’s frontmatter, so Astro renders it, and in a page script, so the browser defines it. The integration loads Lit’s hydrate support ahead of your scripts, so the server’s HTML hydrates instead of rendering again.
---// The server renders each element to HTML, its shadow root included.import '../components/ssr-counter.ts';import {TagList} from '../components/tag-list.ts';---
<html lang="en"> <body> <ssr-counter start="3"></ssr-counter> <TagList client:visible tags={['island', 'visible']} /> <script> // The browser defines them, and they hydrate. import '../components/ssr-counter.ts'; import '../components/tag-list.ts'; </script> </body></html>examples/astro in the repository is a demo site with a page for each thing
this guide covers, all of it running live.
Pass props
Section titled “Pass props”Strings, numbers and booleans become attributes, under the attribute of the
reactive property they name. Arrays, objects and properties declared with
attribute: false can’t ride in an attribute, so the renderer sets them as
properties and holds the element back until the browser has them too.
import {LitElement, css, html} from 'lit';import {customElement, property} from 'lit/decorators.js';import ui from './ui.css?css-sheet';
/** * Takes its tags as an array property, which no attribute can carry, and a * heading through its `title` slot. The renderer sends the array along so * the element hydrates with it, as a plain tag or as an island. */@customElement('tag-list')export class TagList extends LitElement { static override styles = [ ui, css` ul { display: flex; flex-wrap: wrap; gap: var(--space-4); margin: 0 0 var(--space-6); padding: 0; list-style: none; } li { padding: var(--space-1) var(--space-5); border-radius: var(--radius-pill); background: var(--accent-soft); color: var(--text-strong); font: var(--weight-semibold) var(--text-sm) var(--font-mono); } `, ];
@property({attribute: false}) tags: string[] = [];
override render() { return html` <slot name="title"></slot> <ul> ${this.tags.map((tag) => html`<li>${tag}</li>`)} </ul> <button id="add" @click=${() => (this.tags = [...this.tags, `#${this.tags.length + 1}`])} > Add a tag </button> `; }}Beyond JSON, both carry Date, Map, Set, URL, BigInt, RegExp (its
source, without flags), Uint8Array, Uint16Array, Uint32Array,
Infinity and undefined, nested or not. In an island, Astro hands the values
over itself. On a plain tag, they travel in a data-lit-props attribute: as
plain JSON when that’s enough, otherwise in the form Astro uses for islands.
Choose when it hydrates
Section titled “Choose when it hydrates”A plain tag hydrates as soon as its class loads. Write defer-hydration on
it to hold it back, and remove the attribute when it should wake: the
renderer leaves your defer-hydration alone.
An imported class used as a component is an island, so Astro’s directives
apply: client:load, client:idle, client:visible and client:media
hydrate the server’s HTML, and client:only="lit" renders in the browser
only. The renderer is named lit, so that hint finds it in a project with
other renderers too.
Next to other frameworks
Section titled “Next to other frameworks”Lit shares a page with React, Preact, Solid or Vue islands. Restrict a JSX
framework’s integration to its own files with include. By default
@astrojs/preact and @astrojs/react run Babel over every .ts file, and
Babel can’t parse Lit’s decorators:
import {defineConfig} from 'astro/config';import preact from '@astrojs/preact';import lit from '@oddsquad/vite-plugin-lit/astro';
export default defineConfig({ integrations: [preact({include: ['**/*.tsx']}), lit()],});A Lit element inside another framework’s component is that framework’s to
render. On the server it becomes a plain tag, without a shadow root, and Lit
renders it in the browser once its class is defined, so import the class in
the component. Preact’s hydration leaves properties as the server’s HTML had
them, so an array or object prop reaches the element when the component’s
state next changes; give the island client:only="preact" if it must be
there from the start.
Render on demand
Section titled “Render on demand”Prebuilt pages and on-demand rendering work alike. With output: 'server'
and an adapter, the renderer runs for each request, and the browser hydrates
the result as it does a prebuilt page. With @astrojs/node, @astrojs/vercel
and @astrojs/netlify, @lit-labs/ssr and lit stay external to the server
build, so the server loads them from your node_modules, one copy of each.
@astrojs/cloudflare bundles them for the Workers runtime, where the
integration picks Lit’s server builds, as Node would.
Test with the container API
Section titled “Test with the container API”Astro’s container API renders a component to a string in Vitest. Load the
renderer with getContainerRenderer() from the same entry as lit().
import {experimental_AstroContainer as AstroContainer} from 'astro/container';import {loadRenderers} from 'astro:container';import {getContainerRenderer} from '@oddsquad/vite-plugin-lit/astro';import {expect, test} from 'vitest';import Page from '../src/pages/basics.astro';
test('renders the page with the Lit renderer through the container API', async () => { // Resolved from here: Astro imports the entry from its own folder, where // this repo's root package, linked rather than installed, isn't found. const renderers = await loadRenderers([ { ...getContainerRenderer(), serverEntrypoint: import.meta.resolve( getContainerRenderer().serverEntrypoint ), }, ]); const container = await AstroContainer.create({renderers}); const html = await container.renderToString(Page);
// A plain tag: its array rides along as JSON, its shadow root is inline. expect(html).toMatch( /<tag-list defer-hydration data-lit-props="\{"tags":\["lit","astro"\]\}"/ ); expect(html).toContain('<template shadowrootmode="open">'); // A `?css-sheet` import, rendered into the shadow root on the server. const themed = html.slice( html.indexOf('<ssr-themed'), html.indexOf('</ssr-themed>') ); expect(themed).toMatch(/^<ssr-themed[^>]*><template shadowrootmode="open">/); expect(themed).toMatch(/<style>[^<]*\.note \{/); // A hand-written defer-hydration, without the renderer's marker. expect(html).toMatch(/<ssr-deferred defer-hydration(?! data-lit-props)/);});Build the Vitest config with Astro’s getViteConfig(), so the integration
and the plugin’s transforms apply, and set css: true. Vitest empties css it
doesn’t process, and a ?css-sheet import then renders an empty <style>.
/// <reference types="vitest/config" />import {getViteConfig} from 'astro/config';
// Astro's own config, integrations included, so the renderer and the// plugin's transforms apply to what the tests render.export default getViteConfig({ // Processed, as Vite does: a `?css-sheet` import renders its css on the // server, and Vitest empties css it doesn't process. test: {include: ['test/**/*.test.ts'], css: true},});Open the panel
Section titled “Open the panel”Run astro dev and click the Lit logo in the dev toolbar. The panel opens in
a toolbar window, following the page you are on, with each element’s
hydration state in the Components tab.
The first time, the window asks for a code. The terminal prints it in a
Devframe box when the panel connects. Type it in, or open the link in that
box: it opens the panel in a tab of its own, approved, and the toolbar window
is approved with it.
Keep another renderer
Section titled “Keep another renderer”To keep a different Lit renderer, such as @awesome.me/astro-lit, use
litDevtools() instead of lit(). It brings the plugin’s HMR and the panel,
and leaves rendering to the other integration.
Limits
Section titled “Limits”Astro renders pages without Vite’s transformIndexHtml, which is how the
plugin adds its page runtime. In dev the integration adds it to each HTML
page itself, so Astro sends a page once it is complete, not as a stream.
Every current browser parses declarative shadow roots. For an older one, pass
shadowRootPolyfill: true; there is nothing to install.