Prerendering
Render every route to static HTML when the site is built. The browser takes each page over as it stands: nothing renders twice, nothing is fetched twice.
Setup
entry: () => import("@excom/nucleus-kit/server"). Build the site with npx nucleus-ssr prerender.config.js: it prerenders every route, reports each page and fails on a broken internal link. The nucleus-ssr page has the working example, the options and what fails a page. The app itself stays as it is: the same HTML, sheets, views and kit <script>. Prerender after every fresh build: a shell an earlier prerender wrote is refused.
How it fits
The stack needs no second rendering model for this. Three things it already is are enough:
- The HTML is the State. State is attributes, text and children, so a serialized document is the State, fully formed. Provisions are State too: the page carries them, and each Adapter has its own again before it mounts.
- Adapters are drivable. Writing an Adapter's attributes reproduces what its protocol would have done, so a page that arrives with
is-activeoris-successset is an ordinary State to start from. - Hydration is a cold mount. No element is told it hydrates. Each mounts as on any page load; its requests are answered at once, from the page, and its writes find their result already there. The Orchestrator derives the same State again and skips every equal write.
In the browser
A prerendered page is static HTML plus the data it was built from, in a JSON <script> at the end of <body>. Once the kit loads:
- Views (
include-content,spa-route) keep their prerendered content: no re-render, no refetch, no flash.did-renderfires once, and content withready-onis ready at once, never hidden. A view that changed since the build renders as on a cold load. - Providers announce as on a cold load,
provider-fetch-loadingthenprovider-fetch-success, with the response the page carries and no request. - Loaded state (
is-success/did-load) is dropped and set again within one task as an element loads: content gated on it never flashes, and a MutationObserver sees one removal and one re-add. - Sheets find what they would paint already there;
iterate()adopts the prerendered rows. spa-managerruns its first update without a View Transition ortransition-delayand leaves scroll to the browser; a reload gets its saved position back.- What the prerender left out happens now, as on any page load: elements that read the device or the person,
no-ssrregions,shadow/iframerender hosts,pre-fetch="idle"fetches, anything behind a long timer (@delay 3000). An element whose data was kept out of the page arrives not loaded, its rendered content in place, and fetches as on a cold load.
Hydration ends once nothing holds it: kit requests, sheets and @use modules still loading, the first spa-manager update. From then on the page is an ordinary one. It lasts 10 s at most, and a console warning says when that limit ended it.
Sheets
A sheet needs no change: the same sheet runs in the prerender and in the browser.
One contract to keep: an @on write is a one-shot, on both sides. @on blocks run during the prerender and their writes are in the HTML. In the browser the elements' events fire again (provider-fetch-success, spa-route-did-render), so a block applies again if its sheet is listening by then, the $binding only an @on block wrote is unbound in the browser until that block runs again. Content that must survive prerendering derives from state attributes and provisions:
/* derived from State: the browser's first pass paints the same */
provider-fetch:not([is-success]) [bind-status] { content: "Loading…"; }
provider-fetch[is-success] [bind-status] { content: "Loaded"; }
/* a one-shot over a declarative paint: "Loading…" again, until the event is heard */
provider-fetch {
[bind-status] { content: "Loading…"; }
@on provider-fetch-success { [bind-status] { content: "Loaded"; } }
}/* derived from State: the browser's first pass paints the same */
provider-fetch:not([is-success]) [bind-status] { content: "Loading…"; }
provider-fetch[is-success] [bind-status] { content: "Loaded"; }
/* a one-shot over a declarative paint: "Loading…" again, until the event is heard */
provider-fetch {
[bind-status] { content: "Loading…"; }
@on provider-fetch-success { [bind-status] { content: "Loaded"; } }
}Four smaller things:
- Of two rules that set the same attribute, or text
content:, on one element in one pass, only the later writes: a prerendered text node that already shows it is kept.iterate(),template(),dangerous-html()andnonecontent is still written rule by rule, so one matching rule per element keeps the prerendered nodes. iterate()adopts prerendered rows by key: the key property's value, else a hash of the item, compared as text. A row whose key the browser does not find renders again.template()anddangerous-html()content is kept when its source is unchanged, replaced otherwise.- A
$bindingnot written yet, its sheet still loading a@usemodule, keeps what is painted until it is bound; once hydration ends, an unbound name isundefinedagain.
Keeping elements out
What should not render at build time mounts in the browser instead, as on a cold load.
no-ssr keeps a region out. No Neutron element on or inside an element with the attribute mounts in the prerender, NucleusKit elements included. Use it for one place in a page, such as a live demo or a lazy-load view that should stay lazy:
<include-content no-ssr lazy-load template-ref="/views/map/map.html"></include-content><include-content no-ssr lazy-load template-ref="/views/map/map.html"></include-content>A sheet may write it too, on the element itself and first in the rule that activates it. An element that had already loaded or rendered when no-ssr reached it fails the page.
ssr: false keeps a tag out. Neutron({ tag, props, ssr: false }) keeps every instance of your own element out, wherever it is written and without an attribute: for an element whose code reads the device or the person. Only the tag's own instances stay out; what they hold prerenders as usual.
Your own elements
Compose fetchable-element / renderable-element and an element hydrates like the kit's own, with nothing to add. An element that calls fetch() and replaces its own content still works on a prerendered page: it fetches and renders again. To keep the prerendered result instead, use these from @excom/kit-utils:
fetchRecord(url, init)in place offetch(). It resolves the response as plain data (ok,status,headers,bodyas text). The prerender records a successful one, and in the browser the page answers, while it hydrates, as many identical GET / HEAD requests as the prerender made. A request that must be fresh or private always reaches the network: any other method, another origin, aRequestobject,cache: "no-store"/"no-cache"/"reload", anAuthorization,RangeorCache-Controlheader.replaceNonTemplateChildren(host, nodes, { identity })to render.identitynames the source:templateIdentity(ref, { scope })orhtmlIdentity(html). Wherehoststill holds what the prerender rendered from that source, its nodes stay (yours are not inserted) and the call returns"adopted".holdHydration(promise)keeps the page hydrating until other start-up work settles,whenHydrated()resolves once hydration is over, andisServerRender()istrueduring a prerender.
A provision of plain data (what JSON carries) is restored from the page before the element mounts; any other is derived again in the browser. When the response it came from stayed out of the page, the element is written not loaded (no is-success / did-load) and fetches again. An element that reads the device or the person is defined with ssr: false, or stays undefined in the prerender: the entry does not load it, and excludedTags has the renderer check. A <script> an element inserts as HTML never runs in the browser, but does once it sits in a prerendered file: insert none. The attributes the prerender writes for itself (n-ssr, n-tpl, n-tpl-id, n-inert, q-key) are never written or selected on by hand.
Static hosting
- Slashless files.
/docs/introis written todocs/intro.htmland/toindex.html. The host must serve/docs/introfrom that file: many static hosts do, some need a rewrite rule. - A real 404. Prerender the not-found route to
404.html. Once/is prerendered,index.htmlis the home page: a host that falls back to it would answer every unknown URL with the home page and a 200. With a404.html, every route needs its file: a route that depends on the person (a bag, an account) is written as the untouched shell, through , and renders in the browser. The kit's server entry fails a soft 404, a page only the fallback route matches, so a link to a page that is gone cannot be written as one.shellRoutes - A file served for another URL still works: the content of a route the URL does not match is removed, and the matching route renders.
- Script loading. Load the kit as a module or deferred script, so the page paints before the kit runs. A classic script works too: elements mount once the document is parsed.
- Public files. Nothing a per-person response returned may be in a prerendered page. nucleus-ssr fails a page built from a response marked private.
Limits
- Build time only. No per-request rendering: what only a request knows (the person, a cookie) renders in the browser.
- One renderer per process. Pages render one at a time, or in a pool of worker processes; a cache writes a page again without rendering it while its shell and requests answer as in the last build. Both are
nucleus-ssr options . - happy-dom, not a browser. The prerender has no layout: every observed element counts as in view, so
lazy-loadviews are in the page unlessno-ssrkeeps them out, andmatchMedia()answers for one viewport. Where the browser derives something else, it writes it. Markup a browser would parse into other elementsfails the prerender : most often block content rendered into an element that sits in a<p>, or rows rendered straight into a<table>(write the<tbody>). Thehydration test runs on happy-dom too, so it cannot show what only a real browser does. - Time and randomness. A page that depends on them differs between builds, and from what the browser derives.