Eunomia v1.1.0
AllAngularReactNext.jsJavaScriptVue
EunomiaSalmonForestVioletOceanGoldFireCustom…
🇬🇧 English🇫🇷 Français

Server-side rendering

A web component only exists once the browser has loaded its code and upgraded the tag. Before that, a server-rendered page holds the tag, its attributes and its light DOM children, nothing more. This guide covers the three consequences of that in an SSR app (Next.js, Nuxt, SvelteKit, Angular Universal, ...): what can be imported on the server, how registration interacts with hydration, and how to keep links and text in the server HTML for crawlers that don't run JavaScript.

Importing on the server

Every component module registers its tag as soon as it's evaluated, so the package entry point and each component subpath stay client-side only (see Get started). Two subpaths are the exception, since they register nothing and are safe in a Server Component:

  • @eunomia/elements/theme.js: buildThemeCss, buildPalette, buildTints and initializeTheme, for example to render a custom brand's tokens straight into <head>;
  • @eunomia/elements/rich-text.js: toHtml and toPlainText, to turn markdown-lite into HTML on the server.
// app/layout.tsx, a Server Component
import { buildThemeCss } from "@eunomia/elements/theme.js";

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <head>
        <style>{buildThemeCss("#0f766e", "#e5a030")}</style>
      </head>
      <body>{children}</body>
    </html>
  );
}

Importing the package entry point on the server and again in a client component makes the framework evaluate it twice against the same registry, and the second registration throws. Reach for these two subpaths instead.

Hydration

Once upgraded, a component may write attributes on its own host: its ARIA role and state (role, aria-checked), its focus stop (tabindex), or a state it reached at runtime (in-view on eun-animate). If the library finishes loading before the framework hydrates, the framework finds attributes the server never rendered, and React reports a hydration mismatch for each of them.

Components keep these writes to a minimum: an attribute that would only state a default value (appearance="default", size="m", orientation="horizontal", type="button", theme="light", aria-disabled="false", aria-readonly="false", an empty options or value) is left out, and sizes and timings (eun-icon's size, eun-animate's duration and delay) stay inside the shadow root rather than in an inline style on the host. What remains carries meaning, so it can't go.

Two ways to keep hydration clean:

  • Load the library after hydration, with a dynamic import() in an effect. Simple, at the cost of a later upgrade.
  • Load it early and register after hydration. Importing the library registers every tag immediately, so hold the registrations back while it loads, then release them once the framework has hydrated:
"use client";
import { useEffect } from "react";

type Definition = Parameters<CustomElementRegistry["define"]>;
const pending: Definition[] = [];
const nativeDefine =
  typeof window === "undefined"
    ? undefined
    : customElements.define.bind(customElements);

if (nativeDefine) {
  customElements.define = (...definition: Definition) => {
    if (definition[0].startsWith("eun-")) {
      pending.push(definition);
    } else {
      nativeDefine(...definition);
    }
  };
  void import("@eunomia/elements");
}

export function EunomiaLoader() {
  useEffect(() => {
    if (!nativeDefine) {
      return;
    }
    // Runs once React has committed the hydrated tree.
    customElements.define = nativeDefine;
    for (const definition of pending.splice(0)) {
      if (!customElements.get(definition[0])) {
        nativeDefine(...definition);
      }
    }
  }, []);
  return null;
}

Render <EunomiaLoader /> once, near the root of the app. The code is downloaded and evaluated as early as a static import, only the upgrade waits for hydration.

A <Suspense> boundary hydrates later than the rest of the page, after the effect above has run. Components under it may already be upgraded when React reaches them, and the attributes they wrote are then reported as mismatches. Avoid the boundary when you can: in Next.js, read searchParams in the server page and pass the value down as a prop, rather than calling useSearchParams, which requires one.

React doesn't apply the props it gives a custom element while it hydrates either: an object property such as events, or an oneunchange listener, is lost until a later render changes it. Set them through eunProps from @eunomia/elements/react, and write arrays a component reads as JSON, such as a stepper's steps, as an attribute string (see Get started: properties and events).

Boxes before the upgrade

A component's box (padding, border, background and corners) is drawn in its shadow root as well. Before the upgrade, a button or a tag is plain inline text and a card has no surface, then each takes its final shape all at once: a visible flash and a layout shift, which counts toward CLS above the fold. Link the matching stylesheets in the page's <head> to draw the final box from the first paint:

<link
  rel="stylesheet"
  href="node_modules/@eunomia/elements/dist/ssr/button-ssr.css"
/>
<link
  rel="stylesheet"
  href="node_modules/@eunomia/elements/dist/ssr/tag-ssr.css"
/>
<link
  rel="stylesheet"
  href="node_modules/@eunomia/elements/dist/ssr/card-ssr.css"
