Phase 57 — Static-prerender pass for Elmish SPAs (`ClientConfig.PrerenderRoutes`)
Phase 57 — Static-prerender pass for Elmish SPAs (ClientConfig.PrerenderRoutes)
What changes. SDK consumers can declare a list of PrerenderRoute entries on ClientConfig; the build-time FAKE Prerender target emits a static dist/{slug}.html per route with the route's <head> metadata (title / description / OG tags / optional JSON-LD) populated and the <meta name="toolup-prerendered" content="true"> marker injected. At runtime the client bootstrap detects the marker and mounts React via hydrateRoot instead of createRoot, preserving the prerendered first paint. A new server middleware maps incoming GET /some/route to the corresponding dist/some-route.html when one exists.
Net effect: the home page + declared SEO landing pages ship indexable HTML for crawlers (search engines, social-share scrapers, AdSense's contextual targeting) without surrendering the SPA shape for the rest of the app. Stock SPA deployments (PrerenderRoutes = []) are byte-for-byte unchanged.
Why this exists
ToolUp.Platform.Public (Phase 38) covers content-site shapes — markdown-driven marketing sites rendered server-side via Giraffe.ViewEngine, full SSR per request. It does NOT cover SPA prerendering — running the existing Elmish view tree against declared routes at build time to emit indexable HTML for an SPA-shaped app.
Two different surfaces, both warranted:
- Phase 38 = SSR-only marketing site, no SPA hydration.
- Phase 57 = SPA + indexable home + landing pages, SPA hydrates on top.
Ads-monetised public-utility deployments need the Phase 57 shape: the home page + a handful of SEO landing pages must be indexable HTML, the rest of the app stays SPA. "SEO matters more for ad revenue than the ads themselves" — load-bearing for any public-utility deployment.
Diff to apply
1. Declare prerender routes on ClientConfig
// src/Client/Client.fs (or wherever the consumer builds ClientConfig)
open ToolUp.Platform
let routes : PrerenderRoute list = [
{
Path = "/"
InitStateKey = None
Meta = {
Title = "Acme Public Calculator — free online tool"
Description = "Free calculator for X. No signup required."
OpenGraph =
Map.ofList [
"title", "Acme Public Calculator"
"description", "Free calculator for X."
"type", "website"
"image", "https://acme.example/og/home.png"
]
JsonLd = None
}
}
{
Path = "/about"
InitStateKey = None
Meta = PrerenderMeta.basic "About — Acme" "What Acme does, who it serves, how it works."
}
// ... up to ~10 routes for v1; more is supported but the SEO win
// drops off fast once the long tail of pages requires the SPA
// anyway.
]
let config = {
ClientConfig.defaults with
Modes = ...
AppName = "Acme Calculator"
PrerenderRoutes = routes
}
2. Swap the client bootstrap to the hydration-aware one
// src/Client/Program.fs (or wherever Client.run is called today)
// Before:
ToolUp.Platform.Client.run config modules
// After:
ToolUp.Platform.Bootstrap.Hydration.run config modules
Bootstrap.Hydration.run is a drop-in replacement: when the prerender marker is absent (the byte-for-byte common case for any route NOT declared as a prerender target) it delegates to Client.run directly. When the marker is present, it mounts React via hydrateRoot against the existing DOM.
If you also want per-route metadata to update on in-SPA navigation between prerendered routes (recommended for AdSense contextual targeting):
// After `Bootstrap.Hydration.run config modules`:
ToolUp.Platform.Bootstrap.MetadataHook.install config.PrerenderRoutes
MetadataHook.install is idempotent and a no-op when PrerenderRoutes = [], so calling it unconditionally is safe.
3. Wire the server middleware
// src/Server/Server.fs
// Before — only UseStaticFiles serves dist/*.html, requested by their
// full filename:
app.UseStaticFiles(...)
// After — PrerenderedRoutesMiddleware sits ahead of UseStaticFiles so
// GET /some/route resolves to dist/some-route.html when one exists,
// short-circuiting before the SPA index.html fallback:
app.UseMiddleware<PrerenderedRoutesMiddleware>(config) |> ignore
app.UseStaticFiles(...)
Behaviour with no prerendered files present (the byte-for-byte SPA-only case): the middleware looks up the file, finds nothing, calls next.Invoke(ctx), and the request falls through to the existing SPA-index fallback unchanged.
4. Wire the FAKE Prerender target into Build.fs
// Build.fs
open ToolUp.Platform.Build
open ToolUp.Platform.Prerender
let routes : PrerenderRoute list = [ ... ] // your declared routes
[<EntryPoint>]
let main args =
init args
registerTargets buildConfig
// Phase 57 follow-up — registers the `Prerender` FAKE target.
// Defaults match the SDK's vite.config.mts conventions
// (Fable output at `src/Client/output/Client.js`, dist at
// `src/Client/dist`, script served at `/output/Client.js`).
// Override via `{ PrerenderTargetOptions.defaults with ... }`
// for non-standard layouts.
registerTarget buildConfig PrerenderTargetOptions.defaults routes
execute args
// src/Client/Client.fs — register the prerender entry-point so
// the FAKE target's Node script can reach the renderer. No-op in
// the browser; safe to call unconditionally.
open ToolUp.Platform.Bootstrap
PrerenderExport.installEntryPoint config modules
if PrerenderExport.isBrowser () then
MetadataHook.install config.PrerenderRoutes
Hydration.run config modules
Then dotnet run -- Prerender runs the chain Build → Fable → Prerender, writing one dist/{slug}.html per declared route.
Status (as of the FAKE-target follow-up ship 2026-05-27): the ClientConfig.PrerenderRoutes field, hydration bootstrap, metadata hook, server middleware, and the FAKE Prerender execution pass (ToolUp.Platform.Prerender.registerTarget factory + Client/Bootstrap/PrerenderExport.fs entry-point + self-contained Node script + samples/PrerenderApp/ worked example + Tests/InProcess/PrerenderDeterminismTests.fs byte-stability + hydration-marker contract tests) all ship in the SDK.
Alternative: Vite plugin path
For consumers preferring the JS toolchain seam over the FAKE seam, a Vite plugin can run prerendering as a post-build hook. The plugin reads the consumer's compiled Fable output, imports the SDK's prerender entry-point, invokes the renderer per route, and emits dist/{slug}.html.
Both paths produce equivalent output (same HTML structure, same marker, same metadata). Consumers pick by convention — dotnet-shop deployments tend to prefer the FAKE seam; consumers with deep vite.config.mts customisation may prefer the JS seam.
Verification
After applying the diff:
Build:
dotnet buildshould succeed without warnings. The newClientConfig.PrerenderRoutesfield has a default of[], so existing consumers that don't reference it stay compatible.SPA-only behaviour preserved: consumers who do NOT declare any
PrerenderRoutes(or who declare[]) should see byte-for-byte identical runtime behaviour — the hydration bootstrap finds no marker, callsClient.rundirectly, and the server middleware looks up no files and falls through toUseStaticFiles.Prerendered route hydrates without warnings: declare one route, run the prerender target, load the resulting page in a browser. Open DevTools Console; React should not log a hydration-mismatch warning. The DevTools Network panel should show the prerendered HTML as the first response.
Metadata updates on SPA navigation: with two prerendered routes declared and
MetadataHook.installwired, navigate between them via the SPA. The<title>and<meta>tags should swap. Theog:tags should update for AdSense / social-share scrapers.Googlebot fetch:
curl -A "Googlebot/2.1" https://your-deploy/. The response body should contain the prerendered metadata (title, description, OG tags) — not the empty SPA shell.
Rollback
The phase is opt-in throughout. To roll back:
- Set
ClientConfig.PrerenderRoutes = [](or remove the field entirely; the default is[]). - Swap
Bootstrap.Hydration.runback toClient.run. - Remove
MetadataHook.installfrom the bootstrap. - Drop
app.UseMiddleware<PrerenderedRoutesMiddleware>from the server pipeline. - Delete any
dist/*.htmlfiles emitted by the FAKE target (other than the SPAindex.html).
The runtime returns to byte-for-byte pure-SPA behaviour.
Out of scope (not delivered by this phase)
- True request-time SSR (server renders fresh HTML per request, e.g. for deep-link routes like
/calculator/2009/50000). Phase 38 covers content-site SSR; dynamic-SPA SSR is a larger future surface. Static prerender covers the SEO 80%. - Per-route
robots.txtconfiguration — consumers handle this via their static-asset host's standard mechanism. - Internationalised prerender output (per-locale variants of each route). Composes with Phase 12a (i18n infrastructure) when both ship; out of v1 scope.
- AMP / accelerated mobile pages — not a 2026 SEO priority.