How I structure full stack web application with react and nodejs

folder structure, error handling pattern, deployment to ec2, etc

Steve Mu
10 min readSep 9, 2021

in this article, I am going to talk about how I usually architect full-stack web applications with react and nodejs.

I use following frameworks or tools: nx.dev, nextjs and express.

Nx is a monorepo management tool. It allows you to create react or express projects with shared libraries, all in one repo.

Nextjs is a great reactjs framework. Personally I prefer it to create-react-app. Besides the build-in server side rendering capability, it provides really nice routing framework. With nextjs, you don’t need react-router anymore, and for a good reason. Nextjs makes nested routes really easy, in my opinion. If you were to use react-router, you may need to some boilterplate code for nested routes. Nextjs solves this problems really nicely.

My backend stack is nodejs with express. Express has been around for a long time and it is easy to use. Another worthy framework to try is nestjs.com, which claimed to solve the provide architecture on top of express, but I haven’t got a chance to really use it in production.

Next, I am going to share folder structures, common patterns, and error handling patterns in both frontend and backend.

Nx

This is how I structure projects in one nx project. In the “apps” folder, I had a project called api, another called ui. The “api” project is created via nx cli and is a express app. The “ui” project is created via nx cli as well and is a Nextjs project.

In the “libs” folder, I have two libs. First one is “types”, it contains the TypeScript types that used by both frontend and backend, which are all written in TypeScript. The second one is “utils”, which contains utility functions can be used by both frontend and backend.

Backend

Next, let’s talk about how I structure the backend (express) app.

from top to bottom:

auth: contains authentication modules that are for updating user profile, functions for checking user roles etc.

routes: each file or folder in this folder defines a top-level route, such as “/post” or “/product”. Sub-routes for a top-level route are defined in the route file named by the top-level route. For example, post.ts would handle routes for GET /post/:id and POST /post.

If the a route file grows too big, convert it into a folder with a “services” sub folder. For example, in “/routes/user”, there is a file called route.ts and a “services” file that contains the “services” or functions the route.ts uses. This helps route.ts file to be more readable.

scripts: This folder contains some one-time scripts written in JavaScript or TypeScript. For example, if I make a change in schema design, usually I would create a script to go through each record in the collection and modify each record.

example script: this is a nodejs script for creating user objects in my own database from a 3rd party IAM service

types: this folder contains types used in the app. In this case, I want to extend the default express type to have a “user” field on the “req” object.

utils: utility functions that can be used by other parts of the app

db.ts: this file contains only one function “connectToDb” , it returns a Promise of connected db object that anywhere else in the app can import.

main.ts:

main.ts

main.ts is the entry file of the nodejs app.

It uses morgan to log api requests to console.

It imports routes on line 35–36.

On line 38, it provides a fall-back route so that if user make a request to a non-existing route, the backend would return an error response.

On line 43–58, it provides a generic error handler so that if any error thrown in the routes, it would be caught here. The error handle would check for the error instance, and then return appropriate status code. I am going to talk about this error handling pattern next.

middleware.ts: this file contains authorization middlewares. Here are a few:

idTokenCheck middleware

idTokenCheck checks if a incoming request has a valid jwt token. It will 1. verify the token 2. if token is valid, it will grab the sub field, which is the user id and get the user object from the database and put it on req so that routes can access it.

If the token is not valid, it will return a 403 status code with an error response that says “unauthorized”.

Another middleware is roleCheck, which checks if a request is authorized for a endpoint.

roleCheck middleware

This middleware get the user by id from the database, then checks if the roles field on the user object contains the required user role. This is how to use it with the idTokenCheck middleware:

Here, we specify this endpoint to require “admin” role to be in the user object in the database.

Error handling in backend (expressjs)

First, to normalize the error response and to send error messages in consistent format, I created a ErrorResponse class.

ErrorResponse class

