How to Build a PWA without a Build Step

No bundler? No problem.

A bundled notebook

Sometimes modern web development feels overly complex. When I build a static website I mostly write HTML and CSS, with a little bit of JavaScript. My project structure is straightforward and boring - just the way I like it.

But when I move beyond "static website" into "web app" or Progressive Web App (PWA) territory then things are no longer simple... Babel config files, TypeScript config files, Webpack or Rollup config files. Make a change, wait for it to build, then try and debug one giant bundle.js file. Surely there's a better way, right?

From Create React App to a "Buildless" PWA

Focus On This PWA

To figure out if building a PWA without any build step is feasible (enjoyable even?) I took an existing PWA that I had written using Create-React-App and converted it to be buildless. The app is rather simple, so I wouldn't say this is an exhaustive experiment, but it was revealing enough to identify some benefits and challenges with building a modern web app without a build step.

Component Framework

I heart webcomponents

The original version of my PWA was written with React using JSX. If I want to go buildless I can't use JSX since browsers can't directly render it. Moving away from JSX is fine with me because nowadays I prefer building web components over using any other component framework (like React). Someday I'll write a post about why I like web components so much, but it boils down to this: in general, I prefer something standards-based over a non-standard library/framework.

Great, so I'm going to build web components... but how? There are many libraries/tools to help build web components, and the ones I'm most familiar with are:

I think any of these are great options. However, Stencil and LWC have compilers to build their web components whereas LitElement is a base class that can run directly in the browser... sounds perfect if I'm going buildless.

// this runs in the browser, no build step needed 🎉🎉🎉
class FotApp extends LitElement {

Vanilla Web Components

One last comment regarding web components - I try to write any components that are commonly used across projects as standalone packages and build them without any frameworks/libraries/tools. Examples of these commonly used "base components" are side-drawer and wc-menu-button. The reason I like to write base components with vanilla JS is I can build them without any dependencies and be sure they are as small/lean as possible. You might think the development experience is painful using vanilla JS but ideally your base components are mostly just HTML/CSS, with a little bit of JS. Even if that little bit of JS is fairly boilerplate/low-level then I don't mind 🤷‍♂️.


My original version was using client-side routing via React Router. I could pull in a different client-side router that works with web components, or I could just simplify and not use client-side routing. No SPA? No problem thanks to service worker pre-caching. My service worker can pre-cache all my routes so when a user navigates to a new page it's instantly pulled from the local cache. And soon I can make page transitions even fancier/native-feeling while still keeping the simplicity of server-side routing thanks to View Transitions.

Type Checking

I love TypeScript and I figured this would be the deal breaker when it came to going buildless. I don't want to write un-typed JavaScript code like some savage 😛. But the .ts files won't just magically work in the browser, they have to be run through tsc or babel to strip types, right? That's true, but thankfully TypeScript has this really great feature where you can type check your JavaScript files via JSDoc-style comments. Much to my surprise, it all works pretty well (and it keeps getting better)! 💪💪💪


The first step to setting up type checking in JS code is to create a tsconfig.json file at my repo root.

As I found out, buildless doesn't necessarily mean you can escape config files 🙃

Then I set allowJs and checkJs to true. If I didn't want to type check every file I can set checkJs to false and then at the top of the files I do want to check I just include the comment // @ts-check. This is also a great way to slowly move a brownfield project to TypeScript.

Declaring Types

I won't regurgitate everything in the TypeScript handbook, but I will call out a few things I learned.

To declare an object's type I just use a JSDoc-style comment:

/** @type {number} */
const length;

To import type declarations from 3rd party libs in node_modules I just import them:

 * @typedef { import("side-drawer").SideDrawer } SideDrawer
 * @typedef { import("lit-element").LitElement } LitElement

In this example I'm defining a @typedef so I don't have to keep using import("lit-element").LitElement every time I need that type in code.

To declare that my custom element extends from LitElement I then use that imported typedef:

/** @extends {LitElement} */
class FotDrawer extends LitElement {...}

It's not quite as quick/easy/natural as setting types in .ts files, but I want type checking without a build step so it works 🚀


As I said earlier, I don't want my own code to be put into one big bundle.js file. However, I do want each of my app's dependencies to be bundled (not all dependencies as one bundle but one bundle per dependency). I don't have control of the project structure of my dependencies, and my dependencies shouldn't change very often. So how do I bundle my dependencies without a build step?

Using pika web I can have my dependencies bundled as ES modules at install time. I simply set the npm prepare script in my package.json

"prepare": "pika-web --clean --strict --dest public/web_modules/",

I also declare which dependencies are to be bundled as web modules in package.json:

"@pika/web": {
  "webDependencies": [

Now any time I run npm install my dependencies will be bundled and copied into the web_modules folder, and then my code can just directly import from the module in that folder:

import { LitElement, html, css } from "../../web_modules/lit-element.js";

Notice how the import uses a relative path to the actual JS file? No fancy module resolution here. I'm all for abstracting things when it makes sense, but I find this form of "module resolution" refreshingly simple 😁.


There is a lot I like about switching my web app over to be buildless:

Ironically, I am currently having trouble getting the React version of my app to build because one of the dependencies somewhere down in the transitive dependency tree is causing tsc to error out. It's probably not too hard to figure out what the issue is but it does highlight the fact that, in general, the more dependencies and configurations my apps have, the more potential for issues to crop up.


It's not all roses, though. Code bundlers weren't created just to add complexity, after all. They have real benefits. As with most things, there is nuance and trade-off. Here are some issues I noticed when switching over to buildless:

Final Verdict

I like the developer experience "pros" of not having a build step, but I don't like the production "cons"... particularly around tree-shaking and minifying. I also really missed workbox build.

I think some sort of middle-ground might be what I try next. My thought is I will basically go buildless during development, and then have a build step for staging and production sites. That "build step" will run workbox build and minify each file. I still won't have tree-shaking but if I am thoughtful about what dependencies I pull in (which I should always be thoughtful about that, bundle or no bundle) then maybe it won't be a big deal. I'll have to do some benchmarking to be sure, but this idea feels like a sort of healthy middle ground of simplicity and build-step benefits.

Hopefully you found this post helpful, if you have any questions you can find me on Twitter.

How to Build a PWA
How To Set Windows Terminal Starting Directory for WSL