Bigi Lui
2021-01-24 ⋅ 17 min read

Clean monolith code repo structure for a small JAM web app

About a year ago I set out to start a new web app project, that would combine the uses of some of my favorite technologies that I've picked up over the past few years.

One of my main beefs with existing standards for modern web apps (typically in a server-side-rendered React stack) is how difficult the code repo's structure is to work with everyday. One of my specific goals in this project is to figure out a way to structure a web app, with both its backend and frontend, in a mono-repo (monolithic code repository), in a way that is easily understood and navigated.

I should emphasize that I also specifically picked to go with a JAM stack for this project, which means that this is certainly entirely not applicable for a server-side-rendered codebase (e.g. Next.js with SSR, etc.). The frontend is a statically-generated site.

The other emphasis I would make is that I'm going with a full Javascript codebase (so a frontend JS framework, with a node.js backend, instead of something like python or ruby). This allows me to share some code between backend and frontend, which is another goal of mine.

Folder structure

My JAM stack app folder structure
My JAM stack app folder structure

I figured the easiest way to understand the folder structure I want to describe is to visualize it as a tree from the code editor.

Immediately you'll notice a few things: There's only a simple layout of a backend folder, a frontend folder, and a small handful of other root level files and folders. We'll do a quick run-down of the important elements.

Naming

You'll immediately notice that instead of going for more technically-focused terms like client or server, I opted to go with a very English-centric vocabulary for the folders, i.e. just frontend and backend.

I wanted there to be no thinking required to immediately figure out how the codebase is laid out, no matter how many years I've been away from the project and not remember anything.

Using terms like client or server always gives me a double-take. If I don't remember that this is a JAM stack app at all, server makes me question whether it has anything to do with a server-side-rendered code, the server of the frontend site, or if it would be the API backend. (or even some other server-side scripts or configuration) Using backend instead makes it clear that it is indeed the API backend, and not anything else.

backend

As this is a JAM stack app, this is where the API server is. For my project, I chose to go with a Fastify server. The main thing to point out here is what is contained within the backend folder. In short, imagine if you were building a backend-only project (Pick an example Fastify project if you'd like). Simply, the entire repo of the backend project would belong within here, except package.json.

(Later we'll look at what the root level package.json contains and see how it is shared with frontend.)

frontend

I'll just come out and say I'm not a fan of React. I chose Svelte for the project (and I believe I'll choose this for every personal web project going forward), but whether you choose Svelte, Vue or React, the same concept should apply here.

Similar to backend, imagine you were building a frontend-only project. The entire repo of the frontend project would belong within here, again except for package.json.

lib

This is where backend and frontend shared modules are. In my case, I am building a simple card game. Some of the logic of the game belongs here. Later on, we'll take a look to see what a module that has different behavior on frontend and backend might look like here.

package.json

This probably what ties it all up. The important things here to look at are the scripts section and the dependencies.

  "scripts": {
    "check-format": "prettier --list-different './lib/**/*.js' './test/**/*.js'",
    "format": "prettier --write '*.js' './**/*.js'",
    "lint": "node ./node_modules/eslint/bin/eslint './lib/**/*.js' './test/**/*.js'",
    "test": "tap ./test/**/*.test.js",
    "backend": "node backend/index.js | pino-pretty -m message --ignore level,pid,hostname,v",
    "backend:raw": "node backend/index.js",
    "start": "node backend/index.js",
    "frontend": "node frontend/export-env.js && rollup -c -w",
    "frontend:serve": "sirv frontend/public",
    "build": "node frontend/export-env.js && rollup -c"
  },

The first four, check-format, format, lint, test are mostly standard scripts for use with any js projects. They are applicable for both frontend and backend source files. My tools of choice are prettier, eslint and tap which are pretty standard. I have a strong preference for tap over any other testing frameworks like mocha or jest for the simplicity.

The following scripts are more interesting. The backend scripts make it clear which part of the stack they are starting. The default backend starter script is what I use for development, and it pipes logs through pino-pretty to make it much easier to read. The generically named start script is to work with Heroku. I deploy my projects to Heroku so having the default server start script named start makes it a breeze to set up with no additional config needed.

The frontend scripts are similar: the main frontend script is what I use during local development (supports hot reload with rollup). The build step is for Netlify to run on deploy. The export-env.js is a small script that runs in order to take environmental variables I define on Netlify and write them into a file so the frontend bundle can use them. Needless to say this shouldn't contain any secret tokens or anything; but we can use these for any client-side public tokens we use (e.g. Google Analytics, etc.).

Dependencies

One notable thing here is that, every frontend-only package dependency in the project can simply be put in devDependencies. Because the app is a JAM stack one, the frontend is entirely statically-generated. The generation process happens during build-time, hence only devDependencies is needed.

For shared or backend-only dependencies, we would include the package in dependencies. This actually helps keep the list of dependencies very small for the backend app.

rollup.config.js changes

A default rollup config file that comes with a sample Svelte/Sapper project is typically good to start, only having to update the path to the frontend app itself. In my case, the relevant lines in the rollup config look like this:

{
  input: 'frontend/src/main.js',
  output: {
    sourcemap: true,
    format: 'iife',
    name: 'app',
    file: 'frontend/public/build/bundle.js'
  },
  plugins: [
    svelte({
      // enable run-time checks when not in production
      dev: !production,
      // we'll extract any component CSS out into
      // a separate file - better for performance
      css: css => {
        css.write('frontend/public/build/bundle.css');
      }
    }),
  ]
}

export-env.js for frontend

As mentioned before, this file allows us to take Netlify-configured environmental variables and put them into the bundle. For safety reasons, we specify exactly which env vars we want to take (so we don't accidentally add secrets to the Netlify configuration and expose them to clients):

'use strict';

/**
 * This script is for Netlify builds.
 * Netlify allows you to set up env vars, but they only exist at build time.
 * We run this as part of the build to generate a json file, which will then get
 * loaded for use in frontend.
 */

require('dotenv').config(); // for local builds with .env file

const fs = require('fs');

fs.writeFileSync(
  './frontend-env.js',
  `'use strict';\n\nmodule.exports = ${JSON.stringify({
    // The following list of env vars should match what's set on Netlify
    ENV: process.env.ENV,
    WEBSOCKET_URL: process.env.WEBSOCKET_URL,
  })};\n`
);

No packages folder, no separate npm packages, no lerna

This is really my big thing here. When I worked with a mono-repo web app at a job in the past, I hated the multiple packages setup with lerna. The lerna bootstrap process always takes way too long, and it was often error-prone, not knowing when you have to bootstrap again and such.

(And this is a small pet peeve of mine, but I hated how the root level of a frontend app using lerna would have a package.json and packages directory, which really messes with my tab-auto-complete on the command line.)

Here, all of my frontend code (Svelte components, or Vue/React components if you choose) is in frontend. Because I use Svelte and Rollup, I automatically get the benefit of tree-shakes, so even if my project grows large and I start having unused packages they won't pollute the bundle.

Because I'm not doing server-side-rendering, there is generally little "isomorphic" code. The only shared code I have, is explicitly and intentionally placed into the lib folder at root so I know they are shared; and there's no mistake. These are typically functional modules with logic only.

Example shared module with different behavior on frontend and backend: logger.js

One of the modules in my lib folder is the logger module logger.js. The reason I don't just simply have separate logger modules in frontend and backend is that I want to be able to use the logger in other lib shared code themselves as well. My logger.js module code looks like this:

'use strict';

const { ENV } = require('./constants');

if (typeof window === 'undefined') {
  // server: use pino
  const pino = require('pino');
  module.exports = pino({ timestamp: pino.stdTimeFunctions.isoTime });
} else {
  // client: logging only if dev
  const logFn = (level, obj) => {
    if (ENV === 'dev') {
      console.log(level, obj);
    }
  };
  module.exports = {
    fatal: (obj) => logFn('fatal', obj),
    error: (obj) => logFn('error', obj),
    warn: (obj) => logFn('warn', obj),
    info: (obj) => logFn('info', obj),
    debug: (obj) => logFn('debug', obj),
    trace: (obj) => logFn('trace', obj),
  };
}

There are several things going on here:

  1. I use typeof window === 'undefined' to detect for the global window object to determine if this is backend or frontend.
  2. On backend, I use the pino logger.
  3. On frontend, only in a dev build, it uses console.log. Otherwise, it's a no-op.

Conclusion

I'm not expecting this blog post to start a big movement away from the current startup best practice with file structures in mono-repo codebases. I'm merely introducing how I'm doing it, and perhaps you'd find it useful for your projects, find flaws and make improvements to it.

If there's any interests, I'd be happy to put together a sample project you can clone and run immediately with this structure, so you can play around with it more easily.