/>
  • button-ssr.css covers eun-button, eun-dropdown-button and eun-fab: sizes, colors, appearances, rounded, full, disabled, the --button-* and --fab-* custom properties, the room of the icon set by icon or severity, and a positioned fab's corner;
  • tag-ssr.css covers eun-tag: sizes, colors, severities, appearances, rounded, uppercase, icon-only, hide-icon, the room of its icon and the --tag-* custom properties;
  • card-ssr.css covers eun-card, eun-image-card and eun-sliding-card: appearances, the image card's layout and the sliding card's front layer at rest, with their --card-*, --image-card-* and --sliding-card-* custom properties.

Only the resting state is covered: hover and focus styles, animations and loading indicators arrive with the upgrade. Your page's styles on the element itself (its width, margins, position) and on its slotted content win over these stylesheets, just as they win over the component's own styles once it upgrades. The rules sizing and placing the element are written at zero specificity for that, so a single class of yours is enough.

Search engines

Links and crawlers

With href set on eun-link, eun-button or eun-card, the actual <a href> only exists in the shadow root, after the upgrade. Google renders JavaScript and finds it, but only in its rendering pass, and crawlers that don't run JavaScript see no link and no anchor text at all.

For every link that carries the site's structure (navigation, internal calls to action, cards linking to pages), leave href unset and place a native anchor as a direct child instead. It's in the server HTML, keeps the component's styles and states, and can be your framework's own link component, which then handles client-side routing itself:

<eun-link current>
  <a href="/contact">Contact</a>
</eun-link>

<eun-button color="primary">
  <a href="/contact">Get in touch</a>
</eun-button>

<eun-card>
  <a href="/work/atlas"><h3>Atlas</h3></a>
  <p>A design system for a logistics platform.</p>
</eun-card>

In this mode:

  • the anchor owns navigation, so the component doesn't fire eunnavigate for it and the SPA navigation bridge isn't needed for these links;
  • current sets aria-current="page" on the anchor itself;
  • disabled and loading on eun-button mark the anchor aria-disabled="true", take it out of the tab order and block its clicks, since a link has no native disabled state;
  • on eun-card, the anchor stretches over the whole card, so the card stays clickable everywhere while its accessible name remains the anchor's own text. Keep other interactive content out of a linked card;
  • aria-label, target="_blank", its rel, download and the "(opens in a new tab)" hint are yours to write on the anchor. Left on the host, they're ignored, and an aria-label there lands on an element with no role, so the link loses its accessible name. The component logs a warning in the console when it finds one of them on the host.

The anchor's style comes from the component's shadow root, so it only applies once the element upgrades. Until then, and for good for a crawler or a visitor without JavaScript, the anchor looks like a default browser link, blue and underlined. Link links-ssr.css in the page's <head> to give it the surrounding text color and no underline before the upgrade:

<link
  rel="stylesheet"
  href="node_modules/@eunomia/elements/dist/ssr/links-ssr.css"
/>

It also draws the new tab arrow after an anchor with target="_blank" in a link or a button, and after a link or a button set with href and target="_blank", until the component's own icon takes over, and visually hides a .eun-new-tab-hint text written inside the anchor, for good this time since the anchor stays in your page. That text is how a new tab gets announced in the server HTML: each component's page shows it under "Search engines and server rendering".

The anchor also lives in your page now, so your page's CSS reaches it. Rules from the document win over the component's ::slotted(a) rules whatever their specificity, so a global a { ... } or an old rule that targeted links now also restyles these ones. The focus ring is protected against a global *:focus-visible outline, but check the color and decoration rules your stylesheets apply to a.

href on the host remains fine for tel:, mailto:, external links and purely interactive links that don't need indexing.

Text content and crawlers

The same applies to text passed through an attribute and rendered in the shadow root. On eun-collapse, pass the heading through the heading slot and the answer through the default slot, rather than the heading and rich-text attributes, whenever the text matters for search, such as an FAQ (see Collapse).

How long does a project take?

Usually four to eight weeks, depending on its scope.

<eun-collapse level="3">
  <span slot="heading">How long does a project take?</span>
  <p>Usually four to eight weeks, depending on its scope.</p>
</eun-collapse>

The question and the answer are both in the server HTML. toHtml comes from the side-effect-free rich-text.js subpath, so it runs in a Server Component just as well.

Slotted content doesn't get the typography the rich-text attribute applies in the shadow root (paragraph spacing, list bullets and indent), and your page's resets reach it. Link rich-text.css and add the eun-rich-text class to the wrapper, and both modes look the same:

<link
  rel="stylesheet"
  href="node_modules/@eunomia/elements/dist/ssr/rich-text.css"
/>

subheading and tag have slots of their own on eun-collapse as well, for the same reason.

See also

  • SPA navigation, for links that keep href on the host;
  • the Server-side rendering section of Animate, Toolbar and Collapse, for their pre-upgrade stylesheets.