A simple stack for today's web hacks
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:
- Autoformatting. prettier is standard but clunky and is set up similarly to how other tools here have been set up. dprint is a newer replacement that I have liked more but it's newer and riskier.
- Linting. The current state of the ecosystem is a general mess, I suggest avoiding it.
- CSS languages. Too complex for my taste, possibly also because my projects tend to not be that visually complex.
- Web frameworks. This is a much more complex topic, worth an appendix!
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
:
<body></body>
<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!