Skip to content

Deployment

A server-rendered app compiles one set of component files twice: on .NET for the server, and with Fable for the browser. This page assembles the counter from Getting started into three projects, an ASP.NET Core server that renders the page and serves the client bundle, and a Fable client that hydrates it.

For generated HTML and browser startup from a shared F# declaration, see Page declarations. The example below shows the lower-level rendering and hydration calls.

Projects

Shared/   Shared.fsproj   Counter.fs, Shared.fs
Server/   Server.fsproj   ServerPage.fs, Program.fs, wwwroot/main.js (built by the client)
Client/   Client.fsproj   Main.fs, main.js, vite.config.js, package.json
ProjectCompiled byReferencesHolds
Shared.NET and FableRanvier.View, Ranvier.WireCounter.fs from Getting started and Shared.fs, the codec from State transfer.
Server.NET, Microsoft.NET.Sdk.WebSharedThe page function and the endpoint.
ClientFable, then ViteShared, Ranvier.View.DomThe hydration entry point.

The Ranvier.View package holds Server and every template type; Ranvier.View.Dom holds Mounting and Hydration, which run under Fable. Shared.fs is the state-transfer sample under module Shared with open Counter, and exports counterProps and stateId.

<!-- Shared/Shared.fsproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <Compile Include="Counter.fs" />
    <Compile Include="Shared.fs" />
  </ItemGroup>
  <ItemGroup>
    <PackageReference Include="Ranvier.View" Version="0.1.0-alpha.1" />
    <PackageReference Include="Ranvier.Wire" Version="0.1.0-preview.1" />
    <PackageReference Update="FSharp.Core" Version="10.1.401" />
  </ItemGroup>
</Project>
<!-- Server/Server.fsproj -->
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <Compile Include="ServerPage.fs" />
    <Compile Include="Program.fs" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="../Shared/Shared.fsproj" />
    <PackageReference Update="FSharp.Core" Version="10.1.401" />
  </ItemGroup>
</Project>
<!-- Client/Client.fsproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <Compile Include="Main.fs" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="../Shared/Shared.fsproj" />
    <PackageReference Include="Ranvier.View.Dom" Version="0.1.0-alpha.1" />
    <PackageReference Update="FSharp.Core" Version="10.1.401" />
  </ItemGroup>
</Project>

The view packages are on nuget.org; the app's nuget.config adds the temporary Xantham Cloudsmith feed for their Xantham.Fable.Core.TS dependency, as Installation shows.

The page

module ServerPage

open Ranvier.View
open Counter
open Shared

let options = { Server.Options.defaults with Policy = Omit }

let page (props: CounterProps) : string =
    let document =
        { Server.DocumentShell.defaults with
            Lang = Some "en"
            State = [ stateId, counterProps.Encode props ]
            Scripts = [ Server.ModuleScript.Src "/main.js" ] }

    Server.renderDocument options document counter props

Server.renderDocument options document definition props renders the component as renderPage does and writes the whole document around it:

  • the component's head entries inside <head>, after <meta charset="utf-8">;
  • the host element, <div id="app">, holding only the body, so hydration's root marker is its first child;
  • each state element after the host, outside it;
  • each script after the state elements, so the client bundle loads after the body.
Server.DocumentShell fieldDefaultWrites
LangNoneThe lang attribute of <html>.
HostId"app"The id of the host element.
State[]One stateScript element per id and JSON pair.
Stylesheets[]One <link rel="stylesheet"> per URL, after the head entries.
Scripts[]Server.ModuleScript.Src src writes <script type="module" src>; Server.ModuleScript.Inline text writes the text inside <script type="module">.
NonceNoneThe nonce attribute of every script, state and stylesheet element, for a Content-Security-Policy of script-src or style-src 'nonce-...'.
HtmlAttributes, BodyAttributes[]Further attributes of <html> and <body>. An id among them joins the document's ids.

Every attribute value is escaped. Script and stylesheet URLs are trusted author URLs, written as given apart from escaping: pass only URLs the application controls. Some null for Lang or Nonce writes nothing, as None does.

renderDocument raises ArgumentException before the render for:

  • an id that is null, empty or contains ASCII whitespace (space, tab, line feed, form feed, carriage return);
  • an id written twice among the host, the state elements and the id attributes of <html> and <body>;
  • a state element whose JSON is null;
  • an attribute name that a template rejects, or one that an element repeats;
  • a nonce that is empty or holds a character other than ASCII letters, digits and + / = - _;
  • inline module text containing </script in any letter case, or <!--.

A failed render raises as renderPage does. Server.renderDocumentTo options document definition props write passes the same document to write in segments, after a successful render.

The endpoint

Server/Program.fs serves the page at / and the bundle from wwwroot:

module Program

open System
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Http
open ServerPage

[<EntryPoint>]
let main args =
    let app = WebApplication.CreateBuilder(args).Build ()

    app.UseStaticFiles () |> ignore

    app.MapGet ("/", Func<IResult> (fun () -> Results.Content (page { Start = 0; Label = "Clicks" }, "text/html")))
    |> ignore

    app.Run ()
    0

Each Server.render, renderPage and renderDocument call creates and disposes its own graph, and concurrent requests each keep their own reactive state. State shared between requests (a cache, a configuration signal) is read as it is; see Rendering.

The client

Client/Main.fs is the hydration entry point under module Main, with open Counter and open Shared. main.js calls it:

// Client/main.js
import { start } from "./generated/Main.js";

start();

Vite bundles main.js into the server's wwwroot under a fixed name:

// Client/vite.config.js
export default {
  build: {
    outDir: "../Server/wwwroot",
    emptyOutDir: true,
    rollupOptions: {
      input: "main.js",
      output: { entryFileNames: "main.js" },
    },
  },
};

Build and run

cd Client
npm install --save-dev vite
dotnet fable Client.fsproj --outDir generated
npx vite build
cd ../Server
dotnet run

GET / returns the server-rendered counter with its props:

<!doctype html><html lang="en"><head><meta charset="utf-8"></head><body><div id="app"><!--[r--><div class="counter"><span class="label">Clicks</span><button type="button">0</button></div><!--]--></div><script type="application/json" id="counter-props">{"start":0,"label":"Clicks"}</script><script type="module" src="/main.js"></script></body></html>

/main.js hydrates the counter: the button the server wrote stays in the document and counts clicks.

Choosing a shape

ShapeUse it when
Server render and hydrateThe first paint should come from the server, and the page then runs in the browser.
Client mount onlyThe page needs no server HTML: mount with Mounting.mount into an empty host.
Server render onlyThe page is static: render with Server.renderDocument and no Scripts.
Blazor ServerThe UI runs on the server over a circuit, with Ranvier holding the circuit's state.
Edit this page