/Documentation

Server rendering

PublishedUpdated

mtrl's components are built in the browser: a web component builds its factory inside its shadow root when it upgrades, and a factory needs document. So the server sends each component as its element, the host tag with its attributes and light DOM, and the browser upgrades it once its script defines it. This page covers the first paint and what each framework needs; it holds for the plain web components in a server template too.

What the server sends

Next.js, Nuxt, SvelteKit and SolidStart render the framework components to their elements. A switch that is on, with its label, arrives as:

<m-switch checked>Wi-Fi</m-switch>

Strings, numbers and booleans become attributes, a bound value becomes its default attribute (checked, value), and children stay children. Objects, functions and listeners wait for the browser, where the component registers its element when it first mounts and the element upgrades in place. The framework hydrates its own markup; the shadow root is the element's alone, so the two never disagree.

Importing on the server

Every mtrl module imports safely without a DOM, the framework entries included. Importing mtrl/elements registers nothing, and the framework components register their elements only when they mount, which never happens on a server. So no import needs a guard and mtrl's components need no client-only wrapper. Only calls need the browser: run a factory such as createButton(), or defineAll(), in client code or an effect.

Avoiding layout shift

Until it upgrades, an element has no shadow root, so none of its styles: a button shows as its bare label, then jumps to its real size when the script arrives.

The pre-upgrade stylesheet fixes the size. Put it in the server-rendered <head>, after the base stylesheet, so it applies from the first paint:

<head>
  <link rel="stylesheet" href="/css/mtrl-base.css">
  <link rel="stylesheet" href="/css/mtrl-preupgrade.css">
</head>

The files are mtrl/styles/base and mtrl/elements/preupgrade.css: serve copies of them, or import them where your bundler handles CSS, as the framework guides do:

import 'mtrl/styles/base';
import 'mtrl/elements/preupgrade.css';

The rules are built for the m- prefix. With elements registered under another one, build the same rules on the server and inline them in a <style>:

import { preupgradeStyles } from 'mtrl/elements/preupgrade';

const head = `<style>${preupgradeStyles('md')}</style>`;

What it does

Every rule is scoped to :not(:defined), so none of them can reach an upgraded element. Until then, they give each element the box it will have:

  • The host's box: its display, height, width or minimum width, margins, and corners where they shape it. A filled button has its container colour, and a text field its container and indicator.
  • Its text in its final style. A button's label is set in the type style and colour it will have; a text field shows its label at rest and its value on the input's line.
  • Declaration children aren't painted. <m-tab>, <m-chip>, <m-list-item> and the like only mean something after upgrade, so they are hidden while their room stays reserved.
  • Overlays render nothing. A dialog, sheet, menu, tooltip or snackbar opens in the top layer and takes no room on the page, so it stays hidden until it upgrades, even with open.

The colours come from the theme, so the base stylesheet goes in the head too. The rules sit in their own cascade layer, mtrl.preupgrade, so your CSS wins over them, and each element's CSS module applies them as well when it loads.

mtrl measures this in CI: every element, in its common configurations, and a React server render must shift the layout by less than 0.01 (the Cumulative Layout Shift score) between the first paint and the upgrade.

Its limits

  • One line of supporting text. A text field, select or date picker with supporting-text or maxlength reserves one line under the field. Supporting text that wraps onto a second line once upgraded still pushes what follows down.
  • An open overlay appears at upgrade. A dialog rendered with open shows when its script runs, not at first paint. It moves nothing when it does.
  • Size, not behaviour. Before upgrade an element looks right but does nothing: it ignores clicks, and a form submitted before then doesn't include its value.

Per framework

Each framework guide has a section on server rendering; in short:

  • Next.js. mtrl/react starts with "use client", so Server Components can render its components; handlers and refs need a client component of your own. Import both stylesheets in the root layout.
  • Nuxt. The components register in onMounted, so no <ClientOnly> is needed. Load the pre-upgrade stylesheet in app.vue or in nuxt.config's css.
  • SvelteKit. Nothing to turn off for the server: import the pre-upgrade stylesheet once in the root +layout.svelte.
  • SolidStart. SolidStart adds Solid's hydration script; a hand-made server render puts generateHydrationScript() in the head. No clientOnly is needed. Import the stylesheet in src/app.tsx.

In all four, a ref to the element is only set in the browser, so reach the element in a mount hook or an event handler.

A server template without a framework registers the elements in its client script:

import 'mtrl/elements/css';
import { defineAll } from 'mtrl/elements';

defineAll();

What's coming

The pre-upgrade styles keep the page still, but the first paint is only close to each component. The plan for mtrl 1.0 is to send the shadow root from the server too, as Declarative Shadow DOM: a <template shadowrootmode="open"> inside each element, which the browser attaches while parsing, before any script runs. The first paint would then be the real component. The pre-upgrade styles would stay as the fallback.