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
| Project | Compiled by | References | Holds |
|---|---|---|---|
Shared | .NET and Fable | Ranvier.View, Ranvier.Wire | Counter.fs from Getting started and Shared.fs, the codec from State transfer. |
Server | .NET, Microsoft.NET.Sdk.Web | Shared | The page function and the endpoint. |
Client | Fable, then Vite | Shared, Ranvier.View.Dom | The 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 field | Default | Writes |
|---|---|---|
Lang | None | The 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">. |
Nonce | None | The 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
idattributes 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
</scriptin 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
| Shape | Use it when |
|---|---|
| Server render and hydrate | The first paint should come from the server, and the page then runs in the browser. |
| Client mount only | The page needs no server HTML: mount with Mounting.mount into an empty host. |
| Server render only | The page is static: render with Server.renderDocument and no Scripts. |
| Blazor Server | The UI runs on the server over a circuit, with Ranvier holding the circuit's state. |