Skip to main content

JS Frameworks

Electron Forge to Feliz

Electron Forge

Electron Forge is an all-in-one tool for packaging and distributing Electron applications. It combines many single-purpose packages to create a full build pipeline that works out of the box, complete with code signing, installers, and artifact publishing.

The follow along was done using "create-electron-app": "^7.10.2",

In this guide we will walk through the steps needed to migrate a Electron Forge template created with create-electron-app to use Fable and Feliz. It should point towards common patterns and practices using F# in existing JavaScript frameworks.

Initialize Template

npx create-electron-app@7.10.2 my-app --template=vite

This scaffolds a new Electron Forge app in the my-app folder. We use the vite template as it is easy to setup and we do not really need vite-typescript, as we will use the F# compiler for type safety.

We start with a minimal Electron app with the following structure:

.
├── src
│ ├── main.js # nodejs main process
│ ├── preload.js # bridge between main and renderer
│ ├── renderer.js # website renderer process
│ └── index.css
├── forge.config.ts # electron forge config
├── index.html
├── package.json
├── vite.main.config.mjs
├── vite.preload.config.mjs
├── vite.renderer.config.mjs
└── ...

We directly can see that the separation of "main", "preload" and "renderer" processes is done via separate entry points and vite configs. In addtion we will have to update the entrypoint reference in index.html.

forge.config.js
//..
config: {
// `build` can specify multiple entry builds, which can be Main process, Preload scripts, Worker process, etc.
// If you are familiar with Vite configuration, it will look really familiar.
build: [
{
// `entry` is just an alias for `build.lib.entry` in the corresponding file of `config`.
entry: 'src/main.js',
config: 'vite.main.config.mjs',
target: 'main',
},
{
entry: 'src/preload.js',
config: 'vite.preload.config.mjs',
target: 'preload',
},
],
renderer: [
{
name: 'main_window',
config: 'vite.renderer.config.mjs',
},
],
},
//..
index.html
    <script type="module" src="/src/renderer.js"></script>

To mirror this separation in F# we will create three separate projects: Main, Preload and Renderer. And for good measure a fourth project for shared code: Shared. Because Fable allows only for a single entrypoint we will create a 5th project App that will reference the three process projects and serve as our Fable entrypoint.

Init dotnet projects

Renderer project

dotnet new console --language f# -f net8.0 -o src/Renderer -n Renderer

Main project

dotnet new console --language f# -f net8.0 -o src/Main -n Main

Preload project

dotnet new console --language f# -f net8.0 -o src/Preload -n Preload

Shared project

dotnet new classlib --language f# -f net8.0 -o src/Shared -n Shared

App project

dotnet new console --language f# -f net8.0 -o src -n App

Create project references

We need to tell the F# compiler which projects depend on which other projects. We do this by adding project references.

We can either edit the .fsproj files manually or use the dotnet add <target_project_path> reference <path_to_other_project> command.

Add the following references to src/App.fsproj:

  • dotnet add src/App.fsproj reference src/Renderer/Renderer.fsproj
  • dotnet add src/App.fsproj reference src/Preload/Preload.fsproj
  • dotnet add src/App.fsproj reference src/Main/Main.fsproj

Add the reference to src/Shared/Shared.fsproj:

  • dotnet add src/Main/Main.fsproj reference src/Shared/Shared.fsproj
  • dotnet add src/Preload/Preload.fsproj reference src/Shared/Shared.fsproj
  • dotnet add src/Renderer/Renderer.fsproj reference src/Shared/Shared.fsproj

Init Central Package Management (CPM)

Because we already have multiple projects it is a good idea to use Central Package Management (CPM) to manage package versions in a single place.

dotnet new packagesprops

Adding Depdendencies

Add the following packages to Directory.Packages.props:

  <ItemGroup>
<PackageVersion Include="Fable.Core" Version="4.5.0" />
<PackageVersion Include="Feliz" Version="3.0.0-rc.2" />
<PackageVersion Include="FSharp.Core" Version="9.0.303" />
</ItemGroup>

