Skip to content

Deployment

Compile shared components twice: .NET renders the page; Fable builds the browser code that hydrates it. This guide connects the counter, a shared codec and an ASP.NET Core endpoint.

Use Page declarations to generate the document and browser entry from one declaration. Use the calls below when the application manages those pieces itself.

Projects

Place this example under examples/CounterApp in a Ranvier checkout so the project references below resolve. For a standalone application, pack the view projects into a local feed as described in Installation, then replace the view project references with local package references.

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>
    <ProjectReference Include="../../../src/Ranvier.View/Ranvier.View.fsproj" />
    <PackageReference Include="Ranvier.Wire" Version="0.1.1-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" />
    <ProjectReference Include="../../../src/Ranvier.View.Dom/Ranvier.View.Dom.fsproj" />
    <PackageReference Update="FSharp.Core" Version="10.1.401" />
  </ItemGroup>
</Project>

Public DOM types come from Ranvier.Dom; Fable consumers reference Fable.Ranvier.Dom with the same assembly identity. The component and renderer packages require no Xantham browser bindings. See Installation.

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