Skip to content

Repository files navigation

Fullstack Frontend Build Tools

Workshop template for The Frontend Toolchain for Backend Engineers.

You start with three files and no tooling, break the app on purpose, and wire up one tool at a time to fix exactly one problem. At the end you swap your hand-rolled configs for Canopy's shared packages and see they're the same thing.

Nothing here is magic by the end. That's the whole point.


Boot it up

git clone https://github.com/larrycustodio/fullstack-frontend-build-tools.git
cd fullstack-frontend-build-tools
yarn

That's it. Every dependency for all eight steps is already in package.json, so you install once and never wait on the network again during the session.

Then open index.html in your browser. It works — no build step involved yet.

Why are the packages already installed if the workshop is about installing them? Because watching a progress bar isn't the lesson. Each step still shows you the yarn add command that would install that tool, so you learn which package provides what — you just don't have to wait for it.

Prereqs

node --version    # 18+  (see .nvmrc)
yarn --version    # 1.22+ or 4.x

Steps 0–6 need nothing but Node. Step 7 needs Canopy registry access:

  1. Root certs and local overrides — https://canopy.wiki/books/front-end/page/setting-up-root-certs-allowing-local-overrides
  2. Yarn/npm scopes for @canopytax — https://canopy.wiki/books/front-end/page/yarn-npm-scopes-for-at-canopytax

Verify:

cat ~/yarnrc.yml     # or ~/.yarnrc.yml, depending on your setup
npmScopes:
  canopytax:
    npmRegistryServer: "https://npm.pkg.github.com"
    npmAuthToken: "YOUR_AUTH_TOKEN"

No npmScopes block? Fix it before step 7, not during it.


Branches

Branch What's on it
master The template. Baseline files, all deps declared, no configs — you write those.
solution The finished result. Every config, React + TypeScript source, working build.

Fell behind, or want to see where this is going?

git checkout solution
yarn build && yarn types && yarn lint

Grab whichever file you're missing and git checkout master to rejoin.


The eight steps

Step You wire up Because
0 nothing Establish what the browser actually accepts
1 nothing Split into modules — watch it break
2 webpack The browser can't resolve import { sumBy } from "lodash"
3 babel You want JSX and TypeScript. Neither is JavaScript.
4 typescript Babel deleted your types without checking them
5 eslint The build and the type checker both pass on code that's plainly wrong
6 prettier Your diffs are 80% whitespace
7 Canopy packages All of the above, already decided for you

Every command in order is in CHEATSHEET.md. Keep it in a second window.


Step 0 — Three files, no build step

Already in the repo: index.html, styles.css, app.js.

Open index.html in your browser and click the button. It works.

What just happened. The browser fetched three files, built a DOM tree from the HTML, a CSSOM from the CSS, ran the JS, painted, and then sat in an event loop. There is no build step. This is the whole platform.

Look at app.js: every name in it is a global. That's the problem we start with.


Step 1 — Split into modules, and watch it break

Frank's is growing and different squads own different pieces. Give them real files.

mkdir src

src/money.js

export function formatPrice(amount) {
  return "$" + amount.toFixed(2);
}

src/menu.js

export const MENU = [
  { id: "classic", name: "Classic Dog", price: 3.5 },
  { id: "chili", name: "Chili Dog", price: 5.0 },
  { id: "veggie", name: "Veggie Dog", price: 4.25 },
];

src/app.js — move app.js here and rewrite it:

import { MENU } from "./menu.js";
import { formatPrice } from "./money.js";

const sellButton = document.getElementById("sell");
const tally = document.getElementById("tally");
const sold = [];

sellButton.addEventListener("click", () => {
  sold.push(MENU[0]);
  const total = sold.reduce((sum, item) => sum + item.price, 0);
  tally.textContent = sold.length + " dogs sold — " + formatPrice(total);
});

Point the page at it as a module this time:

<script type="module" src="src/app.js"></script>

Reload. Still works. Native ES modules, zero tooling — and the two squads can no longer collide, because each file has its own scope.

Now use a dependency

lodash is already installed. In src/app.js, replace the hand-rolled sum:

import { sumBy } from "lodash";
// ...
const total = sumBy(sold, (item) => item.price);

Reload. Open the console.

Uncaught TypeError: Failed to resolve module specifier "lodash".
Relative references must start with either "/", "./", or "../".

Why. "./menu.js" is a URL the browser can fetch. "lodash" is a bare specifier — a Node convention meaning "walk up node_modules and figure it out." The browser has no filesystem to walk. Somebody has to do that resolution ahead of time. That somebody is a bundler.


Step 2 — webpack

Already installed. For reference, this is what put it there:

yarn add -D webpack webpack-cli

