Skip to content
ShineUI
Install
ShineUI / Server rendering

Server rendering

Optional, and in its own package, so it costs nothing unless you want it.

@shineui/core is the same package whether or not you server-render. Hydration lives in @shineui/ssr, so a page that does not need it ships none of it.

The two halves

You need both. @shineui/ssr renders the markup on the server. @shineui/ssr/components is the same elements compiled with hydration, so the browser takes that markup over instead of rebuilding it. Import the ordinary @shineui/core/components on a server-rendered page and it rebuilds, with the flash you were avoiding.

// server: render the markup import { renderToString } from "@shineui/ssr"; const { html } = await renderToString( '<sui-button variant="accent" label="Pay bills"></sui-button>', { prettyHtml: false } ); // client: the SAME elements, compiled with hydration, so the browser // takes the markup over instead of rebuilding it. One call per element. import { defineCustomElement } from "@shineui/ssr/components/sui-button"; defineCustomElement();

Registration is one call per element. Each file under @shineui/ssr/components/ exports a defineCustomElement for its own element, which keeps a server-rendered page from shipping all 146. Write the path with no .js on the end. To register every element at once, read the bulk module.

import * as hydrated from "@shineui/ssr/components"; for (const [name, define] of Object.entries(hydrated)) { if (name.startsWith("defineCustomElement")) define(); }

This package has no defineCustomElements(). That belongs to @shineui/core/loader, for a page that is not server-rendered.

Per framework
  • Plain HTML and Vite. The path above.
  • React. The components render nothing on the server and while the page hydrates, and the elements draw in the render after, so a Next.js or Remix page hydrates with no mismatch. React cannot take over markup these elements drew: they render into their own light DOM, among the children React owns, and React reports error 418 and rebuilds the whole tree. A React page server-renders its own markup. Frameworks has the detail.
  • Vue. As in React: the components render nothing on the server and while the page hydrates, and the elements draw with the content of their slots once the page has mounted, so a Nuxt page with server rendering on hydrates with no mismatch. Bundlers has the Nuxt setup.
  • Angular. Keep hydration on and give each of your components that holds ShineUI elements ngSkipHydration. Angular renders those afresh on the client and hydrates the rest of the page, incremental hydration included. Frameworks has the detail.
Data that is not an attribute

Server markup is attributes and children, and an array, an object or a function cannot be an attribute. Hand those to the renderer in beforeHydrate, which runs on the render's own document before the elements start: set the property on the element there, and the element renders with it. A table with its rows, a select with its options, a menu, a side nav and a chart all come back complete this way.

import { renderToString } from "@shineui/ssr"; const { html } = await renderToString(page, { async beforeHydrate(doc) { const table = doc.querySelector("#invoices"); table.columns = columns; table.rows = await loadInvoices(customerId); }, });

Set the same properties on the client before you register the elements, so the first client render matches the markup it takes over. A render callback, such as a column's render, runs on the server as well, where there is no global document: return a string, or build nodes with the document beforeHydrate was given. A callback that throws costs only its own cell, row or card, which falls back to what the element draws without one, and the error is logged once.

Children added after hydration

On a hydrated page, add a child to an element with the element's appendChild: the child lands at the end of its slot, inside the element's frame. To put one between two others, call before() or after() on a child that is already there, and take one out with its own remove(). The element's insertBefore and removeChild throw NotFoundError for a child in a slot on such a page, and its append and prepend leave the child outside the frame. A page that @shineui/core draws takes all of them. The hydrating build turns on one of Stencil's slot patches rather than all of them, because the others break on the nodes hydration takes over.

const tabs = document.querySelector("sui-tabs"); const tab = (label) => Object.assign(document.createElement("sui-tab"), { label }); tabs.appendChild(tab("Audit")); // the last tab in the bar tabs.querySelector("sui-tab").after(tab("Roles")); // the second tab tabs.querySelector("sui-tab").remove(); // the first tab goes
Five elements that fetch a library

sui-code-block and sui-scheduler-builder load Shiki, sui-rich-text-editor and sui-edit-box load ProseMirror, and sui-qr-code loads the QR encoder, each only when its feature runs. Install them on the server only if you render those elements there. @shineui/ssr lists them as optional peer dependencies for that reason.

Content security policy

The renderer writes each element's styles into the head of the page. Under a style-src with no 'unsafe-inline', those style elements need the request's nonce. Generate a fresh nonce for every request, render the whole document with the nonce in a <meta name="csp-nonce"> in its head, and send the same nonce in the header. The renderer stamps it on every style element it writes, and in the browser the elements and applyTheme read the same tag. A fragment has no head, so its styles carry no nonce.

import { randomBytes } from "node:crypto"; import { renderToString } from "@shineui/ssr"; const nonce = randomBytes(16).toString("base64"); const { html } = await renderToString(`<!doctype html> <html lang="${lang}"> <head><meta name="csp-nonce" content="${nonce}"></head> <body>${body}</body> </html>`); response.setHeader("Content-Security-Policy", `default-src 'self'; style-src 'self' 'nonce-${nonce}'`); response.end(html);

Security has the rest of the policy.

Language on the server

Give each render its language in its options. language sets <html lang> (a full document's own lang does the same), and locales hands that render its language packs, keyed by tag: the objects registerLocale takes in a browser. Built-in words such as Close or Next page, and numbers and dates, then come back in that language in the first response. A tag such as fr-CA falls back to the fr pack.

import { renderToString } from "@shineui/ssr"; import de from "@shineui/core/locales/de"; import fr from "@shineui/core/locales/fr"; const packs = { de, fr }; // lang comes from the request: the user's setting, the URL, or Accept-Language. const { html } = await renderToString(body, { language: lang, locales: packs[lang] ? { [lang]: packs[lang] } : {}, });

Each render runs in a window of its own, and the packs go into that window, so requests in different languages render at the same time without reaching each other. It is also why registerLocale and setLocale in your server code reach no render: on the server, the options are the way in. In the browser, register the same pack, and the elements keep speaking it after they hydrate. The locale prop on an element still wins over the page's language, on the server as in the browser.