A simple stack for today's web hacks

December 10, 2022

Web development can be overwhelming, with frameworks and tools continually churning. Here's some advice that has worked well for me on my own projects which emphasizes simplicity, stability, and predictability. The recommendations I make here are only for tools that are high quality and which are unlikely to change significantly in the future.

Like most things I write on this blog, my intended audience is more or less "me if I didn't know this stuff already", so if you're say a C++ developer who isn't super familiar with nodejs etc and just wants to write a bit of TypeScript then this is the post for you. People have a lot of strong opinions about this stuff — to my mind, too strong, when a lot of the details really just don't matter that much, especially given how whimsical web fashion is — but if you are such a person then this post is certainly not for you!

To start with, we're not going to use any preconfigured template repository. Blog posts like this one seem to usually start with "copy my setup" but that feels like the opposite of what I want — I want the fewest moving parts possible and to be able understand what pieces I do use are for.

Instead we start from scratch:

$ mkdir myproject
$ cd myproject

Next, for frontend tooling, npm is inevitable. (Installing nodejs/npm is out of scope here since it's OS-dependent but it's trivial.) Make your project a root directory for npm dependencies, bypassing any questions:

$ npm init -y

This generates package.json. Most of its contents aren't necessary, feel free to edit.

With npm in place we install TypeScript. I think of TypeScript as the bare minimum for keeping sane writing JS and it's a self-contained dependency. We install a copy of TypeScript per project because we want the TypeScript compiler version pinned to the project, which makes it resilient to bitrotting as new TypeScript versions come out.

$ npm install typescript

This downloads the compiler into the node_modules directory, updates package.json, and adds package-lock.json, which records the pinned version of the compiler.

Next we mark the repository as a TypeScript root.

$ npx tsc --init

Note the command there is npx, which means "run a binary found within the local node_modules". tsc is the TypeScript compiler, and --init has it generate a tsconfig.json, which configures the compiler. The bulk of this file is commented out; the defaults are mostly fine. However, if you intend to use any libraries from npm (see below) you will need to switch it from the default, backward compatible module resolution to the more expected npm-compatible behavior by uncommenting this line:

"moduleResolution": "node",

At this point if you create any .ts files, VSCode etc. will type-check them as you edit. Running npx tsc will type-check and also convert .ts files to .js.

Create some trivial inputs to try it out:

$ echo "document.write('hello, world');" > main.ts
$ echo "<script src='main.js'></script>" > index.html

Unfortunately, TypeScript is only responsible for single file translation, and by default generates imports in a format compatible with nodejs but not browsers. Any realistic web project will involve multiple source files or dependencies and will require one last tool, a "bundler".

Typically this is where tools like webpack get involved and your complexity budget is immediately blown. Instead, I recommend esbuild, which is (consistent with the spirit of this post) minimal, self-contained, and fast.

So add esbuild as a dependency, downloading a copy into node_modules:

$ npm install esbuild

We invoke esbuild in two ways: to generate an output bundle and while developing. (You really only need the former if you're just willing to run it after each edit, but it's pretty easy to do both and it saves needing some other tools.)

esbuild has no configuration file; it's managed solely through command-line flags. The command to generate a single-file bundle from an input file will look something like this:

$ npx esbuild --bundle --target=es2020 --sourcemap --outfile=main.js main.ts

This generates a file main.js by crawling imports found in the given input file. The --sourcemap flag lets you debug .ts source in a browser. (You'll want to add *.js and *.js.map to your .gitignore.)

You can just stick this command in a shell script or Makefile, or you can stick it in your package.json in the scripts block:

"scripts": {
  "bundle": "esbuild --bundle --target=es2020 --sourcemap --outfile=main.js main.ts"

and invoke it via npm run bundle. (Note you don't need the npx prefix in the package.json command, it knows to find the binary itself.)

Finally, the other way to use esbuild while developing is to have it run a web server that automatically bundles when you reload the page. This means you can save and hit reload to get updated output without needing to run any build commands. It also means you will load the app via HTTP rather than the files directly, which is necessary for some web APIs (like fetch) to work.

The esbuild command here is exactly like the above with the addition of one flag:

$ npx esbuild [above flags] --servedir=.

It will print a URL to load when you run it. This web server serves index.html (and other files like *.css) verbatim, but specifically when the browser loads main.js it will manage converting it from TypeScript.

Note that the esbuild command does not run any TypeScript checks. If your editor isn't running TypeScript checking for you, you can still invoke npx tsc yourself (and on CI). If you do so, I suggest twiddling tsconfig.json to uncomment the

"noEmit": true,

line so that TypeScript doesn't emit any outputs — you want to use only one tool (esbuild) for this.

And with that, you're ready to go!

You might have some follow-up questions for recommendations which I will summarize in list form:

Appendix: web frameworks.

The above is all you need to get started, but commonly the next thing you might want is to use some sort of web framework. There are a lot of these and depending on how fancy you get the framework itself will dictate its own versions of the above tools. A lot of the churn in web development is around frameworks, so if you're looking to stay simple and predictable adopting any of them is probably not the path you want.

But if you're again looking to stay simple and predictable, I have been happy with Preact, which is an API-compatible implementation of the industry-dominant React framework that is only 3kb. Unlike the above recommendations, I would note there's more potential for churn if you depend on Preact. But one nice property of Preact in particular is that it's intended to be a simpler React so it has a combination of limited API (due to small size) and well-understood standard API.

To modify the above project to use Preact, you need to install it:

$ npm install preact

Rename main.ts into main.tsx and update the build commands to refer to the new path.

Tell TypeScript/esbuild to interpret the .tsx file as preact by changing two settings within tsconfig.json:

"jsx": "react-jsx",
"jsxImportSource": "preact",

For completeness, I'll change the source files to a preact hello-world. Add a body tag to index.html:

<script src='main.js'></script>

And call into preact from main.tsx:

import * as preact from 'preact';

preact.render(<h1>hello, world</h1>, document.body);

That ends up enough for most things I do, hope it works for you!