On this pageOverview
Server Rendering
Experimental
Server rendering ships from foldkit/experimental/server while its API and operational contract settle. It will move to foldkit/server once Foldkit can make a stable compatibility commitment to both. It may change in any Foldkit release and has not yet had broad production exposure.
Pin the exact version you deploy and test upgrades against your own SSR or SSG host. For regulated or security-critical workloads, wait for the stable export, or have your security and deployment setup reviewed independently first.
The lowest-risk use today is statically generated public content, which is how this site uses it. Please try it and report what breaks.
Foldkit renders on the server with the same program the browser runs. renderToString resolves init, runs the pure view, and returns HTML. Runtime.hydrate then adopts matching HTML in place and rebuilds mismatches. The same init, view, update, and Model work whether the HTML was rendered during a build (SSG) or while handling a request (SSR).
One program gives Foldkit one rendering pipeline with two delivery policies:
Static site generation (SSG): a build script renders a finite set of URLs and writes HTML files.
Server-side rendering (SSR): a server renders a URL when its request arrives.
An application can use either policy, or use SSG for some URLs and SSR for others. The application code does not need a second rendering API.
Here is one page from request to live application:
GET /page (from the browser)
│
▼
── on the host ── Vite in dev, your server in production
renderPage [you write]
derives Flags from the request
renderToString [Foldkit]
calls your init, runs your view → HTML
toResponse [Foldkit]
fills index.html, embeds the Flags payload
│
▼ HTML page + Flags payload
── on the browser ──
Runtime.hydrate [Foldkit]
reads the Flags, calls the same init
adopts matching DOM, rebuilds mismatches
live application
listeners and Mounts attach to the existing elementsOnce the live application takes over, it behaves like any other Foldkit application. Routing, update, Commands, and Subscriptions run in the browser. Navigation moves between routes without contacting the server. The server renders again only on a full page load, such as a reload or a link the runtime does not handle.
For SSG, the build script takes the host's place. It writes the response to a file that a static server or CDN delivers later.
A server entry connects the application to its host. It exports a renderPage function that accepts a Web Request and returns a Promise<EntryResult>:
import { Effect } from 'effect'
import { Server } from 'foldkit/experimental'
import { readCountCookie } from './cookie'
import { Flags, init, view } from './main'
const flagsForRequest = (request: Request): Flags => ({
initialCount: readCountCookie(request.headers.get('cookie') ?? ''),
})
export const renderPage = (request: Request): Promise<Server.EntryResult> =>
Effect.runPromise(
Effect.gen(function* () {
const renderedApplication = yield* Server.renderToString(
{ Flags, init, view },
{
flags: flagsForRequest(request),
buildId: import.meta.env.FOLDKIT_BUILD_ID,
},
)
return Server.Rendered(renderedApplication, {
headers: {
'cache-control': 'private, no-store',
vary: 'cookie',
},
})
}),
)The outer Promise keeps renderPage callable from Vite, build scripts, serverless functions, and long-running Effect HTTP servers. Those hosts do not need to provide the application's Effect requirements. The entry uses Effect internally; the host sees only the Promise.
The entry is application code. Keep it in src/ (src/entry.server.ts in the examples), not in the host's directory. It imports the application's init, view, and Flags, so the server build must compile it with those application imports.
The client and server are separate module graphs. Within each graph, the view and the Foldkit runtime that calls it must resolve to one foldkit module instance. The HTML builder tracks a render in module-level state. If one render uses two Foldkit copies, the view writes to one copy while the runtime reads the other. The render fails instead of producing the wrong page. Duplicate monorepo installs and aliases that split one graph are common causes.
A delivery host imports the built entry and calls renderPage. It does not import the application and render it directly. The SSR example's host lives outside src/, in server/, and does exactly that.
renderToString accepts the server-relevant subset of a makeApplication config. That subset contains init and view, plus Flags and routing when the application declares them. A full application config satisfies the subset, so an entry can pass it unchanged.
The container, update, subscriptions, and managedResources fields do not participate in server rendering. The server runs the view once over the Model returned by init. There is no DOM to attach to and no Message to dispatch.
For a routing application, pass the request URL so init receives the same value it receives from window.location in the browser:
Server.renderToString(config, {
url: request.url,
flags,
buildId: import.meta.env.FOLDKIT_BUILD_ID,
})request.url is the public URL. The Vite dev host preserves its configured base prefix and the browser's query string when middleware routes the request.
A server entry returns one of two variants:
Server.Rendered(application, options)asks the host to place Foldkit's rendered application in its HTML template. Its options can carry an HTTP status and headers.Server.Responded(response)bypasses template insertion with a complete WebResponse. Use it for redirects and any request that does not render a page.
Server.toResponse(template, result) turns either variant into the Web Response the host sends. It inserts a Rendered application into the template, defaults to status 200 and a UTF-8 HTML content type, and passes a Responded response through unchanged.
The render host serves pages, not a data API. Put JSON endpoints on a separate backend, such as an Effect HttpApi service.
The rendered application contains the body markup and the Document's initial head state:
type RenderedApplication = Readonly<{
html: string
title: string
lang?: string
dir?: 'ltr' | 'rtl' | 'auto'
canonical?: string
ogUrl?: string
}>injectIntoTemplate places that output in a standard index.html. The template must contain exactly one <div id="root"></div> placeholder. The placeholder has no other attributes and no whitespace inside it. The head must contain exactly one <title>. A missing or duplicate placeholder or title produces an error that names the problem.
Pass containerId when the template uses another id. The injector also writes the language, text direction, canonical URL, and Open Graph URL into the corresponding shell elements.
RenderedApplication is public so a host can transport or wrap it. Its html field remains protocol data. Pass the value returned by renderToString to injectIntoTemplate unchanged.
Hydratable HTML must parse as one top-level element with one nonempty application stamp and build stamp. It may be followed by one matching top-level JSON Flags script. Static HTML may contain one element, text, or comment root, or no body output.
Foldkit rejects extra top-level content, ambiguous handoff markers, and source that the HTML parser drops, splits, moves, or reconstructs. It does not insert markup when parsing changes which nodes belong to the application.
runtimeId names one application on the page. It pairs a root with its Flags payload. It also keys the Model and scroll position preserved by hot reloading.
Two roots with the same id are one application claimed twice. The second boot would read the first root's Flags and restore its Model. injectIntoTemplate refuses to build that page, and Runtime.hydrate refuses one assembled elsewhere. This rule also applies when the applications declare no Flags because hot reloading still uses the id.
Hydrating more than one application on a page is not supported. A page-owning makeApplication controls the document title, language, text direction, canonical URL, and Open Graph URL. It also installs document-wide navigation listeners. With two applications, the last render owns the metadata and the first listener handles every link. Distinct ids prevent roots from taking each other's handoff, but they do not divide document ownership. Render one application per page.
The placeholder's location and the view's root are part of the contract. Browsers move or drop markup that appears in an invalid parser context. Foldkit supports a short list of predictable contexts and refuses the rest. Each error names the rejected tag:
The placeholder must reach
<body>throughdiv,main,section,article,aside,header, orfooter. A placeholder inside<form>,<table>,<select>, SVG or MathML content, or<template>content is rejected.Rendered markup cannot declare a shadow root through
<template shadowrootmode>or the oldershadowrootattribute. Parsing moves that content out of the light DOM, so the browser tree and the hydration tree would differ. Attach shadow roots from a custom element instead.A view cannot be rooted at
<html>,<head>,<body>, or<frameset>.renderToStringrejects those roots for static and hydratable output because the document parser drops, merges, or replaces them. Root the view at an ordinary element such as<div>or<main>. Set the title, language, and text direction through theDocumentreturned by the view.
A hydratable render carries these markers:
The application root has
data-foldkit-app. Its value is theruntimeId.The root also has
data-foldkit-build. Its value identifies the deployment that rendered the page.An application with Flags emits a
<script type="application/json" data-foldkit-flags="...">. It carries the Schema-encoded Flags that produced the server Model. The attribute value matches the root'sruntimeId.Keyed elements carry
data-foldkit-key. Elements with build-assigned view identity carrydata-foldkit-identity. Both values are digests. The marker contains neither the original key, which may hold an account id or email address, nor the build's source path. Hydration compares each digest and removes the marker as it adopts the element. A render withisHydratable: falseemits neither marker.A digest is a comparison token, not a secret. A reader cannot reverse it directly, but can hash a guessed value and test for a match. Key by values that are safe to publish.
Conceptually, the handoff appears next to the rendered root:
<main data-foldkit-app="app"><!-- rendered view --></main>
<script type="application/json" data-foldkit-flags="app">
{ "initialCount": 2 }
</script>The script type makes the payload data rather than executable JavaScript. Foldkit escapes values that could close the script element. Hydration then parses and Schema-decodes the text. Flags are public HTML, not a place for secrets.
The client opts into the handoff in its entry (src/entry.ts in the examples):
Runtime.hydrate(application, { buildId: import.meta.env.FOLDKIT_BUILD_ID })Runtime.run always builds the DOM from scratch. An application with Flags supplies its client-only Flags Effect at that boundary:
Runtime.run(application, { flags })Runtime.hydrate accepts no client Flags producer. It reads the serialized Flags, calls the same init, and adopts matching server DOM nodes. Element identity, focus, scroll position, and media state survive while listeners and Mounts attach.
A mismatched subtree is rebuilt from its nearest parent. Rebuilding discards the DOM identity and browser state that adoption preserves. Development logs a warning that points to nondeterministic Flags, init, or view output. Production rebuilds silently, so test hydration before shipping.
Calling hydrate declares that a complete server handoff exists. If the handoff is invalid, startup stops before Foldkit adopts DOM and the page is put out of reach. What a refusal does describes that state. This is safer than booting a different client Model over the server's HTML.
Use run from a separate client entry when the page must also support a fresh SPA boot.
isHydratable defaults to true for SSR and SSG. Set isHydratable: false only for static markup that no client will hydrate. The output then carries no application stamp, build id, Flags payload, key marker, or identity marker. Runtime.hydrate refuses it.
Hydration requires the server and browser to build the same first Model. Embedded Flags let the browser call init with the values the server used.
Request-time SSR can derive Flags from the request, including the URL, headers, and cookies. Build-time SSG writes one file for every visitor, so its Flags must be universal and fixed at build time.
Flags are public
Every serialized Flag ships in the page's HTML. Never place credentials, private tokens, or other secrets in Flags.
Browser-only facts do not belong in hydratable SSG Flags. For example: a theme stored in localStorage, the viewport width, and browser feature detection are unknown during the build. Start with a neutral Model on both sides. Load browser facts through a boot-time Command or Subscription after hydration.
When a preference must affect the server HTML, make it request-visible, such as through a cookie, and use request-time SSR for that URL.
The build id does not make hydration correct. It makes hydration refuse when it would otherwise be incorrect.
The server stamps the id on the rendered root. The client bundle carries the same value. Hydration compares them before it accesses the Flags payload text or adopts DOM. Different ids stop startup; matching ids allow hydration to continue.
Most structural mismatches are safe because Foldkit rebuilds the affected subtree. The dangerous case is markup that has the same shape but a different meaning. For example: an old page may place <input name="email"> where the new build places <input name="ssn">. Without a build check, hydration could preserve text entered before startup and submit it under the new field name.
Flags create the same risk. A payload belongs to the deployment that rendered it. A new Schema may accept the old data even when its values now mean something different.
The deployment supplies the id because Foldkit cannot infer it. Imported constants, configuration, and caller arguments can change a view's output without changing the view function. @foldkit/vite-plugin compiles the value from its buildId option or FOLDKIT_BUILD_ID into application code as import.meta.env.FOLDKIT_BUILD_ID. The client and server entries pass that value explicitly:
// vite.config.ts: the plugin compiles the value into application code, from
// its `buildId` option or from FOLDKIT_BUILD_ID.
foldkit({ buildId: process.env.DEPLOYMENT_SHA })
// src/entry.server.ts
Server.renderToString(config, {
flags,
buildId: import.meta.env.FOLDKIT_BUILD_ID,
})
// src/entry.ts
Runtime.hydrate(application, { buildId: import.meta.env.FOLDKIT_BUILD_ID })Whatever value you pick, three things have to be true:
It is public. The id appears in the HTML sent to every visitor, so it must not contain a secret.
It identifies one deployment. Reusing an id makes a stale page look current and produces no warning. A commit or version is insufficient when the same revision can be deployed with different rendering inputs. The
ssrandssgscaffolds generate a fresh id wheneverFOLDKIT_BUILD_IDis unset.It reaches both builds. The client and server run as separate commands, so one build script must pass the same value to both. The scaffolds provide this coordination in
scripts/build.mjs. A unique CI deployment id is a good source. A commit SHA or release tag is enough only when every deployment carrying it has identical rendering inputs.
A hydratable render without an id fails with MissingBuildId. Runtime.hydrate also requires one. A static render with isHydratable: false needs none.
Only a build takes the id from the deployment. The development server compiles the fixed value development into its server and client transforms. Development runs one live source session rather than producing independently deployable artifacts, so there is no deployment identity to derive.
A view identity names a module path and function. It does not capture imported constants, configuration, or caller arguments.
View identity also ships in the client bundle. Adding a source hash would expose a digest of that source to every visitor. A reader could test candidates for a low-entropy server-only value by hashing each one, even when the client build removed the value itself. A deployment-supplied build id detects skew without hashing source files.
In development, enable the Vite host in vite.config.ts:
foldkit({ ssr: { serverEntry: '/src/entry.server.ts' } })Vite continues to serve the client entry, HMR, and assets. Requests that reach Foldkit become Web Request values and pass to renderPage. The returned Web Response provides the status, headers, and body.
A hot update does not exercise hydration. HMR preserves the Model but rebuilds the DOM under the root. That DOM came from code that predates the edit. Reload the page to test hydration itself. The stamped root remains required during a hot update; without it, startup fails as it would on a fresh load.
In production, build the client and server host separately. The host serves static assets first, imports the built entry, and sends Server.toResponse(template, await renderPage(request)). The SSR example uses an Effect HttpServer for this delivery layer.
Caching personalized responses
When Flags depend on the request, such as a cookie, authorization header, or locale, the rendered HTML belongs to that visitor. Set cache-control and vary so a shared cache cannot serve it to someone else. The SSR example uses private, no-store and vary: cookie because its initial count comes from a cookie.
An SSG host builds the browser bundle and server entry. A build script then calls renderPage once for every generated URL (scripts/prerender.ts in the SSG example):
for (const path of prerenderPaths) {
const request = new Request(`https://example.com${path}`)
const result = await serverEntry.renderPage(request)
if (result._tag === 'Responded') {
throw new Error(`Cannot write a Response for ${path} as static HTML`)
}
const html = Server.injectIntoTemplate(template, result.application)
await writeRoute(path, html)
}A static file is a body plus whatever headers the file host adds. It cannot carry a redirect, a 404, or per-response headers. Writing a Responded result to disk turns a redirect into an ordinary page at that URL. The build should fail on Responded and on any rendered status it cannot reproduce.
The SSG example is the minimal reference. This website is the production-scale reference. Its prerender host uses the same renderPage(Request) contract, seeds route content through universal Flags, and writes every route as hydratable static HTML.
A deployed SSG build is a directory of static files. Any static host or CDN can serve it as is. The hydration handoff already lives in the HTML.
A deployed SSR application needs a host with two jobs: serve the built client assets and call renderPage for page requests. On Node, use the SSR example's server as the reference. It serves static files first and sends Server.toResponse(template, await renderPage(request)) for everything else.
These rules apply to request-time SSR. An SSG deployment is a directory of files, so its static host owns method handling.
An SSR host serves static files for GET and HEAD. Other application methods reach the entry, including OPTIONS. Under vite dev, a configured proxy route may answer first.
Development and the production SSR host follow the same rule. A form action, Server.Responded reply, or CORS preflight should not work during development and fail after deployment.
OPTIONS reaches the entry because a preflight concerns one application resource. Only the application knows its policy. The SSR example and scaffold answer with 204 and an Allow header as a placeholder. Replace that response with a real CORS policy before deploying.
Preflight ownership follows Access-Control-Request-Method, not the URL suffix alone. For example: an OPTIONS request for POST /submit.json reaches the entry even though a GET for that path could name a static asset.
An OPTIONS request without both Origin and Access-Control-Request-Method is not a CORS preflight. It reaches the entry regardless of its path.
Vite still owns configured proxy routes, source modules, assets, and HMR. Its server.cors policy applies to those responses. Requests that fall through to Foldkit do not inherit that development-only policy. The entry's response headers therefore predict the deployed host.
The plugin validates the request target before Vite or Foldkit handles it. Proxies still have the opportunity to answer before the server entry. Application OPTIONS requests that fall through still reach the entry.
CONNECT, TRACE, and TRACK never reach the entry. The WHATWG Request constructor rejects them, so the host answers 405 with Allow.
On Node, only TRACE reaches that rule. The HTTP parser answers TRACK with 400 before a handler runs. CONNECT arrives on its own event rather than as an ordinary request.
A static build is one file for every visitor, so it caches like any other static asset.
Request-time rendering depends on its Flags. A route with universal Flags can use shared caching. A route whose Flags come from the request produces HTML for one visitor. A CDN or reverse proxy must not serve that response to the next visitor. Set the response headers in the server entry and confirm that every cache in front of it honors them.
Cloudflare Workers, Deno, and Bun already use Web Request and Response, so they can run the entry without an adapter:
import { Server } from 'foldkit/experimental'
import template from './dist/client/index.html'
import { renderPage } from './dist/server/entry.server'
export default {
fetch: async (request: Request): Promise<Response> =>
Server.toResponse(template, await renderPage(request)),
}The platform serves the built client assets, and the handler covers page requests. Configure the bundler to treat the template's .html import as a string. Cloudflare's Wrangler CLI calls this a Text module rule. The same built server entry runs unchanged on each runtime.
Alchemy can provision and deploy the host. It is TypeScript-native infrastructure as code built on Effect. The Worker and its databases, object storage, or queues live in the same TypeScript program as the entry. Its Cloudflare support deploys the Worker directly.
SSG and SSR are delivery policies, not separate Foldkit application types. A hybrid deployment can generate stable routes during the build and send the remaining URLs to a request-time host. Both hosts import the same server entry, and every page hydrates through the same client entry.
For example: documentation and marketing pages can be generated at build time, while account pages and preview URLs render per request. Give each route one authoritative policy. Otherwise, one request may receive a generated page from the CDN and the next may receive a fresh page from the runtime host.
Two things happen. Startup stops, and the page is put out of reach.
Every refusal stops before init runs. No Command, Subscription, or ManagedResource from this boot starts.
For a build-id mismatch, Foldkit compares ids before accessing the Flags payload text, parsing its JSON, or Schema-decoding it. Stale Flags belong to the old deployment. Decoding them first would pass those values to current code before Foldkit noticed the mismatch. Flags-related refusals inspect the payload only far enough to identify the reported error.
Every refusal reports a [foldkit] error that names the cause. Failures found while makeApplication resolves the container and stamped root throw immediately. Failures found after Runtime.hydrate starts use Effect's error reporting. Both reach the console and error monitoring. Neither provides an application hook because startup never reaches a Model.
Build skew is one reason to refuse. The same policy also covers:
A Flags payload that is missing, duplicated, malformed, or rejected by the Schema.
A runtime id claimed by two roots.
More than one stamped root when no container identifies the one to adopt.
A served root that lost its stamp. A generated client reaches this state when template insertion already replaced its
#rootplaceholder, leaving neither the stamp nor the placeholder.
One missing-container case is different. If makeApplication cannot find its container and the document contains no data-foldkit-app, data-foldkit-build, or data-foldkit-flags, then no server rendered the page. The application's <div id="root"> is simply absent, usually because of a typo or because the script ran too early. Foldkit reports the setup error and leaves the page alone.
Every other refusal contains the page. This includes calling Runtime.hydrate with an existing container that has no stamped root, even on a page that was never server-rendered. Calling hydrate is the explicit claim that a handoff exists. Use Runtime.run for a fresh client boot.
Foldkit marks the document body with inert, aria-hidden, and data-foldkit-refused. It opens a nondismissable modal shield beside the body and above existing top-layer content, including dialogs in closed shadow roots. The shield takes focus so physical keyboard input cannot reach stale body handlers.
Author-owned dialogs remain open behind the shield. Containment does not call close() or dispatch cancel, either of which could run a stale listener while startup is failing.
Links, forms, and focusable controls stop responding. The shield asks the visitor to reload. The served DOM remains connected, and data-foldkit-refused is available for styling or monitoring. Nothing else in Foldkit sets that attribute.
Nothing moves. Foldkit marks the existing body instead of wrapping the application root. Wrapping would reparent the subtree, call disconnectedCallback and then connectedCallback on every upgraded custom element, and reload every iframe. Marking the body avoids those lifecycle effects.
The body is the containment boundary because every hydratable root sits inside it. renderToString refuses html, head, and body roots, and hydrate is reserved for an application that owns the page.
Containment starts only after the client detects a refusal. It cannot undo earlier activity:
The parser may already have fetched subresources or run scripts from the old deployment.
A custom element may already have run
connectedCallback.A visitor may have interacted with the page before the client entry ran. A script can still submit a form programmatically despite
inert.Containment is not a script or global-event sandbox. Capture listeners on
windowordocumentrun before an event reaches the shield. The browser may also dispatch global or top-layer events.A timer or stale listener can open a new dialog after containment. That dialog enters the top layer above the shield. The shield covers top-layer content that existed when refusal began without invoking its lifecycle.
The build id acts only when the HTML and client bundle come from different deployments. A page cached whole usually references its original content-hashed bundle. Old HTML then loads old JavaScript, the ids match, and Foldkit does not refuse it.
If the old assets have been deleted, the client script returns 404 and nothing boots. That is not a refusal and produces no [foldkit] error because Foldkit never runs.
A mismatch requires stale HTML whose script resolves to current code. Shared caches, partially invalidated CDN nodes, and service workers that retain an application shell can create that pair. A running tab is not rechecked when a deployment lands.
Keep stale HTML out of shared caches. Serve the page and its client bundle from the same deployment.
A refresh usually fixes a refusal by fetching HTML from the current deployment.
A refresh cannot help while a CDN node or cache-first service worker keeps returning the old page. Recovery then depends on that cache updating. Foldkit cannot control the service worker lifecycle.
Foldkit does not reload automatically and exposes no refusal hook. The runtime does not exist yet, so crash.report never runs. Container-resolution failures throw immediately; later hydration failures use Effect's error reporting.
Automatic reload would also be unsafe. If stale HTML remains in the cache, each reload receives the same dead page and starts another loop.
Server rendering has no browser and runs only the first view over the initial Model.
Commands do not run during a server render. Data loaded by a Command therefore appears as the Model's pre-Command state, usually a loading state. Supply the data through Flags when it must appear in the server HTML.
Components that measure the DOM before deciding what to render, such as
Ui.VirtualList, render their initial unmeasured state and fill in after hydration.makeElementandembedapplications do not hydrate. Server rendering supports page-owningmakeApplicationprograms.Ordinary element children under
templatecannot be server-rendered because browsers place them in a separate content fragment that the differ does not walk. Element children undernoscriptbecome raw text while scripting is enabled and cannot hydrate as the declared nodes. Keep template markup in the HTML shell. Use plain text, trustedh.InnerHTML, or shell markup for a noscript fallback.Dynamic HTML tag names are normalized to lowercase, matching the elements
document.createElementproduces. SVG and MathML tag names are case-sensitive and must use their canonical spelling.renderToStringrefuses a foreign-content spelling that the HTML parser would adjust becausecreateElementNSwould preserve the original name on a fresh client render.
Server HTML and client DOM must give each attribute, property, and content slot one owner.
h.Styleowns individual CSS declarations rather than the wholestyleattribute. It accepts known camel-case or declaration names, plus custom properties beginning--, with one string value per declaration. It rejectscssText, Snabbdom lifecycle keys, duplicate names for one declaration, non-string values,!important, and syntax that can escape into another declaration. Server and client renders agree on effective CSS, though not necessarily on the exact attribute bytes or mutation history. Hydration avoids rewriting unchanged declarations. When a strict CSP blocks the parsed style attribute, the client reapplies declared properties through CSSOM.Text entered into a controlled input before hydration yields to the Model when Foldkit reasserts controlled values. Controlled
value,checked,selected, andmutedstate owns the corresponding live and default DOM state. Hydration, a fresh render, andform.reset()therefore agree. Removing the typed property clears that ownership or restores a remaining raw attribute. Ownership changes are observable DOM writes, so a MutationObserver may report them. Element identity, focus, and page scroll survive.A controlled
h.Valuecannot share atextareaoroutputwith declared children because both own the element's content. Keep either the controlled value or the children. This rule also applies to client-only rendering.A raw
h.Attributeand a typed builder cannot name the same attribute on one element.h.Stylelikewise cannot share an element with a rawstyleattribute. Keep one owner for each piece of state.A typed reflected builder is client-only when the HTML element's native interface does not own that property. For example: spreading
h.Type('button')onto adivcreates an expando, so server rendering omits it instead of creating an attribute that a fresh client render would not. Use the matching element when the value must appear in markup, or use an intentional rawh.Attribute.
Custom Elements may upgrade before hydration. These rules divide state between the component and the view.
Attributes added by a Custom Element's
connectedCallbacksurvive when the view does not declare them. Component-added class tokens and style properties also survive when the view usesh.Classandh.Style. A rawh.Attribute('class', ...)orh.Attribute('style', ...)owns the whole attribute and replaces component additions.Component-built light DOM survives when the view declares no content. Foldkit adopts that childless host. When the view declares text, children, or
h.InnerHTML, Foldkit replaces the host and builds the declared content while the new element is detached. The old host disconnects, and the new host has a new DOM identity. It connects once with the view content in place, as it does during a fresh render. This boundary is necessary because a browser may connect the old component before parsing its server content. Hydration cannot distinguish that content from nodes the component inserted and retained, and clearing the old host could let a child'sdisconnectedCallbackmutate it during reconciliation.Keep lifecycle DOM writes within the Custom Element's own host or shadow root. Hydration resamples view-owned element and text state after lifecycle callbacks. It does not sandbox callback code or rescan structure that a component changes elsewhere.
Declared custom-element properties are client behavior, not markup. They apply after hydration and never serialize as attributes. A component property named
idortitlestays client-side, whileh.Idandh.Titlestill serialize the reflected attributes shared by all elements. Native elements continue to reflect their standard properties. For example: a server-rendered<select>expresses its value through the selected<option>before the Model settles it after hydration. A declared property namedinnerHTMLremains a raw HTML sink when assigned on the client. Pass it only trusted markup and do not declare children beside it.
h.InnerHTML and raw attributes cross the typed builder boundary. Treat their values as trusted input.
Server rendering rejects every
<script>insideh.InnerHTMLbecause parsed scripts and scripts created by assigninginnerHTMLfollow different execution and processing rules. The refusal includes inert data blocks such as JSON-LD. Build scripts as ordinary view elements or place them in the HTML template. Declarative shadow-root templates are rejected for the same parse-equivalence reason, including inside ordinary template content.Raw-text elements such as
script,style,xmp,noembed, andnoframescannot contain their literal closing-tag sequence. Trustedh.InnerHTMLinsidetextareaortitlehas the same restriction. Script content also cannot contain<!--, which changes tokenizer state and can prevent the closing tag from ending the element.renderToStringrejects content that would escape the element. It also rejects NUL and unpaired surrogate values because they do not survive HTML parsing and UTF-8 encoding unchanged. A carriage return is escaped as in ordinary text and attributes. It is rejected in raw text and comments, where no escape can protect it from input preprocessing.An
h.InnerHTMLfragment cannot reach outside the application root. An<html>,<head>,<body>, or<frameset>tag inside one is not rendered where it is written. The browser merges its attributes into the document and hoists its content, sorenderToStringrejects it.A live HTML
<base>cannot appear in rendered application markup, including throughh.InnerHTMLor a scripting-disabled<noscript>. The browser applies it before hydration, so it could redirect the relative client entry that follows the root. Put<base>in the template head under host control. An ordinary inert template may contain one.Typed
h.Href,h.Src,h.Action, andh.Formactionneutralize script URL schemes. A rawh.Attributeis an intentional escape hatch and does not sanitize URLs. SVG and MathML use raw attributes for URL-bearing state because their typed HTML properties are not parse-equivalent. Pass only trusted values there.