diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
index 9fecd800f..248ccb3dc 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -55,6 +55,8 @@ jobs:
run: yarn
- name: Build ReScript
run: yarn build:res
+ - name: Build docs
+ run: yarn build:vite
- name: Validate Docs Wrangler dev server
working-directory: apps/docs
run: |
diff --git a/AGENTS.md b/AGENTS.md
index bffff5514..051982a20 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,7 +2,7 @@
## Project Overview
-This is the official documentation website for the [ReScript](https://rescript-lang.org) programming language. It is a **fully pre-rendered static site** (no server-side rendering at runtime) built with **ReScript v12 + React 19 + React Router v8 + Vite 8 + Tailwind CSS v4**, deployed to **Cloudflare Pages**.
+This is the official documentation website for the [ReScript](https://rescript-lang.org) programming language. It is primarily pre-rendered at build time, with runtime server-side rendering for `/try` through Cloudflare Pages Functions. It is built with **ReScript v12 + React 19 + React Router v8 + Vite 8 + Tailwind CSS v4**, deployed to **Cloudflare Pages**.
## System Requirements
@@ -99,7 +99,8 @@ The project uses several patterns for JavaScript interop. Follow the existing co
## ReScript React
- This project uses **React 19** and **React Router v8** (framework mode).
-- The site is **pre-rendered** (`ssr: false`), so loaders have access to the filesystem during build. Loaders do **not** run on a server after the build.
+- The site uses hybrid rendering: most routes are pre-rendered during the build, while `/try` is server-rendered by Cloudflare Pages Functions at runtime.
+- Loaders for pre-rendered routes can access the filesystem during the build. Runtime SSR loaders, including shared loader code reachable from `/try`, execute in Cloudflare and must not depend on Node-only filesystem APIs or other build-time-only operations.
- Route modules live in `app/routes/` and export a `loader` and a `default` component.
- Route modules **require** both a `.res` and a `.resi` (interface) file for Vite HMR to work.
- Only a single React component can be exposed from a module's JS output.
diff --git a/apps/docs/__tests__/Sitemap_.test.res b/apps/docs/__tests__/Sitemap_.test.res
index f35927387..a9f12ce7e 100644
--- a/apps/docs/__tests__/Sitemap_.test.res
+++ b/apps/docs/__tests__/Sitemap_.test.res
@@ -1,7 +1,7 @@
open Vitest
test("renders sorted unique sitemap URLs with a normalized base URL", async () => {
- let xml = Sitemap.render(
+ let xml = Sitemap.renderWithRuntimePaths(
~baseUrl="https://preview.example.com/",
["/docs/manual/introduction", "blog", "/", "/docs/manual/introduction"],
)
@@ -17,6 +17,9 @@ test("renders sorted unique sitemap URLs with a normalized base URL", async () =
https://preview.example.com/docs/manual/introduction/
+
+ https://preview.example.com/try/
+
`)
})
diff --git a/apps/docs/app/entry.server.js b/apps/docs/app/entry.server.js
new file mode 100644
index 000000000..f00cc4976
--- /dev/null
+++ b/apps/docs/app/entry.server.js
@@ -0,0 +1,55 @@
+import React from "react";
+import { renderToReadableStream } from "react-dom/server";
+import { isbot } from "isbot";
+import { ServerRouter } from "react-router";
+
+export const streamTimeout = 5_000;
+
+export default async function handleRequest(
+ request,
+ responseStatusCode,
+ responseHeaders,
+ routerContext,
+) {
+ responseHeaders.set("Content-Type", "text/html");
+
+ if (request.method.toUpperCase() === "HEAD") {
+ return new Response(null, {
+ status: responseStatusCode,
+ headers: responseHeaders,
+ });
+ }
+
+ let shellRendered = false;
+ let userAgent = request.headers.get("user-agent");
+
+ let body = await renderToReadableStream(
+ React.createElement(ServerRouter, {
+ context: routerContext,
+ url: request.url,
+ }),
+ {
+ signal: AbortSignal.timeout(streamTimeout + 1_000),
+ onError(error) {
+ responseStatusCode = 500;
+ if (shellRendered) {
+ console.error(error);
+ }
+ },
+ },
+ );
+
+ shellRendered = true;
+
+ if (
+ ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) &&
+ body.allReady
+ ) {
+ await body.allReady;
+ }
+
+ return new Response(body, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ });
+}
diff --git a/apps/docs/functions/try.data.js b/apps/docs/functions/try.data.js
new file mode 100644
index 000000000..cdd6cbf71
--- /dev/null
+++ b/apps/docs/functions/try.data.js
@@ -0,0 +1 @@
+export { onRequest } from "../server/react-router-handler.js";
diff --git a/apps/docs/functions/try.js b/apps/docs/functions/try.js
new file mode 100644
index 000000000..cdd6cbf71
--- /dev/null
+++ b/apps/docs/functions/try.js
@@ -0,0 +1 @@
+export { onRequest } from "../server/react-router-handler.js";
diff --git a/apps/docs/package.json b/apps/docs/package.json
index a8cbb7fea..3baad8db8 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -23,7 +23,7 @@
"convert-images": "auto-convert-images",
"dev:res": "rescript watch",
"dev:vite": "react-router dev --host",
- "dev:wrangler": "yarn wrangler pages dev build/client",
+ "dev:wrangler": "yarn build:vite && yarn wrangler pages dev build/client",
"dev": "yarn prepare && yarn dev:res & yarn dev:vite & yarn dev:wrangler",
"format": "cd ../.. && oxfmt && cd apps/docs && rescript format",
"prepare": "yarn build:res && yarn build:scripts && yarn check:algolia-public-env && yarn build:update-index",
diff --git a/apps/docs/react-router.config.mjs b/apps/docs/react-router.config.mjs
index fab177c9c..1cbca953b 100644
--- a/apps/docs/react-router.config.mjs
+++ b/apps/docs/react-router.config.mjs
@@ -3,16 +3,23 @@ import * as fs from "node:fs";
const { stdlibPaths } = await import("./app/DocsRoutes.jsx");
export default {
- ssr: false,
+ ssr: true,
+ routeDiscovery: { mode: "initial" },
prerender: {
// Restore os.availableParallelism() after https://github.com/remix-run/react-router/issues/15255 is fixed.
concurrency: 1,
async paths({ getStaticPaths }) {
- return [...(await getStaticPaths()), ...stdlibPaths];
+ return [
+ ...(await getStaticPaths()).filter(
+ (path) => path !== "/try" && path !== "try",
+ ),
+ ...stdlibPaths,
+ ];
},
},
buildEnd: async () => {
+ fs.rmSync("./out", { recursive: true, force: true });
fs.cpSync("./build/client", "./out", { recursive: true });
},
};
diff --git a/apps/docs/scripts/generate_sitemap.res b/apps/docs/scripts/generate_sitemap.res
index d85dc9f44..1fbd70be2 100644
--- a/apps/docs/scripts/generate_sitemap.res
+++ b/apps/docs/scripts/generate_sitemap.res
@@ -36,7 +36,7 @@ if !Node.Fs.existsSync(sourceDir) {
}
let baseUrl = Node.Process.env->Dict.get("VITE_DEPLOYMENT_URL")->Option.getOr("")
-let sitemap = sourceDir->collectPagePaths("")->Sitemap.render(~baseUrl)
+let sitemap = sourceDir->collectPagePaths("")->Sitemap.renderWithRuntimePaths(~baseUrl)
outputDirs->Array.forEach(outputDir => {
if Node.Fs.existsSync(outputDir) {
diff --git a/apps/docs/server/react-router-handler.js b/apps/docs/server/react-router-handler.js
new file mode 100644
index 000000000..c0a8ac2df
--- /dev/null
+++ b/apps/docs/server/react-router-handler.js
@@ -0,0 +1,12 @@
+import { createRequestHandler, RouterContextProvider } from "react-router";
+
+const mode = globalThis.process?.env?.NODE_ENV ?? "production";
+
+const handleReactRouterRequest = createRequestHandler(
+ () => import("../build/server/index.js"),
+ mode,
+);
+
+export function onRequest(context) {
+ return handleReactRouterRequest(context.request, new RouterContextProvider());
+}
diff --git a/apps/docs/src/common/Sitemap.res b/apps/docs/src/common/Sitemap.res
index 2dfacc6b1..31571d85e 100644
--- a/apps/docs/src/common/Sitemap.res
+++ b/apps/docs/src/common/Sitemap.res
@@ -1,4 +1,5 @@
let defaultBaseUrl = "https://rescript-lang.org"
+let runtimePaths = ["/try"]
let normalizeBaseUrl = baseUrl => {
let trimmed = baseUrl->String.trim
@@ -79,3 +80,6 @@ ${urls}
`
}
+
+let renderWithRuntimePaths = (~baseUrl, staticPaths) =>
+ [...staticPaths, ...runtimePaths]->render(~baseUrl)
diff --git a/apps/docs/wrangler.toml b/apps/docs/wrangler.toml
index 2b2e8c6d0..2f9ad5f1a 100644
--- a/apps/docs/wrangler.toml
+++ b/apps/docs/wrangler.toml
@@ -1 +1,4 @@
+name = "rescript-lang-org"
compatibility_date = "2026-09-10"
+compatibility_flags = ["nodejs_compat"]
+pages_build_output_dir = "out"