In Shared.fsproj we will add references for Fable.Core and FSharp.Core. As this project is shared between all processes, its dependencies will be transitively available.

    <ItemGroup>
<PackageReference Include="Fable.Core" />
<PackageReference Include="FSharp.Core" />
</ItemGroup>

And in the Renderer.fsproj add a reference to Feliz.

    <ItemGroup>
<PackageReference Include="Feliz" />
</ItemGroup>

Verify that all projects build correctly by running:

dotnet build src/App.fsproj

Init Fable

First we need a .NET tools manifest to install Fable as a local tool.

dotnet new tool-manifest

Now we can install fable as a local tool.

dotnet tool install fable --prerelease
info

At the time of writing this Fable v5 is in prerelease and Feliz v3 requires Fable v5. Hence the --prerelease flag.

Verify correct installation by running:

dotnet fable --version # 5.0.0-alpha.14

Nice! 🎉

Setup Fable script

In ./package.json we create the fable script, and update the start script to use it. We will transpile all our code into src/fableoutput folder. This will make it easy to clean up fable output if needed.

"scripts": {
"fable": "dotnet fable src/App.fsproj --outDir src/fableoutput",
"start": "npm run fable -- --watch --run electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make",
"publish": "electron-forge publish",
"lint": "echo \"No linting configured\""
},

And update the .gitignore to ignore the fable output folder as well as some common f# files we do not want to track.


# f# + fable
bin/
obj/
fableoutput/

Migrate code

Preload

In preload.js we have no code to migrate, so we can create a small print statement and keep it like this.

src/Preload/Program.fs
namespace Preload

// See the Electron documentation for details on how to use preload scripts:
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
module Main =
printfn "Hello from Preload!"

Main

In main.js we have quite some bindings to write. If you are interested they are included in the <details> below.

main.js - bindings
open Fable.Core

module Node =

type NodeJSProcess =
abstract member platform: string

[<Erase>]
type IPath =
abstract member join: [<ParamSeqAttribute>] paths: string[] -> string

type node =
[<ImportDefaultAttribute("node:path")>]
static member path: IPath = jsNative

[<Erase; AutoOpen>]
module Globals =

[<GlobalAttribute>]
let ``process``: NodeJSProcess = jsNative

[<GlobalAttribute>]
let __dirname: string = jsNative

module Electron =

[<StringEnum(CaseRules.KebabCase)>]
type AppEvents =
| Ready
| WindowAllClosed
| Activate

[<Erase>]
type IApp =
abstract member quit: unit -> unit

[<Emit("$0.on($1, $2)")>]
abstract member on: event: AppEvents -> (unit -> unit) -> unit

type IWebContents =
abstract member openDevTools: unit -> unit

[<Erase>]
type IBrowserWindow =
[<Emit("new $0($1)")>]
abstract member create: obj -> IBrowserWindow

abstract member loadURL: string -> unit

abstract member loadFile: string -> unit

abstract member webContents: IWebContents

abstract member getAllWindows: unit -> IBrowserWindow[]

open Fable.Core

module ElectronSquirrelStartup =

[<ImportDefault("electron-squirrel-startup")>]
let started: bool = jsNative

[<Import("app", "electron")>]
let app: IApp = jsNative

[<Import("BrowserWindow", "electron")>]
let BrowserWindow: IBrowserWindow = jsNative

src/Main/Main.fs
module Main

// ... bindings here ...

[<Erase; AutoOpen>]
module Globals =

[<GlobalAttribute>]
let MAIN_WINDOW_VITE_DEV_SERVER_URL: string = jsNative

[<GlobalAttribute>]
let MAIN_WINDOW_VITE_NAME: string = jsNative

open Node
open Electron

// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if ElectronSquirrelStartup.started then
app.quit ()

open Fable.Core.JsInterop

