Creating a web application using Yarn workspace, TypeScript, esbuild, React, and Express (part 3)

Posted on March 13, 2021

This article will guide you through setting up a basic web application using Yarn's workspace, TypeScript, esbuild, Express, and React. At the end of this tutorial you will have a fully buildable and deployable web application.

  1. Setting up the project (part 1)
  2. Adding code (part 2)
  3. Building the app (part 3)
    1. Bundlers
    2. Build
    3. Serve
    4. Summary
    5. What's next?
  4. Going further (advanced)

Want to see the full code? Check out the repository on GitHub at halftheopposite/tutorial-app.


In this third and final part, we will implement the scripts used to build and launch our application.

Bundlers

To transpile our TypeScript code into interpretable JavaScript, and to bundle all external libraries into a single file, we will be using a bundler tool. Many bundlers are available in the JS/TS ecosystem, such as WebPack, Parcel, or Rollup, but the one that we will be choosing is esbuild. In comparison to others bundlers, esbuild comes with many features loaded by default (TypeScript, React) and has huge performances gains (up to a hundred times faster). If you're interested in knowing more, please take a time to read this FAQ by the author.

The scripts will require the following dependencies:

  • esbuild is our bundler
  • ts-node is a REPL for TypeScript that we will use to execute our scripts

From the project's root, run: yarn add -D -W esbuild ts-node.

package.json

{
  "name": "my-app",
  "version": "1.0",
  "license": "UNLICENSED",
  "private": true,
  "workspaces": ["packages/*"],
  "devDependencies": {
    "esbuild": "^0.9.6",
    "ts-node": "^9.1.1",
    "typescript": "^4.2.3"
  },
  "scripts": {
    "app": "yarn workspace @my-app/app",
    "common": "yarn workspace @my-app/common",
    "server": "yarn workspace @my-app/server"
  }
}

Build

We now have all the tools needed to build our application, so let's create our first script.

Start by creating a new folder named scripts/ at the project's root.

Our script will be written in TypeScript and executed with ts-node from the command line. Although a CLI exists for esbuild, it is more convenient to use the library through JS or TS if you want to pass more complex parameters or to combine multiple workflows together.

Create a build.ts file inside the scripts/ folder and add the code below (I will explain what the code does through comments):

scripts/build.ts

import { build } from 'esbuild';

/**
 * Generic options passed during build.
 */
interface BuildOptions {
  env: 'production' | 'development';
}

/**
 * A builder function for the app package.
 */
export async function buildApp(options: BuildOptions) {
  const { env } = options;

  await build({
    entryPoints: ['packages/app/src/index.tsx'], // We read the React application from this entrypoint
    outfile: 'packages/app/public/script.js', // We output a single file in the public/ folder (remember that the "script.js" is used inside our HTML page)
    define: {
      'process.env.NODE_ENV': `"${env}"`, // We need to define the Node.js environment in which the app is built
    },
    bundle: true,
    minify: env === 'production',
    sourcemap: env === 'development',
  });
}

/**
 * A builder function for the server package.
 */
export async function buildServer(options: BuildOptions) {
  const { env } = options;

  await build({
    entryPoints: ['packages/server/src/index.ts'],
    outfile: 'packages/server/dist/index.js',
    define: {
      'process.env.NODE_ENV': `"${env}"`,
    },
    external: ['express'], // Some libraries have to be marked as external
    platform: 'node', // When building for node we need to setup the environement for it
    target: 'node14.15.5',
    bundle: true,
    minify: env === 'production',
    sourcemap: env === 'development',
  });
}

/**
 * A builder function for all packages.
 */
async function buildAll() {
  await Promise.all([
    buildApp({
      env: 'production',
    }),
    buildServer({
      env: 'production',
    }),
  ]);
}

// This method is executed when we run the script from the terminal with ts-node
buildAll();

The code is pretty self-explanatory, but if you feel that you're missing pieces you can have a look at esbuild's API documentation to have a complete list of keywords.

Our build script is now complete! The last thing we need to do is to add a new command into our package.json to conveniently run the build operations.

{
  "name": "my-app",
  "version": "1.0",
  "license": "UNLICENSED",
  "private": true,
  "workspaces": ["packages/*"],
  "devDependencies": {
    "esbuild": "^0.9.6",
    "ts-node": "^9.1.1",
    "typescript": "^4.2.3"
  },
  "scripts": {
    "app": "yarn workspace @my-app/app",
    "common": "yarn workspace @my-app/common",
    "server": "yarn workspace @my-app/server",
    "build": "ts-node ./scripts/build.ts" // Add this line here
  }
}

We can now run yarn build from the project's root folder to launch the build process everytime you make changes to your project (we will see how we can add hot-reloading in another post).

Structure reminder:

my-app/
├─ packages/
├─ scripts/
│  ├─ build.ts
├─ package.json
├─ tsconfig.json

Serve

Our application is built and ready to be served to the world, and we just need to add a last command to our package.json:

{
  "name": "my-app",
  "version": "1.0",
  "license": "UNLICENSED",
  "private": true,
  "workspaces": ["packages/*"],
  "devDependencies": {
    "esbuild": "^0.9.6",
    "ts-node": "^9.1.1",
    "typescript": "^4.2.3"
  },
  "scripts": {
    "app": "yarn workspace @my-app/app",
    "common": "yarn workspace @my-app/common",
    "server": "yarn workspace @my-app/server",
    "build": "ts-node ./scripts/build.ts",
    "serve": "node ./packages/server/dist/index.js" // Add this line here
  }
}

Since we are now dealing with pure JavaScript we can use the node binary to launch our server. So go on and run yarn serve.

If you look at the console you'll see that the server is successfully listening. You can also open a web browser and navigate to http://localhost:3000 to display your functional React application 🎉!

If you'd like to change the port at runtime, you can launch the serve command by prefixing it with an environment variable: PORT=4000 yarn serve.

Summary

If you've read this far, then congratulations on completing your modern web application! If you still have questions or feedback on this serie of articles, please do not hesitate to reach out.

What's next?

Some things that we will explore in an appendix post:

  • Creating a containerized application with Docker
  • Adding CI with GitHub Actions
  • Deploying our container to Heroku
  • Adding a hot-reloading mode with esbuild

Like this article?

Have some questions? Email me at contact[at]halftheopposite.dev.