Directives and Wrappers

Next.js file directives are a packaging concern, not just an F# syntax concern.

Inline server actions

Directive.useServer() emits a 'use server' statement inside the function body. Use it inside server components, layouts, route handlers, and dedicated action modules:

open NextFs

let saveSearch (_formData: obj) =
    Directive.useServer()
    ()

This covers the function-level server-action case.

Do not define inline Directive.useServer() actions inside a client entry. Next.js rejects that at build time. For client pages, export the action from a separate server module and wire it through an app/actions.js wrapper.

NextFs also exposes cache directives for modern App Router caching flows:

let loadNavigationLabels () =
    Directive.useCache()
    Cache.cacheLifeProfile CacheProfile.Hours
    Cache.cacheTag "navigation"

    [| "Home"; "Docs" |]

Available inline directives today:

  • Directive.useServer()
  • Directive.useCache()
  • Directive.useCachePrivate()
  • Directive.useCacheRemote()

Why wrappers exist

Next.js expects 'use client' and 'use server' to be the first statement in the emitted JavaScript file.

Fable-generated output starts with imports, so an App Router entry module cannot always carry the directive directly. The fix is a thin wrapper file that contains the directive and re-exports the compiled Fable module.

Declaring entries with [<NextFsEntry>]

The recommended way to declare an entry is to annotate the module with [<NextFs.NextFsEntry>]:

[<NextFs.NextFsEntry("app/page.js", Directive="use client", Default="Page")>]
module App.Page

open Feliz
open NextFs

[<ReactComponent>]
let Page() =
    Html.h1 "Hello"

Place the attribute immediately before the module declaration — before any open statements. Use the fully-qualified form NextFs.NextFsEntry because open NextFs has not been processed yet at that point.

Available parameters:

Parameter Type Meaning
output (positional) string Wrapper path relative to project root, e.g. "app/page.js"
Directive string Optional: "use client" or "use server"
Default string Named F# binding to re-export as the JavaScript default export
Named string Space-separated list of named exports, e.g. "metadata viewport"
ExportAll bool When true, emits export * from ...

For cases where Next.js requires a statically-analyzable literal that Fable cannot guarantee to produce (e.g. middleware config objects), add [<NextFs.NextFsStaticExport>]:

[<NextFs.NextFsEntry("proxy.js", Named="proxy")>]
[<NextFs.NextFsStaticExport("config", """{"matcher":["/((?!_next).*)"]}""")>]
module Proxy

Once all modules are annotated, run the scanner to produce nextfs.entries.json:

node tools/nextfs-scan.mjs src/YourProject.fsproj

In the starter this step is wired into npm run scan and called automatically by npm run sync:app.

Wrapper generator

After nextfs.entries.json exists (whether generated by the scanner or written by hand), generate the actual wrapper files with:

node tools/nextfs-entry.mjs samples/nextfs.entries.json

It reads a config file and writes wrapper files to the target paths.

Supported entry fields:

  • from: compiled Fable output to re-export
  • to: wrapper file to generate
  • directive: optional, either "use client" or "use server"
  • default: re-export the default export
  • defaultFromNamed: emit a default export from a named symbol in the compiled Fable module
  • named: re-export the named exports listed in the array
  • exportAll: re-export everything from the source module
  • staticExports: inline JSON-serializable route config values such as runtime, preferredRegion, or config

Common patterns:

Case directive default named Typical to
client page "use client" defaultFromNamed: "Page" none app/page.js
client component "use client" true none app/components/counter.js
server actions module "use server" false ["createPost"] app/actions.js
route handler omitted false ["GET", "POST"] + staticExports app/api/posts/route.js
proxy entry omitted false ["proxy"] + staticExports.config proxy.js
server instrumentation omitted false ["register", "onRequestError"] instrumentation.js
client instrumentation omitted false none instrumentation-client.js via exportAll
segment error "use client" true none app/error.js
global error "use client" true none app/global-error.js
global not found omitted true ["metadata"] optional app/global-not-found.js

Current rules enforced by the generator:

  • directive must be omitted, "use client", or "use server"
  • use server wrappers cannot use default
  • use server wrappers cannot use exportAll
  • output paths must be unique
  • each entry must export at least one symbol
  • stale generated wrappers are pruned automatically
  • generated output is deterministic and covered by tests/nextfs-entry.test.mjs

Starter Pipeline

In the starter app, the file ownership boundary is:

  • src/**: handwritten F# source
  • .fable/**: generated JavaScript from Fable
  • app/**, proxy.js, instrumentation.js, instrumentation-client.js: generated Next.js entry wrappers

The shortest intended loop is:

dotnet tool restore
cd examples/nextfs-starter
npm install
npm run sync:app
npm run dev

And for incremental work:

npm run watch:fable
npm run dev

The wrapper generator never compiles F#. It only translates nextfs.entries.json into thin re-export files after Fable has already emitted .fable/**.

Example config

{
  "entries": [
    {
      "directive": "use client",
      "from": "./.fable/App/Page.js",
      "to": "./app/page.js",
      "defaultFromNamed": "Page"
    },
    {
      "directive": "use server",
      "from": "./.fable/App/Actions.js",
      "to": "./app/actions.js",
      "named": ["createPost", "deletePost"]
    },
    {
      "from": "./.fable/App/Api/Posts.js",
      "to": "./app/api/posts/route.js",
      "named": ["GET", "POST"],
      "staticExports": {
        "runtime": "nodejs"
      }
    },
    {
      "from": "./.fable/Proxy.js",
      "to": "./proxy.js",
      "named": ["proxy"],
      "staticExports": {
        "config": {
          "matcher": ["/dashboard/:path*"]
        }
      }
    },
    {
      "from": "./.fable/Instrumentation.js",
      "to": "./instrumentation.js",
      "named": ["register", "onRequestError"]
    },
    {
      "from": "./.fable/InstrumentationClient.js",
      "to": "./instrumentation-client.js",
      "exportAll": true
    }
  ]
}

That example shows the three common cases:

  • a client entry page with a default export
  • a server-actions module with named exports only
  • a route-handler module that does not need a directive
  • a route-handler module that can also re-export route config constants
  • a root-level proxy.js entry exported from F#
  • root instrumentation entries that can stay directive-free

For a full project walkthrough, see Starter app walkthrough.