ResX
A ReScript framework for building server-driven web sites and applications. Use familiar tech like JSX and the component model from React, combined with simple server driven client side technologies like HTMX. Built on Bun and Vite.
ResX is suitable for building everything from blogs to complex web applications.
Philosophy
ResX focuses on the web platform, and aims to see how far we can get building web sites and applications before reaching for a full blown client side framework is necessary.
ResX has an "open hood". That means that it's trying to stay close to the metal, and have fairly few abstractions. It encourages you to understand how a web server and the web platform works. This will lead to you building better and more robust things as you're encouraged to understand the platform itself.
Demo
The demo is currently a WIP.
The demo/ will contain a comprehensive example of using ResX.
Deploy
ResX apps are deployed the same basic way you would deploy a server-rendered JS app: you either ship a built server artifact, or you ship the app code and run the server entry point on the machine that hosts it.
In practice that usually means a build step in CI, then either a container or a process manager on the server, typically sitting behind a reverse proxy.
With ResX and Bun, the practical options are:
- Build a Bun single-file executable and deploy that
- Deploy the built app code and run the entry point with Bun on the server
- Wrap either of those in Docker if you want a more self-contained deploy unit
The demo app in demo/ contains a working example of the first option. It includes a minimal Docker setup that builds a Bun single-file executable and runs it from a small Alpine image.
If you use the ResX asset pipeline, there are now two deploy modes:
staticAssetRoutes.mode: "filesystem"is the default. Generated static routes read from./dist, so you need to deploy the builtdist/directory alongside the executable and run the process from the directory that contains thatdist/folder.staticAssetRoutes.mode: "embedded"generates Bun embedded-file imports instead. That mode is intended forbun build --compile, and lets the executable serve generated ResX assets without a sidecardist/tree at runtime.
In the demo:
demo/assets/anddemo/public/are emitted intodemo/dist/demo/build/demo-appis the compiled executabledemo/Dockerfileshows the minimal Alpine image setupdemo/README.mddocuments the full Docker and direct-SFE flow
The Docker path is still the safest default because it builds the Linux executable in-container. In filesystem mode it also packages the required dist/ assets; in embedded mode the executable can stand on its own.
Bun Single-File Executables
ResX works well with Bun single-file executables built via bun build --compile.
The important detail is that ResX has two static-asset deployment modes:
staticAssetRoutes.mode: "filesystem"is the default. Generated static routes read from./distat runtime.staticAssetRoutes.mode: "embedded"generateswith { type: "file" }imports instead, so Bun can embed the generated assets into the executable itself.
If you want a truly standalone executable, use "embedded".
1. Configure the Vite Plugin
// vite.config.js import { defineConfig } from "vite"; import resXVitePlugin from "rescript-x/res-x-vite-plugin.mjs"; export default defineConfig(({ command }) => { const staticAssetRouteMode = command === "build" ? "embedded" : "filesystem"; return { plugins: [ resXVitePlugin({ clientDirs: ["client"], staticAssetRoutes: { mode: staticAssetRouteMode, }, }), ], }; });
This is the most ergonomic setup for Bun SFEs: production builds switch to embedded, while local vite serve stays on the familiar filesystem-backed setup.
2. Build the App Normally First
Build the Vite output and ReScript output before compiling the executable:
{
"scripts": {
"start": "NODE_ENV=production bun run src/App.js",
"build": "NODE_ENV=production bun run build:vite && bun run build:res",
"build:vite": "vite build",
"build:res": "rescript",
"build:sfe": "bun run build && mkdir -p build && NODE_ENV=production bun build --compile --outfile ./build/app ./src/App.js"
}
}Important details:
- Compile the generated JavaScript server entrypoint such as
src/App.js, not the.ressource file. - Run the normal build first so ResX has already generated the final asset URLs and static route module.
staticAssetRoutes.modeonly affects build output. Dev mode stays on the normal filesystem-backed workflow.
3. Build the Executable
bun run build:sfe
That produces an executable such as:
build/app
In filesystem mode you should also expect to deploy:
dist/
4. Deploy It
For staticAssetRoutes.mode: "embedded":
- Deploy the executable by itself.
- Start it with something like
PORT=4444 NODE_ENV=production ./build/app. - The generated ResX static assets are served from the executable, so the original
dist/tree does not need to be present at runtime.
For staticAssetRoutes.mode: "filesystem":
- Deploy the executable together with
dist/. - Start the executable from the directory that contains
dist/, or configure your service working directory accordingly. - ResX will serve generated static assets from the files in
dist/.
5. Practical Notes
- Bun single-file executables are target-platform specific. Build on the same OS/architecture you plan to deploy, or build inside a matching container.
- Docker is still a good default when you want a reproducible Linux build artifact.
embeddedonly changes generated static asset routes. Your application server code still mountsResXAssets.staticAssetRoutesthe same way.- Browser-facing asset URLs such as
ResXAssets.assets.resXClient_jsstill work the same way from application code.
Publishing
Publishing to npm is handled by GitHub Actions trusted publishing in .github/workflows/publish.yml.
One-time npm setup:
- Open the
rescript-xpackage settings on npm. - Add a trusted publisher for GitHub Actions:
- owner:
zth - repository:
res-x - workflow file:
publish.yml
- owner:
- Save the trusted publisher.
Release flow:
- Bump
package.jsonto the version you want to publish. - Commit the version bump and any generated artifact updates.
- Push a matching git tag. Both
1.2.2andv1.2.2are accepted.
The publish workflow rebuilds the package, regenerates client/ResXClient.js, runs the test suite, verifies that the build does not change tracked files, and then publishes to npm via OIDC. Pre-release versions publish under their pre-release identifier as the npm dist-tag, so 1.2.2-beta.1 publishes with the beta tag and 1.2.2-dev.1 publishes with the dev tag.
Getting started
For a new ResX app, start from the ResX template. The rest of this section documents the manual setup if you want to wire things together yourself.
First, make sure you have Bun installed and setup. Then, install rescript-x and the dependencies needed:
npm i rescript@^12 rescript-x vite rescript-bun
Note that ResX requires these versions:
rescript@>=12.0.0-0 <13.0.0rescript-bun@>=2.1.0
Configure our rescript.json:
{
"jsx": {
"module": "Hjsx",
"version": 4
},
"dependencies": ["rescript-x", "rescript-bun"],
"compiler-flags": [
"-open RescriptBun",
"-open RescriptBun.Globals",
"-open ResX.Globals"
]
}Go ahead and install the dependencies for Tailwind as well if you want to use it:
npm i autoprefixer postcss tailwindcss
Let's set everything up. Start by setting up vite.config.js:
import { defineConfig } from "vite"; import resXVitePlugin from "rescript-x/res-x-vite-plugin.mjs"; export default defineConfig({ plugins: [ resXVitePlugin({ clientDirs: ["client"], }), ], server: { port: 9000, }, });
Make sure you have both folders for static assets set up: assets and public in the root, next to vite.config.js. More on static assets later.
If you're using Tailwind, add tailwind.config.js and postcss.config.js as well:
// postcss.config.js module.exports = { plugins: [require("tailwindcss"), require("autoprefixer")], };
// tailwind.config.js /** @type {import('tailwindcss').Config} */ module.exports = { content: ["./src/**/*.res"], theme: { extend: {}, }, plugins: [], };
There! If you want, you can also set up a bunch of scripts in package.json that'll make life easier:
{
"scripts": {
"start": "NODE_ENV=production bun run src/App.js",
"build": "NODE_ENV=production bun run build:vite && bun run build:res",
"build:vite": "vite build",
"build:res": "rescript",
"build:sfe": "bun run build && mkdir -p build && NODE_ENV=production bun build --compile --outfile ./build/app ./src/App.js",
"clean:res": "rescript clean",
"dev:res": "rescript watch",
"dev:server": "bun --watch run src/App.js",
"dev:vite": "vite",
"dev": "concurrently 'bun:dev:*'"
}
}Note: These scripts use
concurrently. Install vianpm i concurrently.
Now, let's create your Handler instance. You'll use this throughout your app as a sort of context:
// Handler.res // This context will be passed throughout your application. Use it for any per-request needs, like dataloaders, the id of the currently logged in user, etc. type context = {userId: option<string>} // `requestToContext` should produce your context above from the pending `request`. It'll be called fresh for each request. let handler = ResX.Handlers.make(~requestToContext=async _request => { userId: None, }) // This isn't required but is a shorthand to pull out the context a bit more conveniently from your handler. let useContext = () => handler.useContext()
Next, let's set up our webserver via Bun:
// App.res let port = 4444 let server = Bun.serve({ port, development: ResX.BunUtils.isDev, routes: Dict.assign( dict{ "/health": {get: Bun.Static(Response.make("ok"))}, }, ResXAssets.staticAssetRoutes, ), fetch: async (request, _server) => { // Handle the request using the ResX handler if this wasn't a static route. // Note: By default, all HTMX handler routes are prefixed with "_api", and all form action routes are prefixed with "_form". await Handler.handler.handleRequest({ request, setupHeaders: () => { // You can do any basic headers setup here that you want. These can be overwritten easily by your main application regardless of what you set here. Headers.make(~init=FromArray([("Content-Type", "text/html")])) }, render: async ({path, requestController, headers}) => { // This handles the actual request. switch path { | list{"sitemap.xml"} => <SiteMap /> | appRoutes => requestController.appendTitleSegment("Test App") <Html> <div> {switch appRoutes { | list{} => <div> {Hjsx.string("Start page!")} </div> | list{"moved"} => requestController.redirect("/start", ~status=302) | _ => requestController.setStatus(404) <div>{Hjsx.string("404")}</div> }} </div> </Html> } }, }) }, }) let portString = server->Bun.Server.port->Int.toString Console.log(`Listening! on localhost:${portString}`) // Run the small dev socket server used to trigger page refreshes after backend restarts. if ResX.BunUtils.isDev { ResX.BunUtils.runDevServer(~port) }
Note that there's plenty of more things you can configure here, but for the sake of keeping it simple we'll just go with the basics.
You can now start up the dev environment: bun run dev. Open the Vite URL, for example http://localhost:9000, and you should see your "Start page!" string.
In dev, browse the app through the Vite server, not the raw Bun app server port. ResX serves dev assets with root-relative URLs from the Vite origin and performs a full page refresh after the backend restarts and reconnects.
There's a ton more to ResX of course, but this should get you started.
Routing
As you noticed from the example above, there's no explicit router in ResX itself. In the future, we might ship a dedicated type safe router in the style of rescript-relay-router. But for now, we'll use pattern matching!
You route by just pattern matching on path:
switch path { | list{} => // Path: / <div> {Hjsx.string("Start page!")} </div> | list{"moved"} => // Path: /moved requestController.redirect("/start", ~status=302) | _ => // Any other path requestController.setStatus(404) <div>{Hjsx.string("404")}</div> }
Static assets
ResX comes with full static asset (fonts, images, etc) handling via Vite, that you can use if you want. The asset pipeline generates Bun-ready static routes for you under ResXAssets.staticAssetRoutes:
let server = Bun.serve({ port, routes: ResXAssets.staticAssetRoutes, fetch: async (request, _server) => await Handler.handler.handleRequest({ request, ... }), })
In build output, ResXAssets.assets.* always resolves to normal browser-facing URLs. That includes package-owned browser assets like ResXAssets.assets.resXClient_js, which are emitted under your asset namespace instead of leaking raw /node_modules/... paths.
If you want to add your own Bun static routes, staticAssetRoutes is a regular Dict.t, so you can merge it the same way you would merge any other ReScript dict:
Bun.serve({ port, routes: Dict.assign( dict{ "/health": {get: Bun.Static(Response.make("ok"))}, }, ResXAssets.staticAssetRoutes, ), fetch: async (request, _server) => await Handler.handler.handleRequest({ request, ... }), })
If you want to configure how these generated static asset routes behave, pass staticAssetRoutes to the Vite plugin:
These settings only apply to generated static asset routes, not your normal app routes.
// vite.config.js import { defineConfig } from "vite"; import resXVitePlugin from "rescript-x/res-x-vite-plugin.mjs"; export default defineConfig(({ command }) => ({ plugins: [ resXVitePlugin({ staticAssetRoutes: { mode: command === "build" ? "embedded" : "filesystem", headers: { "/assets/**": { "Cache-Control": "public, max-age=31536000, immutable", }, "/robots.txt": { "Cache-Control": "public, max-age=300", }, }, }, }), ], }));
staticAssetRoutes.mode controls how ResX materializes generated server-side asset routes:
"filesystem"is the default and keeps the currentBun.file("./dist/...")behavior."embedded"generateswith { type: "file" }imports so the routes work withbun build --compile.
If you are building a Bun single-file executable and want it to run without the original dist/ tree on disk, use "embedded".
Using command === "build" ? "embedded" : "filesystem" is a good default convention. It keeps the production build standalone-friendly while making the dev intent explicit, even though ResX already keeps dev on the normal filesystem-backed path.
staticAssetRoutes.headers is an object where:
- Each key is a route pattern for generated static asset routes.
- Each value is a map of response headers to apply when that pattern matches.
- Exact paths like
"/robots.txt"match only that route. *matches a single path segment, for example"/assets/*".**matches any remaining path depth, for example"/assets/**".- If multiple patterns match the same route, the last matching rule wins.
So this:
headers: { "/assets/**": { "Cache-Control": "public, max-age=31536000, immutable", }, "/robots.txt": { "Cache-Control": "public, max-age=300", }, }
means:
- all generated
/assets/...routes get long-lived immutable caching /robots.txtgets a shorter cache policy- nothing outside the generated static asset routes is affected
ResX always generates exact Bun routes for the static assets it knows about at build time. That keeps the runtime simple: Bun just loads a generated file that already contains the route table and any configured headers.
This built-in pipeline is intended for standard webapp asset sets. If you have so many generated static asset routes that Bun startup is becoming slow, that is a sign that you should implement your own asset loading pipeline instead of pushing the built-in one further.
As for the assets themselves, there are two ways of handling them in ResX:
public for assets that don't need transformation
Putting assets in the public directory. Any assets you put in the top level public directory next to vite.config.js will be copied as-is to your production environment. It's then available to you via the top level:
// public/robots.txt exists
GET /robots.txt
Nested paths are preserved as well:
// public/assets/logo.svg exists
GET /assets/logo.svg
assets for assets that do need transformation
If you have assets you'd like transformed by Vite before using, put them in the top level assets folder. This could be CSS, images, or browser entry JavaScript. Anything you might want Vite to transform.
Here's an example of how you wire up Tailwind:
/* assets/styles.css */ @tailwind base; @tailwind components; @tailwind utilities;
Then, include it in your ReScript:
<head> <link type_="text/css" rel="stylesheet" href={ResXAssets.assets.styles_css} /> </head>
There! It's now available to you, and Vite will transform it for both dev and production builds. In dev, assets are served from the Vite origin using root-relative URLs.
Thinking about client side JavaScript
ResX is server-first. The default is:
- Render HTML on the server.
- Reach for normal links, forms and handlers first.
- Use HTMX or
ResX.Clientwhen declarative browser behavior is enough. - Add your own browser JavaScript only when you actually need code running in the browser.
When you do need browser JavaScript, think in terms of browser entry modules, not loose script files. An entry module is the file you include from HTML. That file can then import whatever else it needs, and Vite will handle transformation, minification, hashing, CSS extraction, and shared chunks in production.
There are two intended places for those entry modules:
- Put small app-local entry files in top level
assets/when they sit naturally next to your other transformed assets. - Configure
clientDirswhen you want a dedicated folder for browser code, for exampleclient/.
Top level JS and TS files in assets/ become browser entries automatically. They are exposed through ResXAssets.assets and should be loaded as module scripts:
@jsx.component let make = (~trustedHtmlContent) => { <div> {Hjsx.string("This content is escaped: <script>alert('xss')</script>")} {Hjsx.dangerouslyOutputUnescapedContent(trustedHtmlContent)} </div> }
CRITICAL SECURITY WARNING: dangerouslyOutputUnescapedContent completely bypasses HTML escaping. Never use this function with user-provided content or any untrusted data, as it can create XSS vulnerabilities. Only use this with content you trust completely, such as:
- Static HTML strings in your code
- Content from trusted CMS systems that handle their own sanitization
- Pre-sanitized content from trusted markdown processors
- Generated HTML from your own trusted systems
- Raw non-HTML content like CSV data, or other structured data formats (see the CSV export example in the Doc header section)
When outputting non-HTML content types (CSV, XML, etc.), you'll typically need to:
- Set the appropriate
Content-Typeheader - Remove or customize the doc header using
setDocHeader - Use
dangerouslyOutputUnescapedContentto output the raw content without HTML escaping
When in doubt, use Hjsx.string instead, which safely escapes all content.
Async components
Components can be defined using async/await. This enables you to do data fetching directly in them:
// User.res @jsx.component let make = async (~id) => { let user = await getUser(id) <div>{Hjsx.string("Hello " ++ user.name)}</div> }
WARNING! As with all async things you need to be careful to not create waterfalls, or performance will suffer. Handling that is out of scope for this readme, but following this tip will get you far - initiate data fetching as far up the tree as possible. Awaiting the data is fine to do in leaf components, but it's good for perf to initiate data fetching as high up as possible, and then pass the promise of that data down the tree.
Context
Just like in React, you can use context to pass data down your tree without having to prop drill it:
// CurrentUserContext.res let context = H.Context.createContext(None) let use = () => H.Context.useContext(context) module Provider = { let make = H.Context.provider(context) } @jsx.component let make = (~children, ~currentUserId: option<string>) => { <Provider value={currentUserId}> {children} </Provider> } // App.res let currentUserId = request->UserUtils.getCurrentUserId <CurrentUserContext currentUserId> <div> ... </div> </CurrentUserContext> // LoggedInUser.res // This is rendered somewhere far down in the tree @jsx.component let make = () => { switch CurrentUserId.use() { | None => <div>{Hjsx.string("Not logged in")}</div> | Some(currentUserId) => <div>{Hjsx.string("Logged in as: " ++ currentUserId)}</div> } }
Error boundaries
Just like in React, you can protect parts of your UI from errors during render using an error boundary, using the <ResX.ErrorBoundary /> component. You need to pass it a renderError function, and this function will be called whenever there's an error:
<ResX.ErrorBoundary renderError={err => { Console.error(err) <div>{Hjsx.string("Oops, this blew up!")}</div> }}> <div> <ComponentThatWillBlowUp /> </div> </ResX.ErrorBoundary>
You can use as many error boundaries as you want. You're recommended to wrap your entire app with an error boundary as well.
Request conveniences
ResX ships with a number of conveniences for handling common things when building responses for requests.
onBeforeBuildResponse hook for manipulating the context before the response is built
onBeforeBuildResponse lets you manipulate your request specific context before ResX starts generating HTML. Let's look at an example of adding a script tag to the head if a certain criteria has been met: