Using React server/client components with Rescript (React 18/NextJS 13)

I’ve spend a bit of time playing around with NextJS’s experimental app/ directory setup which introduces React’s new server components (RSC), but it seems like it’s not possible use the new features with Rescript.

How it currently works is that you add a 'use client'; directive at the top of the file in which you define client components. I can’t figure out how/if this can be done in Rescript. Using %%raw() does not seem to work since it appears after the imports even if you use it on line 1. Is there any other way I can get around this?

Also, it seems like colocating pages (always named page.<ext>), and layouts (always named layout.<ext>) with components is the convention that is being advocated for when it comes to the app/ directory in NextJS 13+. Since it’s not possible to have the pages and layouts in Rescript anymore because the naming convention means code duplication, following this would mean mixing .res and .js files in the same directory. I understand that the no-duplicate-filenames is very intentional, but could it make sense to have some kind of escape hatch for this, given that you’re fine with the contents of these files not being possible to import elsewhere?

Regardless use client.

You can specify a js script that runs after every file compilation - Build System Configuration | ReScript Language Manual

I made a script postProcessJS.cjs:

const fs = require("fs");
const parser = require("@babel/parser");
const t = require("@babel/types");
const generate = require("@babel/generator").default;
const traverse = require("@babel/traverse").default;

const [_nodePath, _scriptPath, filePath] = process.argv;
const useClientDirective = "use client";

let file = fs.readFileSync(filePath, "utf-8");

const ast = parser.parse(file, {
  sourceType: "module",

let hasUseClient = false;

traverse(ast, {
  StringLiteral: function (path) {
    if (path.node.value === useClientDirective) {
      hasUseClient = true;

if (hasUseClient) {
  traverse(ast, {
    Program(path) {
      path.unshiftContainer("body", t.stringLiteral(useClientDirective));

  let modified = generate(ast).code;
  fs.writeFileSync(filePath, modified);

and included it in my bsconfig:

  "js-post-build": {
    "cmd": "path/to/node ../../postProcessJS.cjs"

What it does - as soon as it spots use client string - it will remove it from the file and add on top of it (before imports);
So %%raw("use client") would work as intended.

Regardless file naming.
You can use the same approach with js-post-build - for example, you have CartPage.res, CartPage_Layout.res and CartPage_Error.res - you will create files via fs.writeFileSync and have /cart/page.mjs, /cart/layout.mjs, /cart/error.mjs.

Or you can simply create those js files by hand and import compiled from rescript file.
Something like /cart/page.mjs:

import { make as Component } from 'src/pages/cart/CartPage.mjs;

export default Component;
1 Like

We use this approach at my company, and I think it’s actually the best way of working with Next.js routing as of now.

Also, you can write it a little bit shorter:

export { make as default } from 'src/pages/cart/CartPage.mjs;

If I have time in the next year, I think of writing a small code-gen tool to generate the /pages directory with reexports. It’ll reduce manual labor and make the solution a little bit more typesafe.

1 Like