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-exportto: wrapper file to generatedirective: optional, either"use client"or"use server"default: re-export the default exportdefaultFromNamed: emit a default export from a named symbol in the compiled Fable modulenamed: re-export the named exports listed in the arrayexportAll: re-export everything from the source modulestaticExports: inline JSON-serializable route config values such asruntime,preferredRegion, orconfig
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:
directivemust be omitted,"use client", or"use server"use serverwrappers cannot usedefaultuse serverwrappers cannot useexportAll- 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 Fableapp/**,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.jsentry exported from F# - root instrumentation entries that can stay directive-free
For a full project walkthrough, see Starter app walkthrough.