let createWindow () =
// Create the browser window.
let mainWindow =
BrowserWindow.create (
{|
width = 800
height = 600
webPreferences = {|
preload = node.path.join [| __dirname; "preload.js" |]
|}
|}
)

// and load the index.html of the app.
if not (isNullOrUndefined MAIN_WINDOW_VITE_DEV_SERVER_URL) then
mainWindow.loadURL MAIN_WINDOW_VITE_DEV_SERVER_URL
else
mainWindow.loadFile (node.path.join [| __dirname; $"`../renderer/{MAIN_WINDOW_VITE_NAME}/index.html`" |])

// Open the DevTools.
mainWindow.webContents.openDevTools ()

// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on AppEvents.Ready createWindow

app.on
AppEvents.WindowAllClosed
(fun () ->
if ``process``.platform <> "darwin" then
app.quit ()
)

app.on
AppEvents.Activate
(fun () ->
if BrowserWindow.getAllWindows().Length = 0 then
createWindow ()
)

Renderer

As we want to show some Feliz component in the renderer process we will make some adjustments to the simple print statement in renderer.js.

src/Renderer/Program.fs
module Renderer

open Feliz

type private Components =

/// <summary>
/// A stateful React component that maintains a counter
/// </summary>
[<ReactComponent>]
static member Counter() =
let (count, setCount) = React.useState (0)

Html.div [
Html.div [
Html.h1 [ prop.testId "counter-display"; prop.text count ]
Html.div [
prop.text "Click the button to increment the counter:"
]
Html.button [
prop.testId "inc-btn"
prop.onClick (fun _ -> setCount (count + 1))
prop.text "Increment"
]
]
]

open Browser.Dom

let root = ReactDOM.createRoot (document.getElementById "feliz-app")
root.render (Components.Counter())

Update ./index.html to have a <div> element with the id feliz-app.

  <body>
<div id="feliz-app"></div>
<script type="module" src="/src/renderer.js"></script>
</body>
info

Q: But what about the file name /src/renderer.js?

A: Good thinking! We will update the file name later when we adjust all entry points!

And we need to install a npm dependency to let vite handle our React components correctly.

npm i -D @vitejs/plugin-react react react-dom
vite.renderer.config.mjs
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react'

// https://vitejs.dev/config
export default defineConfig({
plugins: [react()],
});

Shared + App

We do not need to share any code for now, so we adjust src/Shared/Program.fs to a minimal code statement.

src/Shared/Program.fs
module Shared

let placeholder = ()

And for App we can actually remove all code files. We delete src/Program.fs and remove the reference from src/App.fsproj.

src/App.fsproj
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="Renderer\Renderer.fsproj" />
<ProjectReference Include="Preload\Preload.fsproj" />
<ProjectReference Include="Main\Main.fsproj" />
</ItemGroup>

</Project>

Update entry points

Now that we actually update the F# files with real code, let us run npm run fable to check the transpiled code.

npm run fable

