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
@eunomia/elements/theme.js:buildThemeCss,buildPalette,buildTintsandinitializeTheme, for example to render a custom brand's tokens straight into<head>;@eunomia/elements/rich-text.js:toHtmlandtoPlainText, 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
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.csscoverseun-button,eun-dropdown-buttonandeun-fab: sizes, colors, appearances,rounded,full,disabled, the--button-*and--fab-*custom properties, the room of the icon set byiconorseverity, and a positioned fab's corner;tag-ssr.csscoverseun-tag: sizes, colors, severities, appearances,rounded,uppercase,icon-only,hide-icon, the room of its icon and the--tag-*custom properties;card-ssr.csscoverseun-card,eun-image-cardandeun-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>
import Link from "next/link";
export function Navigation({ pathname }: { pathname: string }) {
return (
<eun-link current={pathname === "/contact"}>
<Link href="/contact">Contact</Link>
</eun-link>
);
}
<template>
<eun-link :current="route.path === '/contact'">
<RouterLink to="/contact">Contact</RouterLink>
</eun-link>
</template>
<eun-link [current]="isContact">
<a routerLink="/contact">Contact</a>
</eun-link>
In this mode:
- the anchor owns navigation, so the component doesn't fire
eunnavigatefor it and theSPA navigation bridge isn't needed for these links; currentsetsaria-current="page"on the anchor itself;disabledandloadingoneun-buttonmark the anchoraria-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", itsrel,downloadand the "(opens in a new tab)" hint are yours to write on the anchor. Left on the host, they're ignored, and anaria-labelthere 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
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>
import { toHtml } from "@eunomia/elements/rich-text.js";
const faq = [
{
question: "How long does a project take?",
answer: "Usually four to eight weeks, depending on its scope.",
},
];
export function Faq() {
return faq.map((item) => (
<eun-collapse key={item.question} level={3}>
<span slot="heading">{item.question}</span>
<div
className="eun-rich-text"
dangerouslySetInnerHTML={{ __html: toHtml(item.answer) }}
/>
</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 keephrefon the host;- the Server-side rendering section of
Animate ,Toolbar andCollapse , for their pre-upgrade stylesheets.