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.
git clone https://github.com/larrycustodio/fullstack-frontend-build-tools.git
cd fullstack-frontend-build-tools
yarnThat'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 addcommand that would install that tool, so you learn which package provides what — you just don't have to wait for it.
node --version # 18+ (see .nvmrc)
yarn --version # 1.22+ or 4.xSteps 0–6 need nothing but Node. Step 7 needs Canopy registry access:
- Root certs and local overrides — https://canopy.wiki/books/front-end/page/setting-up-root-certs-allowing-local-overrides
- 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 setupnpmScopes:
canopytax:
npmRegistryServer: "https://npm.pkg.github.com"
npmAuthToken: "YOUR_AUTH_TOKEN"No npmScopes block? Fix it before step 7, not during it.
| 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 lintGrab whichever file you're missing and git checkout master to rejoin.
| 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.
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.
Frank's is growing and different squads own different pieces. Give them real files.
mkdir srcsrc/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.
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 upnode_modulesand figure it out." The browser has no filesystem to walk. Somebody has to do that resolution ahead of time. That somebody is a bundler.
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 buildPoint the page at the bundle — a plain script again, not a module:
<script src="build/app.js"></script>Reload. Works. lodash resolved and bundled.
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 everyimport. Resolved"./menu.js"to a path and"lodash"by walkingnode_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.
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 buildERROR 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.
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 buildWorks.
npx babel src/app.jsxYour 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.
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 buildIt builds. You never configured TypeScript. Remember that.
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 numberyarn buildIt builds fine. Babel deleted the annotations without reading them. Ship it and
toFixed is not a function happens at the counter.
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 typessrc/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 failyarn build, onlyyarn types.If
yarn typesisn't in CI, your types are documentation.
Fix the bug, re-run, get silence.
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 typesBoth pass. Every line above is legal TypeScript that runs.
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 automaticallySeverity 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.
Mangle a file's formatting on purpose — inconsistent quotes, random indentation, missing semicolons. Then:
yarn format
git diffEverything 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.)
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-loader401 or 404? Your
@canopytaxscope 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];yarn analyze-devDumps 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.
| 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.
- https://github.com/CanopyTax/canopy-webpack-config
- https://github.com/CanopyTax/babel-preset-canopy
- https://github.com/CanopyTax/eslint-config-canopy
| 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.