In my previous post on trying to use the NextJS App Router and Reason I described some of the problems and limitations of their compatibility with one another. With the release of Melange 2 I decided to see if the new features of Melange 2 could help to increase the compatibility of Reason and the NextJS App Router. I have also documented some of the things learnt after trying Melange (v1) with Astro and Remix.
Melange 2
Melange 2 introduces a couple of new features that help to make Reason and ReasonReact more compatible with the NextJS App Router.
@mel.as
The @mel.as
attribute allows us to redefine a name in the output JavaScript. Using @mel.as
we can solve one of the previous problems with NextJS API route functions and Reason. NextJS expects API route functions to export a function with the name of a HTTP method. With @mel.as
we can rename our Reason API function to the HTTP method we want in the output JS and get no export related errors from NextJS:
[@mel.as "POST"]
let handler = () => {
...
};
Will be compiled to:
function POST(param) {
...
}
export {
POST ,
}
Astro API endpoints also require the route function to be named after a HTTP method, so this will be useful for Astro as well.
$$default
Another previous compatibility problem was the export of $$default
whenever default
was used as the function name in ReasonReact instead of make
. The export of $$default
would cause NextJS to complain and not compile. In Melange 2 $$default
is still present but it is exported properly in the output JS:
[@react.component]
let default = () => {
...
};
Compiles to:
function Page$default(Props) {
...
}
var $$default = Page$default;
export {
$$default as default,
}
@mel.as
and the changes to $$default
make Reason and ReasonReact much more compatible with the NextJS App Router.
Structuring a Reason and React meta-framework app
After experimenting with Reason plus Astro, NextJS, and Remix, I think there are two main ways to structure a React meta-framework app for use with Reason, and it depends upon how much Reason you want to use and how you are deploying your app. If you want to write a few components in ReasonReact then it is best to promote (i.e., copy) the output JS back into the src
folder of your app. If you want to write most of your app in Reason then it is best to add the meta-framework files as Melange dependencies and then promote all the output JS out of the _build
folder and back into the root.
Why promote
Developing in the OCaml way can be done by copying all of the meta-framework files to the _build
folder as Melange dependencies and then serving the app from the JavaScript output folder in _build
. However, I have found that this doesn't always go smoothly and can cause the meta-framework development tooling to behave in unexpected ways. You could try and debug these unexpected behaviours, but ...
I have found that the meta-framework dev tooling works much better if you promote the output JS into a folder that the meta-framework dev tooling expects to be used.
Promoting the output code also makes it much easier to deploy an application via linking a Git repository to a platform like Vercel or Netlify and deploying on push.
Promoting components
If your are only using ReasonReact to create a few components then it is best to promote the output JS of those components back into the src
folder. For example, if you have an Astro app like:
/
├── _build/
├── public/
├── src/
│ ├── dune
│ ├── layouts/
│ │ └── Layout.astro
│ ├── pages/
│ │ ├── dogs.astro
│ │ ├── jokes.astro
│ │ └── index.astro
│ └── reason_components/
│ ├── Dog.re
│ ├── Dog.rei
│ ├── Joke.re
│ ├── Joke.rei
│ └── dune
├── package.json
├── astro-reason.cfg.mjs
├── <project_name>.opam
├── dune
├── dune-project
└── Makefile
After promoting the output of the reason_components
folder you will end up with:
/
├── _build/
├── public/
├── src/
│ ├── dune
│ ├── layouts/
│ │ └── Layout.astro
│ ├── pages/
│ │ ├── dogs.astro
│ │ ├── jokes.astro
│ │ └── index.astro
│ ├── reason_components/
│ │ ├── Dog.re
│ │ ├── Dog.rei
│ │ ├── Joke.re
│ │ ├── Joke.rei
│ │ └── dune
│ └── reason_components_output/
│ ├── node_modules/
│ └── src/
│ └── reason_components/
│ ├── Dog.js
│ └── Joke.js
├── package.json
├── astro-reason.cfg.mjs
├── .opam
├── dune
├── dune-project
└── Makefile
No doubt, the path to the components in the promoted output folder is ugly and cumbersome, but you can remedy this by adding a path alias in a tsconfig.json
or jsconfig.json
file:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@components/*": ["src/reason_components_output/src/reason_components/*"]
}
}
}
Promoting serverless functions
How you promote serverless functions depends on where you are deploying them. If you are using something like Netlify, where functions can be configured independently of the frontend, then promoting the serverless functions folder might be the best option.
Expanding on the Astro example above we can add serverless functions:
/
├── _build/
├── public/
├── reason_netlify_functions/
│ ├── dune
│ └── joke.re
├── reason_netlify_functions_output/
│ ├── node_modules/
│ └── reason_netlify_functions/
│ └── joke.js
├── src/
...
└── Makefile
We can then tell Netlify the location of our functions (./reason_netlify_functions_output/reason_netlify_functions
) in a netlify.toml
file. See this basic Astro, Reason and Netlify application for an example.
If you are using something like Vercel, where the serverless functions are expected to be in a certain folder, then it might be best to promote the whole app.
Promoting the whole app
When promoting only certain folders extra paths are created when the output JS files are copied back next to the Reason source files. If we were to do this for applications that require serverless function routes to be in an api
folder then the path for that route would be long and ugly; for example, you could end up with something like api/output/reason_serverless_functions/joke/route.js
. You could use some hacks to get around such a path, e.g., redirects or a .env
value to shorten the path, but...
A better and cleaner solution is to promote the whole app. What does it mean to promote the whole app? Promoting the whole app involves adding all the meta-framework files to a melange.runtime_deps
stanza as dependencies so that they are copied to the _build
folder and next to the output JS files. We can then promote all the meta-framework files back out of the _build
folder along with the output JS.
A typical TypeScript NextJS App Router application will be structured something like:
├── README.md
├── app/
│ ├── api/
│ │ └── joke/
│ │ └── route.ts
│ ├── jokes/
│ │ └── page.tsx
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
├── next-env.d.ts
├── next.config.js
├── node_modules/
├── package-lock.json
├── package.json
├── postcss.config.js
├── public/
├── tailwind.config.ts
└── tsconfig.json
We can then convert it to a Reason application:
├── README.md
├── reason_app/
│ ├── api/
│ │ └── joke/
│ │ ├── dune
│ │ └── route.re
│ ├── jokes/
│ │ ├── dune
│ │ ├── page.re
│ │ └── page.rei
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ ├── page.tsx
│ └── dune
├── next-env.d.ts
├── next.config.js
├── node_modules/
├── package-lock.json
├── package.json
├── postcss.config.js
├── public/
├── tailwind.config.ts
├── tsconfig.json
├── <project_name>.opam
├── dune
├── dune-project
└── Makefile
It is important to note that we are not using a src
folder (i.e., ./src/app/) for our application structure and Reason source code. We will be using the src
folder eventually because it is important when promoting the output JS.
We have to rename the app
folder to reason_app
because an app
folder at the root level will take priority over a src/app
folder.
In ./app/dune
we add the NextJS files to the melange.runtime_deps
stanza:
(library
(name app)
(modes melange)
(libraries reason-react jokes)
(melange.runtime_deps
favicon.ico
globals.css
(glob_files *.tsx))
(preprocess
(pps reason-react-ppx melange.ppx)))
We can then add a reason_bindings
folder at the root level for any bindings we want, and when we build the app the output in the _build
folder will be:
./_build/default/output/
├── reason_app/
│ ├── api/
│ │ └── joke/
│ │ └── route.js
│ ├── jokes/
│ │ └── page.js
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
├── reason_bindings/
└── node_modules/
Note the node_modules
folder. This node_modules
folder contains the output JS for the Melange libraries used in the application. It is not the contents of the node_modules
folder created from package.json
.
Using dune directory-targets
(add (using directory-targets 0.1)
to the ./dune-project
file) we can promote the output JS in the _build
folder how we like. In ./dune
we can add a rule for the promotion of the output code to the all important ./src
folder, thus creating a typical NextJS application structure that uses ./src/app
:
(rule
(alias promote-app)
(mode
(promote (until-clean)))
(deps
(alias_rec nextjs))
(targets
(dir src))
(action
(bash
"mkdir src && cp -r output/reason_app src/app && cp -r output/reason_bindings src && cp -r output/node_modules src")))
alias promote_app
is an alias we can use in our Makefile so that the rule is run after every build. deps (alias_rec nextjs)
is a dependency on the recursive construction of our main application code which has the alias of nextjs
. dir src
sets the target for the promotion of the output JS as src
. In the action stanza we use bash to copy output/reason_app
to src/app
, making it recognisable to NextJS. We also copy reason_bindings
and the Melange node_modules
to src
so that they are at the same directory level as app
, which is important for maintaining import paths defined in the application when it was built and put into the build
folder. What we end up with is:
/
├── _build/
...
├── public/
├── reason_app/
├── reason_bindings/
├── src/
│ ├── app/
│ ├── node_modules/
│ └── reason_bindings/
├── package.json
├── next.config.js
...
├── <project_name>.opam
├── dune
├── dune-project
└── Makefile
And the app should work perfectly with the NextJS dev tooling. See this basic NextJS App Router, Reason and Melange 2 application for an example.
Miscellaneous
Melange node_modules
and deployment
If you want to deploy your app by connecting a Git repo to a service like Vercel or Netlify then you need to include the Melange node_modules
folder in the repository. If you don't you will likely get errors about being unable to find certain Melange libraries during the build process.
If you don't want to commit the Melange node_modules
folder to your repo then you can build the app locally using the Vercel/Netlify CLI and deploy using the CLI as well.
Another option is to use a CI/CD workflow to spin up a Linux container, install opam and build everything from scratch, but...
Directives
To use the directives "use client"
and "use server"
in a NextJS App Router application add [@mel.config {flags: [|"--preamble", "\"use client\";"|]}];
to the top of a file.
React Server Components
React Server Components are not yet supported by ReasonReact. You can work around this by creating a standard page.tsx
file. If you need a React component then you can write one in ReasonReact and import it into the page file.
Editor errors when importing components from ReasonReact
If you structure your application so that the whole app is going to be promoted out of the _build
folder and you import a ReasonReact component from a .re
file into a page.tsx
file you will get an editor error about the import. This error occurs because you have to import the component as if the .re
file was a JS component file. Once compiled the .re
file will be converted to a .js
file in the _build
folder and it will be in the correct location for the import to work properly after promotion. This is another reason to ensure that the folder structure of the output JS in the _build
folder is maintained when promoting the code.
Special characters in file names
Dune will allow some special characters in folder names but it does not allow them in file names (in my limited experience). Remix uses file name based routing and the file names can have special characters in them, e.g., concerts.$city.js
. When a file name with special characters in it fails to compile we can use a Dune rule stanza to create an output JS file with special characters in it:
; ./src/routes/dune
(library
(name app)
(libraries reason-react routes bindings)
(modes melange)
(melange.runtime_deps tailwind.css concerts_city.js)
(preprocess
(pps reactjs-jsx-ppx melange.ppx)))
(rule
(target concerts.$city.js)
(deps %{project_root}/remix/app/routes/concerts_city.js)
(action
(copy %{deps} %{target})))
In the above dune file we add concerts_city.js
to the melange.runtime_deps
- this is the output JS file name created from the Reason source code file concerts_city.re
. In the rule stanza we set the target as the special character containing file name we want and the dependency (deps
) as the path to concerts_city.js
in the _build
folder. The rule then copies concerts_city.js
to concerts.$city.js
, which should then work perfectly in a Remix application. Here is a broken and unfinished Remix app that may be of some limited use.
Hot Module Replacement (HMR)
If you find that HMR is not working properly it might help to add a delay to the meta-framework file watcher. For NextJS you could add the following to next.config.js
:
...
const nextConfig = {
...
webpack: function (config, context) {
config.watchOptions = {
poll: 1000,
aggregateTimeout: 300,
};
return config;
},
...
};
...
Ignore _build
and _opam
folders
A meta-frameworks file watcher will watch for any changes in any of the files and folders in root. The presence the OCaml _build
and _opam
folders can cause the meta-frameworks file watcher to behave in unexpected ways, so it is best to configure the meta-frameworks file watcher to ignore the _build
and _opam
folders. For Astro you can add the following to astro.config.mjs
:
...
export default defineConfig({
...
vite: {
server: {
watch: {
ignored: ["**/_build/**", "**/_opam/**"]
}
}
},
...
});
Conclusion
Melange 2 and the capabilities of Dune allow Reason and ReasonReact to work very well with React meta-frameworks. There maybe a few aspects of the meta-framework and their development tools that you have to work around, but hopefully the information above should help to make development go smoothly.
Thank you to Javier Chávarri for the help with Dune.
Top comments (0)