TLDR
After creating a demo application with Astro and ReasonReact I thought I would try and recreate the app using NextJS and the App Router. In the Astro app components were written using ReasonReact and then imported into an Astro file from the output JS files created by Melange. This time I wanted to try and use no JS at all and write the whole thing in Reason. The code can be found here.
The Dune way
The _build
folder
When Dune builds a projects the output is placed into the _build
directory. For the Astro application mentioned above ReasonReact components were promoted out of the _build
folder and back into the src
folder, which made it easier to import the components into .astro
files. Because NextJS uses folder based routing I wanted to try and keep the directory structure as clean as possible and without promoted folders, thus making the directory structure the same as a regular JavaScript NextJS app.
For the NextJS app to work properly from the _build
directory, files and folders required by the app have to be copied into the output directory using the runtime_deps
stanza in a dune
file:
(melange.emit
(target nextjs-app-folder-reason)
(alias nextjs)
(module_systems es6)
(libraries app api components bindings)
(runtime_deps
jsconfig.json
next-env.d.ts
next.config.js
package.json
postcss.config.js
tailwind.config.js
tsconfig.json
(source_tree public)))
This effectively replicates the standard app directory structure in the _build
folder, and on compilation the Reason files will be compiled to JS files:
To run the app from the root directory the commands in package.json
have to be updated, e.g., "cd _build/default/nextjs && next dev"
.
dune
files
In the image above you can see that there are a lot of dune
files. The dune
files contain stanzas that, among other things, can define libraries, specify what libraries this library relies on, and how the library should be processed. For example, the dune
file in the api
folder is:
(library
(name api)
(libraries melange-json melange-fetch bindings joke)
(modes melange)
(preprocess
(pps melange.ppx)))
Every subfolder of api/
is a route, e.g., api/joke/
, and requires its own dune
file, which is then referenced in the api/dune
file.
Writing a dune
file for each route (and page) is not hard, but it could get tedious really quickly if there are a lot of them.
One way to get around this is to modify the api/dune
file with (include_subdirs qualified)
; this means that every subdirectory of api/
can be referenced by module namespacing and we don't have to write dune
files for every route (or pages) folder. However, the OCaml LSP does not like it and red squiggles will show up in the editor (although the app with still compile without errors). Trying to develop the app knowing those red squiggles cannot be vanquished would drive me nuts, so instead of using (include_subdirs qualified)
I just wrote dune
files for every route (and page) which gets rid of the red squiggles.
Dune and the NextJS dev server are completely fine with this application and directory structure, and everything works during development; but when trying to build the app NextJS turns cantankerous.
Computer Says No
make
When creating a React component with Reason it is typical to use the word make
as the name of the function that will create the component. So the dogs page, for example, might look something like:
// src/app/dogs/page.re
[@react.component]
let make = () => {
<Dog />; // Dog component from src/app/components/Dog.re
};
In the output JS file created by Melange the export will be:
// ...
var make = Dog;
export { make };
But next build
will give a type error:
Type error: Page "src/app/dogs/page.js" does not match the required types of a Next.js Page.
"make" is not a valid Page export field.
Okay. Let's try changing make
to default
. Now the output JS has a default export:
// ...
var $$default = Page$default;
export { $$default, $$default as default };
But still next build
will give a type error:
Type error: Page "src/app/dogs/page.js" does not match the required types of a Next.js Page.
"$$default" is not a valid Page export field.
Deleting the extra $$default
from the export in the output JS file, so that the the export is just export { $$default as default };
, seems to fix the problem, but editing the output JS is a bad idea and not practical. I'm not sure why Melange adds the extra $$default
to the export but NextJS does not like it.
Routes
According to the NextJS docs routes have to export a function with the name of the HTTP method you want to use, e.g., export async function POST(request) {...};
. In Reason/OCaml we cannot use POST
as the name of a function because a word beginning with an uppercase letter is a constructor for a data type. To get around this we can we use bs.raw
to write some raw JavaScript in the Reason code:
// src/app/api/joke/route.re
let handler = request => {
/// ...
};
[%%bs.raw {|export const POST = handler|}];
And the output JS will be:
...
export const POST = handler
;
export {
decodeJokeCount , // JSON decoder function
decodeJoke , // JSON decoder function
handler ,
}
Will next build
let us pass??? No.
Type error: Route "src/app/api/joke/route.js" does not match the required types of a Next.js Route.
"decodeJokeCount" is not a valid Route export field.
Type error: Route "src/app/api/joke/route.js" does not match the required types of a Next.js Route.
"decodeJoke" is not a valid Route export field.
Type error: Route "src/app/api/joke/route.js" does not match the required types of a Next.js Route.
"handler" is not a valid Route export field.
Once again NextJS does not like the extra export fields. Using an interface file can restrict the exports and make decodeJokeCount
and decodeJoke
private; however, trying to make handler
private so that the only export is export const POST = handler;
is not possible, and trying to do so causes Melange to output an empty JS file.
Refactoring
To stop next build
from slapping us with type errors we have to wrap the Reason code with JavaScript, which means creating page.js
and route.js
files and importing the Reason code (the output JS code) into them. This is now a situation that is similar to the Astro project, and so now, rather than having many folders and many dune
files, all of the Reason code can be put into one folder. Because of this it also makes sense to promote the output JavaScript out of the _build
folder and back into the src
folder, which means that project files no longer have to be copied to the _build
folder.
Directives and bindings
"use client"
directive binding:
[@bs.config {flags: [|"--preamble", "\"use client\";"|]}];
Font and metadata bindings for the root layout:
[%%bs.raw "import './globals.css'"];
[%%bs.raw "import { Inter } from 'next/font/google'"];
[%%bs.raw "const inter = Inter({ subsets: ['latin'] })"];
type font;
type metadata = {
title: string,
description: string,
};
[@bs.val] external inter: font = "inter";
[@bs.get] external fontClassName: font => string = "className";
Since the Reason code is now wrapped in JS less bindings are required because they can just be written in the JavaScript file; therefore, bindings like those above are no longer needed.
The refactored code is the code in the main branch. The all Reason code version of the app can be found on the include-subdirs-qualified
branch.
Conclusion
Replacing all of the JavaScript in a NextJS App Router application with Reason is currently not possible. Reason can be used for the majority of the code but it will need to be wrapped in JS for it to be compatible with NextJS. I'm not sure why NextJS is so strict on what fields are exported. As long the fields NextJS needs are exported I don't see why other export fields are a problem, especially since they cause no problem during development. I also wish these problems would have showed up sooner during development instead of during building. There may be some Dune or NextJS configuration options that I have missed, but at this point I'm too tired and bored to try and figure it out.
Top comments (0)