Create webpack.config.js — note it's CommonJS, because Node tooling still is:

const path = require("path");

module.exports = {
  // 1 - where the dependency graph starts
  entry: "./src/app.js",

  // 2 - where the bundle lands
  output: {
    path: path.resolve(__dirname, "build"),
    filename: "app.js",
    clean: true,
  },

  // Map the bundle back to your original source in devtools
  devtool: "source-map",
};
yarn build

Point the page at the bundle — a plain script again, not a module:

<script src="build/app.js"></script>

Reload. Works. lodash resolved and bundled.

Look at what you got

ls -la build/

Open build/app.js. Your code plus every module you imported, wrapped in functions and keyed by ID, with a small runtime on top. import became a lookup in a registry object.

What webpack did. Started at src/app.js. Read every import. Resolved "./menu.js" to a path and "lodash" by walking node_modules. Recursed until nothing was unresolved. Emitted one file.

Closest thing you've used: a linker, or PyInstaller.

Try this: add an export to src/menu.js that nobody imports. Build with --mode production, then --mode development, and compare sizes. Production tree-shakes the unused export away.


Step 3 — babel

You want to build the menu with React instead of document.getElementById.

React is already installed. Rename src/app.js to src/app.jsx:

import { createRoot } from "react-dom/client";

function App() {
  return <h1>Frank's Hotdog Stand</h1>;
}

createRoot(document.getElementById("root")).render(<App />);

Update entry to "./src/app.jsx" and put <div id="root"></div> in your HTML.

yarn build
ERROR in ./src/app.jsx
Module parse failed: Unexpected token (4:9)
You may need an appropriate loader to handle this file type.

Why. JSX is not JavaScript. webpack's parser is a JavaScript parser. It has no idea what <h1> is doing in the middle of a return statement.

Wire up the compiler

Already installed:

yarn add -D @babel/core @babel/preset-env @babel/preset-react \
            @babel/preset-typescript babel-loader

babel.config.js

module.exports = {
  presets: [
    [
      "@babel/preset-env",
      {
        targets: "defaults",
        modules: false, // leave import/export alone so webpack can tree-shake
        bugfixes: true,
      },
    ],
    ["@babel/preset-react", { runtime: "automatic" }],
    "@babel/preset-typescript",
  ],
};

Add to webpack.config.js:

  resolve: {
    extensions: [".tsx", ".ts", ".jsx", ".js"],
  },

  module: {
    rules: [
      {
        test: /\.[jt]sx?$/,
        exclude: /node_modules/,
        use: "babel-loader",
      },
    ],
  },
yarn build

Works.

See the transform with your own eyes

npx babel src/app.jsx

Your JSX comes back as _jsx("h1", { children: "..." }) function calls. That's all JSX ever was.

Try this: change targets: "defaults" to targets: "ie 11", run npx babel again, and watch arrow functions and const disappear. Then put it back.

Now TypeScript

Rename src/menu.js → src/menu.ts and add types:

export interface MenuItem {
  id: string;
  name: string;
  price: number;
}

export const MENU: MenuItem[] = [
  { id: "classic", name: "Classic Dog", price: 3.5 },
  { id: "chili", name: "Chili Dog", price: 5.0 },
  { id: "veggie", name: "Veggie Dog", price: 4.25 },
];
yarn build

It builds. You never configured TypeScript. Remember that.


Step 4 — typescript

Introduce a real bug. Rename src/money.js → src/money.ts:

export function formatPrice(amount: number): string {
  return `$${amount.toFixed(2)}`;
}

…and call it wrong somewhere:

formatPrice("3.50"); // a string, not a number
yarn build

It builds fine. Babel deleted the annotations without reading them. Ship it and toFixed is not a function happens at the counter.

Wire up the checker

Already installed:

yarn add -D typescript @types/react @types/react-dom @types/lodash

tsconfig.json

{
  "compilerOptions": {
    "noEmit": true,
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "isolatedModules": true,
    "resolveJsonModule": true,
    "allowJs": true
  },
  "include": ["src"],
  "exclude": ["node_modules", "build"]
}
yarn types
src/app.tsx:12:14 - error TS2345: Argument of type 'string' is not
assignable to parameter of type 'number'.

The load-bearing line is "noEmit": true. tsc is a linter here — it produces no files. Babel owns output; tsc owns opinions. Which is exactly why a type error does not fail yarn build, only yarn types.

If yarn types isn't in CI, your types are documentation.

Fix the bug, re-run, get silence.


Step 5 — eslint

Plant a few problems in src/app.tsx:

import { sumBy } from "lodash";
import { sumBy } from "lodash"; // duplicate

const unusedThing = 42;

console.log("debugging");

if (revenue == NaN) return null;
yarn build && yarn types

