The previous two lessons have resolved how a Lit component integrates with the world around it once it's already in the user's browser: plain HTML, or inside another application framework. This lesson raises a problem of a different nature, one that happens before the browser even gets the chance to run a single line of JavaScript: what does a user, or a search engine, receive in the very first instant a page's HTML response arrives from the server, before Lit's bundle has loaded and run? By default, the answer is "very little": an empty <task-card> tag, with nothing inside, until the JavaScript downloads, runs, and the component decides to render itself. Server-side rendering (SSR) exists to change that answer.
Contents
- The problem: an empty page until the JavaScript loads
- What hydration is and why it matters
- Why SSR matters especially for Web Components
- Overview of
@lit-labs/ssrand its experimental nature @lit-labs/ssr'srenderfunction- Declarative Shadow DOM: the standard mechanism that makes it possible
- Conceptual example:
<task-card>rendered on the server - Current limitations to keep in mind
- The problem: an empty page until the JavaScript loads
All of TaskFlow's development throughout the course has assumed, without saying so explicitly, that the user's browser downloads the page's HTML, then downloads and runs the JavaScript that defines the components (customElements.define('task-card', TaskCard), and the rest), and only then does each <task-card> tag present in the markup "come alive" and render its real content. Between the first instant (HTML received) and the second (JavaScript run and components defined), there is a window of time —sometimes milliseconds, sometimes several seconds on a slow connection or a modest device— during which the page exists, but its Lit components are empty: the browser knows the <task-card> tag as an element with no special behavior (what the Custom Elements specification calls an "unupgraded" element), with no visible content inside its Shadow DOM, because that Shadow DOM doesn't even exist yet.
For an application meant to be used after an explicit user interaction (after logging in, for example), that window is usually acceptable. For content that must be visible as soon as possible —a home page, a task list a user wants to see the moment they open the link, or any page a search engine needs to be able to index without running JavaScript— that window is exactly the problem SSR solves: generating, on the server, the HTML already filled in with each component's real content, so the browser receives, from the very first instant, a page with visible content, without depending on the JavaScript having run yet.
- What hydration is and why it matters
Rendering on the server solves only half the problem: the HTML that reaches the browser already has visible content, but it is not yet interactive. A button inside a server-rendered <task-card> is visible on screen, but doesn't respond to a click until a second step occurs, called hydration: the process by which the component's JavaScript, once downloaded and run in the browser, "takes ownership" of the already-existing HTML —instead of destroying and regenerating it from scratch— and connects it to the component's real reactive logic, including its event listeners and its ability to re-render in response to future state changes.
Hydration is, in a sense, the opposite of creating a component from scratch: in a normal render (without SSR), Lit starts from an empty tag and builds all its internal content the first time render() runs; in a hydration, Lit finds content already present in the DOM (generated by the server) and needs to reconcile with it, recognizing which parts of that HTML correspond to which dynamic expressions in the template, without rebuilding anything that's already correctly in place. This distinction matters because it explains why SSR isn't simply "running the same components in Node.js instead of in the browser": the browser, afterward, has to do additional, different work (hydrating) from what it does when there's no SSR involved (rendering from scratch).
- Why SSR matters especially for Web Components
SSR isn't an idea exclusive to Lit; it's a technique widespread across the application framework ecosystem (Next.js for React, Nuxt for Vue, Angular Universal for Angular), historically motivated by two reasons that apply equally well to any component, including Lit's: SEO (search engines index a page better when it already contains the real content in the HTML, rather than depending on running JavaScript to discover it, although modern crawlers have improved quite a bit in that respect) and perceived performance (the user sees meaningful content sooner, instead of a blank screen or a loading indicator during the window described in section 1).
Web Components add, however, a difficulty of their own that traditional application frameworks don't share in the same way: the Shadow DOM. A React or Vue component, with no Web Components involved, renders directly into the page's regular DOM, with no special encapsulation boundary; the HTML generated on the server for that component is, structurally, the same type of HTML as any other element on the page. A Lit component, by contrast, encapsulates its content inside a Shadow DOM (as studied in lesson 04-01), and HTML served from a traditional server has, by definition, no way to represent "here there is a Shadow DOM root with this content inside" using only the usual HTML tags. Without a solution to that specific problem, SSR for Web Components would, in practice, be impossible to implement faithfully to how the component actually behaves in the browser.
- Overview of
@lit-labs/ssr and its experimental nature
@lit-labs/ssr and its experimental nature@lit-labs/ssr is the package Lit's own team maintains to solve this problem: running Lit components in a server environment (typically Node.js) and producing HTML that faithfully includes each component's real content, including its Shadow DOM. The labs prefix in the package name is neither accidental nor decorative: Lit reserves that namespace for packages its own team still considers experimental, with an API that can change between minor versions more freely than the stable lit core used throughout the rest of the course. This doesn't mean @lit-labs/ssr is unusable in production —in fact, real projects do use it— but it does mean it's worth approaching with the expectation of reviewing the changelog more carefully than usual before upgrading versions, and accepting that some pieces of the surrounding ecosystem (such as integration with certain server frameworks) may be less mature than Lit's core.
The package installs independently, just like @lit/context in the previous module:
And it's meant to run on the application's own server —inside an Express route handler, a server function of a broader framework (such as Next.js, with adaptations), or any Node.js environment capable of importing ES modules— not in the end user's browser.
@lit-labs/ssr's render function
@lit-labs/ssr's render functionThe central piece of @lit-labs/ssr is its own render function, different from Lit's usual html template but meant to consume exactly the same kind of value a component's render() would return:
// server.js (conceptual fragment, Node.js environment)
import { render } from '@lit-labs/ssr';
import { html } from 'lit';
import './src/components/task-card.js';
async function generarHtmlDeTarjeta(tarea) {
const resultado = render(html`
<task-card
titulo="${tarea.titulo}"
estado="${tarea.estado}"
prioridad="${tarea.prioridad}"
></task-card>
`);
let html_generado = '';
for (const fragmento of resultado) {
html_generado += fragmento;
}
return html_generado;
}render(...) receives an ordinary html template —the same syntax used throughout the course— and returns an iterable (specifically, a generator) of string fragments, not a single complete string all at once; this allows, on a real server, sending the response to the client in pieces as they're generated (streaming), instead of waiting to have the complete HTML in memory before responding, something especially valuable for pages with many components or with data that takes time to resolve. The example in the previous fragment concatenates all the fragments into a single string, for simplicity, but a real streaming-oriented server would write each fragment directly to the HTTP response as soon as it became available.
For this to work, <task-card> must be registered exactly as it is in the browser —the same customElements.define('task-card', TaskCard)—, with the particularity that @lit-labs/ssr provides its own implementations of the DOM APIs Lit needs (HTMLElement, customElements, and the rest), since Node.js doesn't have them natively; the package takes care of simulating that environment well enough that the same component class, with no special server-oriented modification, can run its normal render cycle and produce a correct result.
- Declarative Shadow DOM: the standard mechanism that makes it possible
The piece that solves the problem pointed out in section 3 —how to represent a Shadow DOM inside plain HTML, without running JavaScript— is Declarative Shadow DOM (DSD), a relatively recent extension of the Shadow DOM standard itself, not an invention of Lit's or @lit-labs/ssr's own. DSD allows declaring, directly in HTML, a special template marked with the shadowrootmode attribute, which the browser recognizes and automatically "attaches" as the real Shadow DOM of the element that contains it, with no need for any attachShadow(...) call from JavaScript:
<task-card>
<template shadowrootmode="open">
<style>/* component's encapsulated styles */</style>
<article>
<h3>Revisar propuesta de cliente</h3>
<p>Estado: progreso · Prioridad: 3</p>
</article>
</template>
</task-card>When the browser processes this markup, it recognizes the <template shadowrootmode="open"> and converts it, natively and without running a single line of JavaScript yet, into <task-card>'s real Shadow DOM; the content inside the <template> stops being a plain, inert <template> (like normal HTML <template> elements, which never display themselves) and becomes the element's effective Shadow DOM tree, visible on screen from the very first moment the browser processes the HTML, exactly as if attachShadow(...) had been called from JavaScript immediately after creating the element. This is, precisely, the HTML @lit-labs/ssr produces when it renders a component with Shadow DOM: not a plain <task-card>...</task-card> with the content "flattened" inside, but this structure with the nested DSD template, faithful to how the component would actually behave in the browser.
It is this native browser support, not any internal magic from Lit, that lets hydration (section 2) work faithfully: when Lit's JavaScript runs afterward, it finds a real Shadow DOM already attached to the element (thanks to DSD), with the correct content inside, and only needs to reconnect the reactive logic, instead of having to create the Shadow DOM from scratch as it would without SSR.
- Conceptual example:
<task-card> rendered on the server
<task-card> rendered on the serverPutting the previous pieces together, here is, conceptually, what the full flow would look like for serving an initial TaskFlow page with several cards already rendered on the server:
// server.js (conceptual, with a generic server framework)
import { render } from '@lit-labs/ssr';
import { html } from 'lit';
import './src/components/task-list.js';
import { cargarTareas } from './src/services/tareas-service.js';
app.get('/', async (peticion, respuesta) => {
const tareas = await cargarTareas();
const plantilla = html`
<!DOCTYPE html>
<html lang="es">
<head><title>TaskFlow</title></head>
<body>
<task-list></task-list>
<script type="module" src="/main.js"></script>
</body>
</html>
`;
respuesta.setHeader('Content-Type', 'text/html');
for (const fragmento of render(plantilla)) {
respuesta.write(fragmento);
}
respuesta.end();
});The key point of this flow is that cargarTareas() —the same service function introduced in lesson 07-03 to simulate an asynchronous load— resolves before the HTML is generated, directly on the server, with await; unlike the use of until seen in that lesson (designed for the browser, where render() must stay synchronous while the promise resolves later on), the server can wait without any problem, because there is no synchrony restriction equivalent to the client-side render()'s: the server simply doesn't respond to the HTTP request until cargarTareas()'s promise resolves, and only then generates the HTML, already with the tasks included inside <task-list>'s Shadow DOM and each nested <task-card>'s. The <script type="module" src="/main.js"> at the end of the <body> is what triggers, already in the browser, the hydration: as soon as it runs and defines <task-list> and <task-card>, Lit recognizes the Shadow DOM already present (thanks to DSD) and connects it to the real reactive logic, without rebuilding it from scratch.
- Current limitations to keep in mind
@lit-labs/ssr solves the central problem of SSR for Web Components, but it's worth knowing, before adopting it in a real project, several limitations in effect at the time this course was written:
- Browser support for Declarative Shadow DOM: although DSD is already part of the standard specification and the major browsers support it, a project that needs to support older browsers should check exact compatibility before relying on it in production, or include a polyfill for cases without native support.
- The cost of maintaining two execution environments: a component that runs both on the server (inside
@lit-labs/ssr's simulated environment) and in the browser must avoid depending on browser-exclusive APIs (such aswindow,document.querySelectoragainst the global document, or browser timers) in a way that breaks in the server environment; code like theContadorTiempoRestanteControllerfrom lesson 06-03, which usessetInterval, would need careful review to behave reasonably if an attempt were made to render it on the server without adaptation. - Server-framework integration ecosystem still under development: integration with specific server frameworks (Express, or others more specific to full-application SSR) varies in maturity, and some flows —such as the incremental fragment streaming mentioned in section 5— require more manual wiring code than the equivalent already solved in more established application frameworks, such as Next.js for React.
- Hydration is neither automatic nor free: although
@lit-labs/ssrcorrectly generates the initial HTML, the hydration process itself in the browser (reconciling the already-existing DOM with the reactive logic) remains, at the time this course was written, a piece of the ecosystem that demands more attention to detail than ordinary rendering from scratch, and not every pattern freely used throughout the course (for example, certain directives with complex internal state) is guaranteed to behave identically in a hydrated component versus one rendered normally from the start.
None of these limitations invalidates the package; they simply point out that, unlike the stable Lit core used throughout the rest of the course, SSR with @lit-labs/ssr is terrain where it's worth thoroughly testing the application's specific case before taking it for granted in production.
Common Mistakes and Tips
- Thinking SSR "flattens" the Shadow DOM into plain HTML: as explained in section 6,
@lit-labs/ssrdoesn't discard the Shadow DOM when generating HTML; it represents it faithfully via Declarative Shadow DOM, precisely so that the later hydration finds a real structure, not a simplified approximation. - Confusing SSR with hydration as if they were the same thing: as explained in section 2, they are two distinct, complementary steps; SSR generates the initial HTML on the server, hydration connects that already-existing HTML with the reactive logic in the browser. Without the second step, the page would have visible content but no component would respond to interactions.
- Using browser-exclusive APIs without checking the environment: as pointed out in section 8, a component meant to also run on the server must avoid assuming, without checking, that
windowordocumentbehave exactly as they would in a real browser;@lit-labs/ssr's simulated environment covers the essentials, but it isn't a complete browser. - Adopting
@lit-labs/ssrfor an internal project with no real need for SEO or critical perceived performance: given its experimental nature (section 4) and its limitations (section 8), it's worth reserving it for cases where the problem it solves —content visible before the JavaScript loads— has a real, measurable impact, not adopting it by default in any Lit project without evaluating whether the extra cost is justified.
Exercises
- Explain, in your own words and based on section 3, why SSR for a React or Vue component without Web Components doesn't need any mechanism equivalent to Declarative Shadow DOM, while a Lit component does need it.
- A teammate, after reading about
@lit-labs/ssr, proposes using it to render<task-card>'sContadorTiempoRestanteController(the urgency timer from lesson 06-03) on the server exactly as it's written, expecting the initial HTML to already show the correct urgency state. Point out, based on section 8, what specific problem that controller would have when running in the server environment with no adaptation. - Revisit the example from section 7 and explain why
await cargarTareas()is perfectly valid inside the server's route handler, while lesson 07-03 insisted that a Lit component'srender()can never be anasyncfunction nor directly await a promise. Are both statements compatible, or does one contradict the other?
Solutions
- A React or Vue component without Web Components never creates a separate Shadow DOM root at any point; all its content is inserted directly into the page's regular DOM, like any other set of HTML tags. The HTML generated on the server for that component is, therefore, structurally indistinguishable from any other HTML fragment on the page, and needs no special mechanism to represent it: ordinary tags, already supported by any HTML engine forever, are enough. A Lit component, by contrast, encapsulates its content inside a real Shadow DOM (lesson 04-01), a structure traditional HTML had no way to express declaratively until the arrival of Declarative Shadow DOM; without DSD, the server could only generate the "flattened" content, with no encapsulation boundary, which would not faithfully reproduce how the component actually behaves in the browser.
ContadorTiempoRestanteControllerusessetInterval(as described in lesson 06-03) to periodically recalculate whether a task is close to its deadline, updating its state as real time passes in the browser. In@lit-labs/ssr's server environment, the HTTP request resolves and responds at one specific instant; it doesn't stay "alive" indefinitely like an open browser tab does; starting asetIntervalduring that render would make no practical sense (the server isn't going to keep that interval running forever for every processed request, and if it did, it would be a serious resource leak), and the generated HTML could only reflect the urgency state calculated at the exact instant of the request, not a continuous update. The controller would need, at minimum, to check which environment it's running in and avoid scheduling thesetIntervalif there's no real browser behind it, leaving it to the later hydration, already on the client, to activate the real timer.- Both statements are perfectly compatible, because they refer to restrictions in different contexts. Lesson 07-03's restriction applies to a Lit component's
render()method running in the browser, where Lit needs that method to return a template synchronously so it can update the DOM immediately, without blocking the browser's main thread waiting on a promise. A server route handler, by contrast, doesn't have that restriction: it's common and correct for an HTTP request-handling function to beasyncand toawaitany asynchronous operation (a database query, a call to another service) before generating and sending the complete response; the server, unlike a component'srender(), doesn't need to return something "immediately" in the same sense, because its job is to respond to a single HTTP request, not to maintain a reactive, fluid user interface in the face of continuous state changes.
Conclusion
This lesson has presented @lit-labs/ssr as the way, still experimental but functional, to generate on the server the initial HTML of Lit components with their Shadow DOM faithfully represented thanks to Declarative Shadow DOM, solving the problem of the empty page until the JavaScript loads, at the cost of a second step —hydration— and several limitations currently in effect that are worth evaluating case by case before adopting it in production. With integration toward plain HTML, toward other frameworks, and toward the server now covered, one last front of integration with the world outside TaskFlow remains, of a more practical nature: how to bundle and publish the components themselves so other projects can consume them, and what changes if TypeScript is chosen instead of the JavaScript used so far.
Lit Course
Module 1: Introduction to Lit and Web Components
- What are Web Components and why Lit?
- Setting Up the Development Environment
- Your First Lit Component
- Anatomy of a Lit Component
Module 2: Reactive Templates and Rendering
- Lit's Template Engine
- Expressions and Interpolation in Templates
- Conditional Rendering
- List Rendering
- The Rendering Cycle
Module 3: Reactive Properties and State
- Reactive Properties
- Internal State with @state
- Types of Properties and Custom Converters
- Attributes vs Properties and Reflection
Module 4: Styling Lit Components
- Encapsulated CSS with Shadow DOM
- Shared Styles Between Components
- Custom CSS Properties and Theming
- Slots and Styling Distributed Content
Module 5: Events and Component Communication
- Handling DOM Events in Templates
- Custom Events: Communication from Child to Parent
- Communication from Parent to Child with Properties
- Communication Patterns Between Sibling Components
Module 6: Lifecycle and Advanced Behavior
- Lifecycle Callbacks
- Reactive Hooks: willUpdate, updated, and firstUpdated
- Reactive Controllers
- Mixins and Composing Behavior
Module 7: Directives and Advanced Template Features
- Built-in Directives: classMap, styleMap and ifDefined
- Custom Directives
- Asynchronous Rendering with until
- Shared Context with @lit/context
Module 8: Integration, Interoperability and Deployment
- Using Lit Components in Plain HTML
- Integrating Lit with React, Vue, and Angular
- Server-Side Rendering with @lit-labs/ssr
- Bundling, Publishing, and TypeScript
Module 9: Testing and Best Practices
- Unit Tests with Web Test Runner
- Accessibility in Web Components
- Performance and Optimization
- Common Patterns and Anti-patterns
