Skip to content

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.

Install the plugin with Lit’s server renderer and its hydrate support. The integration needs Astro 7.3 or later.

Terminal window
npm install lit @lit-labs/ssr @lit-labs/ssr-client
npm install -D @oddsquad/vite-plugin-lit

Add 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.

astro.config.mjs
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.

src/pages/index.astro
---
// 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.

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.

src/components/tag-list.ts
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.

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.

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:

astro.config.mjs
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.

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.

Astro’s container API renders a component to a string in Vitest. Load the renderer with getContainerRenderer() from the same entry as lit().

test/container.test.ts
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="\{&quot;tags&quot;:\[&quot;lit&quot;,&quot;astro&quot;\]\}"/
);
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>.

vitest.config.ts
/// <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},
});

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.

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.

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.