Then, whenever the backend needs to send a error response to UI, it can construct a error object like new ErrorResponse(message)`, below is an example where it is used in a route:

Another use-case of error handling is when the route needs to throw an error with a custom status code to tell UI what went wrong.

So I created an ApiError class that can be thrown anywhere in any routes:

Then throw it in any route:

whenever the error is thrown, it will be caught in the generic error handler. Then the backend will extract the status code and message from ApiError instance and return a json with the status code.

a sample route file:

routes/user/route.ts

This is to show you how you can structure a route file. On line 3, I import express-async-errors to have errors thrown in the async handlers to be caught in the generic error handler in main.ts. At the bottom, I export the router as user so main.ts can see the name of the route.

Frontend

Here is how I structure frontend (Reactjs with Nextjs) app:

api: contains fetch wrapper with url coded in the function so we have all urls defined in this folder.

example:

user.ts

the getRequest is a fetch wrapper that takes care of constructing the full url with the root url which depend on environment, grab jwt token from local storage, takes care of JSON.stringifyof the request body, and throw errors if received a non-200 status response. There is code:

`makeUrlWithRoot` handles concatenating the root url with the pathname:

in addition, whether to use production protocol and host is determined by the environment variable NEXT_PUBLIC_USE_PROD_API. You just need to set this environment variable when running the build like this:

dev:ui for running the dev build. start:ui is for running the production build. Notice NEXT_PUBLIC_USE_PROD_API is false for dev:ui, but true for start:ui.

validateResponse function will throw error depend on the status code so that UI can check for a particular error type and show corresponding error messages. The fallback would be to throw a generic Error object with “message” and “error” fields from the response.

This is the code for ApiError409Conflict :

components: reusable react components

constants: constants, such as local storage keys and keys of 3rd libraries.

data: this folder contains React Query wrappers. This is an example:

pages can import usePosts to get a list of posts, in this case.

React Query is a great library to manage server states in UI. I used redux before, but React Query is way better. It is probably the best library out there for this purpose. I strongly recommend to use React Query for caching server states. Kent Dodds have a post about it here.

pages: this folder contains pages like the name suggests. Nextjs will generate routes based on folder structure and page names automatically.

util: this folder contains utilities functions that can be used in other parts of the application

Error handling in front end

Make sure errors thrown in front end are caught by the custom _error.tsx page (which is equivalent to the error boundary page is a non-nextjs react app). The exception is in event handlers.

Enable useErrorBoundary option for useQuery so as to make sure errors thrown when making api calls are caught by the custom _error.tsx page.

This is my _error.tsx:

_error.tsx

this error page will show the error were thrown, and provide a Log Out button to clear the jwt token stored in local storage that may have expired.

Deployment

I find it is easy to use to 1. build locally 2. copy built artifacts to ec2 via ssh 3. restart process with pm2 on ec2

build locally

build:api : This is for building the api app. It will save the built artifact to dist/apps/api. Be aware you may want to stop the development server while running this script because I think the dev server will save the development artifact to the same folder and will overwrite the production artifact.

build:ui : This is for building the ui app. It sets NEXT_PUBLIC_USE_PROD_API to be true because this is production version and it should use the production api as well.

build:ui:localApi : This is building the production version of the UI app but make it request the api running locally. This is mainly for testing locally the production version of the UI.

build: This uses previous written scripts to build both api and UI apps.

testing production version locally

start:ui will start the production version of the UI app. You must have it built first using one of the build script first.

start:ui:localApi is for running production version of the UI app and have it connect to the api server running locally. The NEXT_PUBLIC_USE_PROD_API is set to false while running the npx nx run ui:serve:production command because Nextjs would render pages server side, and so we want let it nodejs environment know it too, in addition to setting this env when building the artifact.

start:api:local is for running the production version of the api locally.

deploying to ec2

deploying to ec2 involves coping built artifact to ec2 via scp.

build:upload:api build and upload the built files for the api app.

build:upload:ui build and upload the built files for the UI app.

build:uploadutilizes previous two scripts to upload to both api and UI app.

redeploy:api build, upload and restart the api app on ec2 with pm2

redeploy:ui build, upload and restart the UI app on ec2 with pm2

redeploy runs previous two scripts together.

Summary

In this article, I shared how I structure a full-stack application, including folder structure and error handling patterns. I also shared some example code that I am using in my applications. Finally, I shared some deployment scripts for deploying the full-stack app to a ec2.

Hopefully those pattern and code could help you build your application better or get started on a better ground.

--

--