DEV Community

Cover image for NextJS, the App Router and ReasonReact
Paul Bacchus
Paul Bacchus

Posted on

NextJS, the App Router and ReasonReact

TLDR

VyVyan cricket bat

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)))
Enter fullscreen mode Exit fullscreen mode

This effectively replicates the standard app directory structure in the _build folder, and on compilation the Reason files will be compiled to JS files:

build folder structure

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)))
Enter fullscreen mode Exit fullscreen mode

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.

dune files everywhere

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.

shocked gopher

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
};

Enter fullscreen mode Exit fullscreen mode

In the output JS file created by Melange the export will be:

// ...
var make = Dog;

export { make };
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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|}];
Enter fullscreen mode Exit fullscreen mode

And the output JS will be:

...
export const POST = handler
;

export {
  decodeJokeCount , // JSON decoder function
  decodeJoke ,      // JSON decoder function
  handler ,
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.

Refactored folder structure

Directives and bindings

"use client" directive binding:

[@bs.config {flags: [|"--preamble", "\"use client\";"|]}];
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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)