This will create the transpiled .js files in src/fableoutput folder. If we look inside we can see:

  • src/fableoutput/fable_modules/* contains transpiled f# dependencies
  • src/fableoutput/Main/Program.js
  • src/fableoutput/Preload/Program.js
  • src/fableoutput/Renderer/Program.js
  • src/fableoutput/Shared/Program.js

We start by updating ./index.html with

index.html
  <body>
<div id="feliz-app"></div>
<script type="module" src="/src/fableoutput/Renderer/Program.js"></script>
</body>

Next we update forge.config.ts to point to the new entry points.

forge.config.mjs
        build: [
{
// `entry` is just an alias for `build.lib.entry` in the corresponding file of `config`.
entry: 'src/fableoutput/Main/Program.js',
config: 'vite.main.config.mjs',
target: 'main',
},
{
entry: 'src/fableoutput/Preload/Program.js',
config: 'vite.preload.config.mjs',
target: 'preload',
},
],

We also find an entry point reference in ./package.json. But at this point we are not sure what it should be. So we leave it for now.

    "main": ".vite/build/main.js",

Delete JavaScript files

Now that we have updated all entry points to point to the transpiled Fable output we can delete the original JavaScript files to avoid confusion.

  • 🗑️ src/main.js
  • 🗑️ src/preload.js
  • 🗑️ src/renderer.js

Run the app!

To run the app we can use the start script we defined earlier.

npm run start

💣💥 As expected we get an error. The entrypoint referenced in ./package.json does not exist!

Cannot find module
\.vite\build\main.js. Please verify that the package.json has a valid "main" entry.

In ./.vite/build/, we can find a single Program.js file... 👀 wait a single file? With the same name as our F# files? We should go back and rename some files, to avoid future errors, we use the same names as they had as .js files.

  • src/Main/Program.fs -> src/Main/main.fs
  • src/Preload/Program.fs -> src/Preload/preload.fs
  • src/Renderer/Program.fs -> src/Renderer/renderer.fs
warning

Remember to update the project files (.fsproj) to include the renamed files!

This step heavily depends on your IDE/Editor

And update all entrypoints in ./forge.config.mjs and ./index.html accordingly.

🗑️ Finally delete the src/fableoutput folder to have a clean build and run npm run fable again.

Tada 🎉 Our Fable Electron app opens up! We check the developer tools and see a nice Hello from Preload! printed in the console!

React Router to Feliz

React Router

React Router is a multi-strategy router for React bridging the gap from React 18 to React 19. You can use it maximally as a React framework or as minimally as you want.

The follow along was done using "create-react-router": "^7.9.4",

In this guide we will walk through the steps needed to migrate a React Router app created with create-react-router to use Fable and Feliz. It should point towards common patterns and practices using F# in existing JavaScript frameworks.

Initialize Template

For this example we will use the "framework" mode for React Router, which can be initialized as a template using create-react-router.

npx create-react-router@7.9.4 .

This scaffolds a new React Router app in the current folder (the trailing . means "here").

We start with a minimal React Router app with the following structure:

.
├── app
│ ├── routes # folder for route modules
│ │ └── home.tsx # home route module
│ ├── welcome
│ │ └── welcome.tsx # welcome component
│ ├── root.tsx # root route module
│ ├── routes.tsx # route config
│ └── app.css
├── react-router.config.ts
├── package.json
├── tsconfig.json
└── ...

Install Fable

dotnet new tool-manifest
dotnet tool install fable

This creates a local tools manifest for the repo and installs the Fable CLI as a local tool (so everyone uses the same version).

The config file will be placed in .config/dotnet-tools.json.

Init dotnet project

  1. Create F# console project
dotnet new console -f net8.0 --language f# -o .\app\ -n App
  1. Create F# library project for React Router bindings
dotnet new classlib -f net8.0 --language f# -o .\lib\React.Router\ -n React.Router
  1. Add Fable packages
dotnet add .\lib\React.Router\React.Router.fsproj package Feliz 

dotnet add .\lib\React.Router\React.Router.fsproj package Fable.Core

Adds Feliz (the React DSL) and Fable.Core (interop/runtime) to the bindings library.

  1. Create .sln file and add both projects
dotnet new sln -n ReactRouterFable

dotnet sln add .\app\App.fsproj

dotnet sln add .\lib\React.Router\React.Router.fsproj

Optional but helpful for multi-project workflows and IDE tooling.

  1. Mirror existing .tsx files to .fs files in the app folder:

Make sure to create the following files and add them to app/App.fsproj

<ItemGroup>
<Compile Include="welcome/welcome.fs" />
<Compile Include="routes/home.fs" />
<Compile Include="routes.fs" />
<Compile Include="root.fs" />
</ItemGroup>
info

F# projects require .fs files to be added to the project file to be compiled. The order of files matters. Code can only be referenced from files after/below them in the list.

Add fable script

# package.json
"scripts": {
"fable": "dotnet fable ./app/App.fsproj --outDir ./app/fable_output -e .fs.jsx",
"build": "npm run fable -- --run react-router build",
"dev": "npm run fable -- --watch --run react-router dev",
"start": "react-router-serve ./build/server/index.js",
},
  • fable: compiles F# to app/fable_output. --outDir specifies a directory for the output files. I like using a specific output folder to easily clean up fable output if needed. -e sets the file extension for the transpiled files to .fs.jsx.
  • build/dev: runs the React Router CLI against the compiled output (one-off vs. watch mode).
  • start: serves the built SSR bundle.

Start replicating the js code in F#

Start by adding placeholder to all files so we get correct intellisense and no errors.

// example placeholder in app/routes/home.fs
module Home

let x = 0

Run dotnet build .\ReactRouterFable.sln to verify correct placeholders.

routes.fs

routes.tsx
import { type RouteConfig, index } from "@react-router/dev/routes";

export default [index("routes/home.fs.sx")] satisfies RouteConfig;

We investigate the output type and see that RouteConfig is an array of RouteConfigEntry. Which we will import and set as return type for index(path) function

// lib/React.Router/Library.fs
module React.Router

open Fable.Core
open Feliz

[<Erase; Mangle(false)>]
module DevRoutes =

[<Import("RouteConfigEntry", "@react-router/dev/routes")>]
type RouteConfigEntry =
interface end

[<Import("index", "@react-router/dev/routes")>]
let index (path: string) : RouteConfigEntry = jsNative
// app/routes.fs
module Routes

open Fable.Core
open Feliz
open React.Router.DevRoutes

Fable.Core.JsInterop.exportDefault [|
index("./routes/home.fs.jsx")
|]
transpiled routes.fs.jsx
import { index } from "@react-router/dev/routes";

export default [index("./routes/home.tsx")];

root.tsx

root.tsx
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "react-router";

import type { Route } from "./+types/root";
import "./app.css";

export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
},
];

export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}

export default function App() {
return <Outlet />;
}

export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!";
let details = "An unexpected error occurred.";
let stack: string | undefined;

if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
details =
error.status === 404
? "The requested page could not be found."
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}

return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}

We start by importing necessary components from react-router.

// lib/React.Router/Library.fs
module React.Router

open Fable.Core
open Feliz

[<ReactComponent("Meta", "react-router")>]
let Meta() = React.imported()

[<ReactComponent("Links", "react-router")>]
let Links() = React.imported()

[<ReactComponent("ScrollRestoration", "react-router")>]
let ScrollRestoration() = React.imported()

[<ReactComponent("Scripts", "react-router")>]
let Scripts() = React.imported()

[<ReactComponent("Outlet", "react-router")>]
let Outlet() = React.imported()

Next we look at the js objects used for links. We can see that all objects have the rel and href properties, and an optional crossOrigin property. We could just use anonymous records, but to keep things clear we will define a type for it. We will use two important Fable interopt attributes:

  • [<ParamObject>] (or [<ParamObjectAttribute>] — both spellings work in F#) lets us build a plain JS object (POJO) from a constructor with named/optional args.
  • [<Emit>] is used to tell fable to emit straight up js code. Where parameters can be referenced using $0, $1, etc.
  • Combining both into [<ParamObject; Emit("$0")>] tells Fable to construct a js POJO from the arguments and then emit just the constructed object instead of any class construct.

Using a class with ParamObjectAttribute allows us to create objects with named parameters and optional parameters.

[<AllowNullLiteral; Global>]
type LinkEntrycrossOrigin
[<ParamObject; Emit("$0")>]
(rel: string, href: string, ?crossOrigin: string) =
member val rel = rel with get, set
member val href = href with get, set
member val crossOrigin = crossOrigin with get, set

We translate the links function next. Because of the optional parameter we can easily create the objects needed.

let links = fun () ->
[|
LinkEntrycrossOrigin("preconnect", "https://fonts.googleapis.com")
LinkEntrycrossOrigin("preconnect", "https://fonts.gstatic.com", "anonymous")
LinkEntrycrossOrigin("stylesheet", "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap")
|]

If we check the output we see that our links function looks not exactly like the original: export function links() {.., but if we check the official docs we can find the example in the same syntax as we have it:

export function links() {
return [
{
rel: "icon",
href: "/favicon.png",
type: "image/png",
},
// ...
]
}

Then we try to replicate the react components Layout, App and ErrorBoundary. Pay attention to: [<ReactComponentAttribute(true)>] as we want to define the App component as default export.

[<ReactComponentAttribute>]
let Layout(children: ReactElement) =
Html.html [
prop.lang "en"
prop.children [
Html.head [
Html.meta [prop.charset "utf-8"]
Html.meta [prop.name "viewport"; prop.content "width=device-width, initial-scale=1"]
Meta()
Links()
]
Html.body [
children
ScrollRestoration()
Scripts()
]
]
]

[<ReactComponentAttribute(true)>]
let App() =
Outlet()

[<Emit("import.meta.env.DEV")>]
let metaEnvDev: bool = jsNative

[<ReactComponent>]
let ErrorBoundary(error: ErrorBoundaryProps) =

let mutable message = "Oops!"
let mutable details = "An unexpected error occurred."
let mutable stack: string option = None

if isRouteErrorResponse(error) then
let is404 = error.status = 404
message <- if is404 then "404" else "Error"
details <- if is404 then
"The requested page could not be found."
else
error.statusText |> Option.defaultValue details
stack <- error.stack
elif metaEnvDev then
details <- error.message
stack <- error.stack

Html.main [
prop.className "pt-16 p-4 container mx-auto"
prop.children [
Html.h1 message
Html.p details
match stack with
| Some stack ->
Html.pre [
prop.className "w-full p-4 overflow-x-auto"
prop.children [
Html.code stack
]
]
| None ->
Html.none
]
]

welcome/welcome.fs & routes/home.fs

Copying welcome.tsx to Feliz is straightforward, but busy work. Below you can find the code if you are interested.

welcome.fs
module Welcome

open Fable.Core
open Fable.Core.JsInterop
open Feliz

let logoDark: string = importDefault "./logo-dark.svg"
let logoLight: string = importDefault "./logo-light.svg"

let resources = [
{|
href = "https://reactrouter.com/docs"
text = "React Router Docs"
icon =
Svg.svg [
svg.xmlns "http://www.w3.org/2000/svg"
svg.width 24
svg.height 20
svg.viewBox (0,0,20,20)
svg.fill "none"
svg.className "stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
svg.children [
Svg.path [
svg.d "M9.99981 10.0751V9.99992M17.4688 17.4688C15.889 19.0485 11.2645 16.9853 7.13958 12.8604C3.01467 8.73546 0.951405 4.11091 2.53116 2.53116C4.11091 0.951405 8.73546 3.01467 12.8604 7.13958C16.9853 11.2645 19.0485 15.889 17.4688 17.4688ZM2.53132 17.4688C0.951566 15.8891 3.01483 11.2645 7.13974 7.13963C11.2647 3.01471 15.8892 0.951453 17.469 2.53121C19.0487 4.11096 16.9854 8.73551 12.8605 12.8604C8.73562 16.9853 4.11107 19.0486 2.53132 17.4688Z"
svg.strokeWidth 1.5
svg.strokeLineCap "round"
]
]
]
|}
{|
href = "https://rmx.as/discord"
text = "Join Discord"
icon =
Svg.svg [
svg.xmlns "http://www.w3.org/2000/svg"
svg.width 24
svg.height 20
svg.viewBox(0,0,24,20)
svg.fill "none"
svg.className "stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
svg.children [
Svg.path [
svg.d "M15.0686 1.25995L14.5477 1.17423L14.2913 1.63578C14.1754 1.84439 14.0545 2.08275 13.9422 2.31963C12.6461 2.16488 11.3406 2.16505 10.0445 2.32014C9.92822 2.08178 9.80478 1.84975 9.67412 1.62413L9.41449 1.17584L8.90333 1.25995C7.33547 1.51794 5.80717 1.99419 4.37748 2.66939L4.19 2.75793L4.07461 2.93019C1.23864 7.16437 0.46302 11.3053 0.838165 15.3924L0.868838 15.7266L1.13844 15.9264C2.81818 17.1714 4.68053 18.1233 6.68582 18.719L7.18892 18.8684L7.50166 18.4469C7.96179 17.8268 8.36504 17.1824 8.709 16.4944L8.71099 16.4904C10.8645 17.0471 13.128 17.0485 15.2821 16.4947C15.6261 17.1826 16.0293 17.8269 16.4892 18.4469L16.805 18.8725L17.3116 18.717C19.3056 18.105 21.1876 17.1751 22.8559 15.9238L23.1224 15.724L23.1528 15.3923C23.5873 10.6524 22.3579 6.53306 19.8947 2.90714L19.7759 2.73227L19.5833 2.64518C18.1437 1.99439 16.6386 1.51826 15.0686 1.25995ZM16.6074 10.7755L16.6074 10.7756C16.5934 11.6409 16.0212 12.1444 15.4783 12.1444C14.9297 12.1444 14.3493 11.6173 14.3493 10.7877C14.3493 9.94885 14.9378 9.41192 15.4783 9.41192C16.0471 9.41192 16.6209 9.93851 16.6074 10.7755ZM8.49373 12.1444C7.94513 12.1444 7.36471 11.6173 7.36471 10.7877C7.36471 9.94885 7.95323 9.41192 8.49373 9.41192C9.06038 9.41192 9.63892 9.93712 9.6417 10.7815C9.62517 11.6239 9.05462 12.1444 8.49373 12.1444Z"
svg.strokeWidth 1.5
]
]
]
|}
]

[<ReactComponent>]
let Welcome() =
Html.main [
prop.className "flex items-center justify-center pt-16 pb-4"
prop.children [
Html.div [
prop.className "flex-1 flex flex-col items-center gap-16 min-h-0"
prop.children [
Html.header [
prop.className "flex flex-col items-center gap-9"
prop.children [
Html.div [
prop.className "w-[500px] max-w-[100vw] p-4"
prop.children [
Html.img [
prop.src logoLight
prop.alt "React Router"
prop.className "block w-full dark:hidden"
]
Html.img [
prop.src logoDark
prop.alt "React Router"
prop.className "hidden w-full dark:block"
]
]
]
]
]

Html.div [
prop.className "max-w-[300px] w-full space-y-6 px-4"
prop.children [
Html.nav [
prop.className "rounded-3xl border border-gray-200 p-6 dark:border-gray-700 space-y-4"
prop.children [
Html.p [
prop.className "leading-6 text-gray-700 dark:text-gray-200 text-center"
prop.text "What's next?"
]
Html.ul [
for res in resources ->
Html.li [
prop.key res.href
prop.children [
Html.a [
prop.className "group flex items-center gap-3 self-stretch p-3 leading-normal text-blue-700 hover:underline dark:text-blue-500"
prop.href res.href
prop.target.blank
prop.rel.noreferrer
prop.children [
res.icon
Html.text res.text
]
]
]
]
]
]
]
]
]
]
]
]
]

For home.tsx we just need to use the Welcome component and export default the new parent. Only the meta function is not straightforward. It is a function that returns an array of differently shaped objects. To translate this into f# syntax we will use an erased discriminated union. [<Erase>] will tell fable that we don't want to transpile it as a union, but just use the cases to create objects.

danger

Erased unions will only work if the of ... cases are distincly different shapes. If two cases have the same shape you might run into runtime errors.

[<Erase>]
type MetaDescriptor =
| CharSet of {|charSet: string|}
| Title of {|title: string|}
| NameContent of {|content: string; name: string|}
| PropertyContent of {|content: string; property: string|}
| HttpEquivContent of {|content: string; httpEquiv: string|}

This is not a full list of all possible meta tag shapes, but it is enough for our example. Now we can create the meta function.

info

Currently the official React Router docs do recommend using the built in <meta> component instead of the meta function.

Since React 19, using the built-in <meta> element is recommended over the use of the route module's meta export.

For the sake of this example we will still use the meta function as it was used in the original home.tsx file.

module Home 

open Feliz
open React.Router

let meta(_: obj) =
[|
MetaDescriptor.Title {|title = "New React Router App"|}
MetaDescriptor.NameContent {|name = "description"; content = "Welcome to the new React Router app!"|}
|]

[<ReactComponent(true)>]
let Home() =
Welcome.Welcome()

Replacing entry points

React Router configures entry points via react-router.config.ts file. If we look at the official docs we can find:

appDirectory: The path to the app directory, relative to the root directory. Defaults to "app".

So we only need to add a appDirectory property to point to our fable output folder app/fable_output.

import type { Config } from "@react-router/dev/config";

export default {
appDirectory: "app/fable_output",
// Config options...
// Server-side render by default, to enable SPA mode set this to `false`
ssr: true,
} satisfies Config;

Final steps

  1. Run npm run dev to start the fable compiler in watch mode and the react-router dev server.

💣💥 Oh no! Something went wrong!

Error: Could not find a root route module in the app directory as "app/fable_output/root.tsx"

Ahh seems like it looks exactly for a file named root.tsx. We can fix this by updating our fable script in package.json to use -e .jsx instead of -e .fs.jsx.

"fable": "dotnet fable ./app/App.fsproj --outDir ./app/fable_output -e .jsx",

Clear the app/fable_output folder, update the filepath in app/routes.fs and restart npm run dev.

  1. Open the link shown in the terminal to see your Fable React Router app in action! 🎉

  2. Delete all .tsx files and tsconfig.json if you want to fully switch to Fable.

Add a new route

In this section we want to quickly add a new route, as well as some navigation using NavLink.

  1. Create a new .fs file in the app/routes folder, e.g. about.fs.
about.fs
module About 

open Feliz
open React.Router

let meta(_: obj) =
[|
MetaDescriptor.Title {|title = "About"|}
MetaDescriptor.NameContent {|name = "description"; content = "Welcome to the About page!"|}
|]

[<ReactComponent(true)>]
let About() =
Html.div [
Html.h1 "About Page"
Html.p "This is the about page of our Fable React Router application."
]
  1. Update app/routes.fs to add the new route.
// lib/React.Router/Library.fs
[<Erase; Mangle(false)>]
module DevRoutes =
//...

[<Import("route", "@react-router/dev/routes")>]
let route(url: string, filePath: string) : RouteConfigEntry =
jsNative
// app/routes.fs
module Routes

open Fable.Core
open Feliz
open React.Router.DevRoutes

Fable.Core.JsInterop.exportDefault [|
index("./routes/home.jsx")
route("about", "./routes/about.jsx")
|]
  1. Add navigation using NavLink component.

In this example I still used a let binding for NavLink. To correctly bind this component we should instead use an approach allowing for easier optional parameters, like a static member or Feliz style with list of properties. But for the sake of simplicity I kept it like this.

// lib/React.Router/Library.fs
[<ReactComponent("NavLink", "react-router")>]
let NavLink(``to``: string, children: ReactElement, className: NavLinkState -> string) =
React.imported()
// app/root.fs
[<ReactComponentAttribute(true)>]
let App() =

let MyNavLink(to': string, label: string) =
NavLink(to', Html.text label, fun state -> if state.isActive then "underline" else "")

React.fragment [
Html.div [
prop.className "w-full p-2 bg-gray-900 fixed top-0 left-0 z-10 shadow-md h-12 flex flex-row items-center gap-2 "
prop.children [
MyNavLink("/", "Home")
MyNavLink("/about", "About")
]
]
Html.div [
prop.className "mt-12"
prop.children [
Outlet()
]
]
]