# Use Lit in Astro

> One integration renders Lit components on the server, hydrates them, hot-patches them, and puts the Lit panel in the Astro dev toolbar.

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](/guides/ssr/) first if hydration is new to you.

## Set it up

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

```sh
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](/reference/options/), turns the timeline on, since the panel needs it, and has [three of its own](/reference/options/#astro-options).

```js
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.

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

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

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

Functions stay on the server

No prop can carry a function to the browser, and a class instance arrives as a plain object, if at all. On a plain tag the renderer leaves such a prop out and warns in the terminal; in an island Astro sends `null` for a function. Set them from a page script once the element is defined.

## 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

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:

```js
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

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

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

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

```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},
});
```

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

Client auth guards the panel

The code stops other pages and devices that reach the dev server from driving the panel, which can write files and open your editor. Turn it off only on a machine you trust, with `VITE_DEVTOOLS_DISABLE_CLIENT_AUTH=true`.

## 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

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.

## Related

[Render Lit on the server](/guides/ssr/)What the plugin does for hydrated components.

[Inspect live components](/guides/devtools/components/)The Components tab, where hydration state shows.