Both pass. Every line above is legal TypeScript that runs.

Wire up the linter

Already installed:

yarn add -D eslint @eslint/js typescript-eslint globals

eslint.config.mjs — flat config, just an array, later entries win:

import js from "@eslint/js";
import tseslint from "typescript-eslint";
import globals from "globals";

export default [
  { ignores: ["build/**", "node_modules/**"] },

  js.configs.recommended,
  ...tseslint.configs.recommended,

  {
    files: ["**/*.{js,jsx,ts,tsx}"],
    languageOptions: {
      ecmaVersion: "latest",
      sourceType: "module",
      parserOptions: { ecmaFeatures: { jsx: true } },
      globals: { ...globals.browser },
    },
    rules: {
      "no-console": ["error", { allow: ["warn", "error", "info"] }],
      "use-isnan": "error",
      "no-const-assign": "error",
    },
  },
];
yarn lint          # every planted problem, with a rule name
yarn lint --fix    # some of them, fixed automatically

Severity is a workflow decision. "warn" prints and exits 0 — CI still passes, good for migrations. "error" exits 1 and blocks — for things that are actually wrong.


Step 6 — prettier

Mangle a file's formatting on purpose — inconsistent quotes, random indentation, missing semicolons. Then:

yarn format
git diff

Everything snaps to one style.

Prettier can't break your code. It parses to an AST, throws your original text away entirely, and prints a fresh document from the tree. Your formatting was never data.

This is why it has almost no options, and why you shouldn't fight it. It's black.

Turn on format-on-save in your editor now and you'll never think about it again. (.vscode/extensions.json in this repo recommends the ESLint and Prettier plugins.)


Step 7 — the reveal: Canopy's shared configs

Look at what you've accumulated:

webpack.config.js     ~30 lines
babel.config.js       ~18 lines
eslint.config.mjs     ~28 lines
tsconfig.json         ~16 lines

Every Canopy service needs those same decisions. So we packaged them.

yarn add -D canopy-webpack-config babel-preset-canopy eslint-config-canopy \
            @babel/runtime @babel/plugin-transform-runtime babel-loader

401 or 404? Your @canopytax scope isn't configured — see Prereqs above. If you're stuck, just read the diff; the point is what these replace.

webpack.config.js — 30 lines becomes:

const canopyWebpackConfig = require("canopy-webpack-config");

module.exports = canopyWebpackConfig("orders-ui", {}, { typescript: true });

babel.config.js — 18 lines becomes:

module.exports = {
  presets: ["babel-preset-canopy"],
};

eslint.config.mjs — 28 lines becomes:

import canopyConfig from "eslint-config-canopy";

export default [...canopyConfig];

Prove it isn't magic

yarn analyze-dev

Dumps the fully merged config — Canopy's defaults plus your overrides, the whole object. Those three lines expand back into everything you hand-rolled, and then some.

Add this script to every service you work on. When a loader isn't firing and you can't tell why, start here — not in the wrapper's source.

What you get on top of what you wrote

Package Adds beyond your hand-rolled version
canopy-webpack-config AMD output for single-spa, the externals list so React is shared rather than bundled per app, bundle analyzer wired to --env analyze=…, HTTPS dev server, build/ conventions
babel-preset-canopy browserslist-config-canopy targets, transform-runtime helper dedupe, babel-plugin-webpack-import-ignore for @canopytax/*
eslint-config-canopy React + hooks + import + TS plugins, Canopy runtime globals (SystemJS, __webpack_require__), and six custom canopy/* rules that encode platform knowledge

Read the source. canopy-webpack-config/src/canopy-webpack-config.js is about 180 lines and you can now follow every one of them.


Which tool do I blame?

What you're looking at Culprit Where to look
Module not found: Can't resolve './Foo' webpack Path, or missing extension in resolve.extensions
Failed to resolve module specifier "react" (browser) runtime An external isn't being supplied — import map, not your build
Module parse failed: Unexpected token babel File isn't matching the babel-loader test, or is excluded
Type 'X' is not assignable to type 'Y' tsc Only yarn types and your editor produce these. Never your build.
Re-exporting a type requires 'export type' tsc isolatedModules — a real constraint of Babel's per-file compilation
'x' is defined but never used eslint Fix it, or narrow-disable with a reason
Diff is 400 lines of whitespace prettier Someone's editor isn't running it
Two copies of React / "invalid hook call" webpack externals — React got bundled instead of shared
"But it works locally" mode Dev vs production build. Reproduce with yarn build.

Companion to the slide deck frontend-toolchain-for-backend-engineers.html. Package versions here were chosen as known-good recent majors but have not been install-tested — run yarn && yarn build once before presenting.

About

Fullstack workshop for frontend build tools

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors