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
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:
- I use
typeof window === 'undefined'
to detect for the global window object to determine if this is backend or frontend. - On backend, I use the
pino
logger. - 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.