Zero build step web apps for fun and profit

I wanted to note down the zero build step setup I’ve been using for SPA prototypes and hobby projects over the last year. I’ve found this combination of tools and trade offs keeps me productive and gives me peace of mind that I won’t return to a codebase hobbled by abandoned dependencies and breaking changes that often plagues the current Javascript ecosystem.

A zero build web app

One more todo app for good measure

I couldn’t write about web apps without building another To Do app to add to the heap. Check it out on GitHub: andrewbridge/example-zero-build-webapp

Components

I’ve yet to find another web framework that competes with Vue’s ease of access, and it certainly goes toe to toe with the other big frameworks once you’ve built out fairly complex SPAs. While Vue has considerably outgrown its client side template parsing as a default, I applaud the team’s continued support of this version of the framework.

With Vue loaded, creating an app only requires a few lines of code to get going

import { createApp } from "vue";

const App = {
    data: () => ({ message: 'Hello, World!' }),
    template: `<h1>{{message}}</h1>`
};

const root = document.getElementById('root');
root.innerHTML = '';
const app = createApp(App);
app.mount(root);

From here, plain objects work as components which can be split out into individual files and imported in. See src/index.mjs to see where the To Do app initialises and src/components/TodoList.mjs for an example of a component file.

On top of components, Vue 3 provides a totally independent reactivity API which makes building out your data model very easy. Once you’re ready to reflect your data in UI, Vue components naturally integrate well with this API allowing for fairly complex data flows to be visualised without a tonne of boilerplate.

Alternatives

  • Preact
    • Those less keen on Vue may be interested to delve into Preact’s brief mention of Alternatives to JSX which provides a zero-build, React flavoured experience
  • vue-petite and AlpineJS
    • Touted as minimal and the “jQuery for the modern web”, these should be perfect for my use case, but the heavy use of HTML attributes for component data makes these far better suited to server driven apps that need a helping hand.

Third party imports

In the code sample above, we import createApp directly from a package called vue. Previously this would’ve required a bundler to step in and smooth this over for browsers, but Import Maps now provide us a zero build alternative.

<script type="importmap">
        {
          "imports": {
            "vue": "https://unpkg.com/vue@3.2.37/dist/vue.esm-browser.prod.js",
            "goober": "https://unpkg.com/goober@2.1.10/dist/goober.esm.js"
          }
        }
</script>

In our index.html we can list all third party dependencies in one place, providing a name that we can use to import with instead of duplicating URLs.

Support for Import Maps is coming along, but be wary that Safari users or developers will be left out in the cold until the next iteration of macOS and Safari lands. Even worse, this is currently an unofficial specification so could disappear from browsers in the future.

Alternatives

  • Import browser friendly versions of libraries
    • Far from a new idea, many modern libraries still provide a build that will expose a global variable to interface with it. Adding these in with <script> tags will still work, but immediately returns developers to the days of worrying about whether these magic globals will exist at runtime and giving code editors a harder time to keep code safe
  • Use URL imports
    • A considerably newer approach would be to directly import libraries via their URL like import vue from 'http://mycdn.com/path/to/vue.mjs'. You’ll need to be in a script run with type="module" and if you ever need to change the URL, you’ll need to change it in every file that you’ve used the library
  • Use a deps directory
    • One technique I’ve used in other small projects is a deps directory which groups wrapper modules around the import statements for third party libraries. I’ve found a fair amount of success with this technique, especially given the wider browser support, but it does require every item imported from the library to first be imported in the wrapper module before it can be used in the rest of the project.

Scoped CSS

For small projects, a single stylesheets should suffice for the majority of your styles. In fact, most projects where I apply this zero build approach normally use Pico.css or Tabler to do the heavy lifting. But it can’t be denied that scoped CSS can be incredibly useful when you need to make component specific style adjustments.

I’ve settled on goober, a tiny scoped CSS implementation that does away with a lot of the opinions and bulk of the likes of styled-components and Emotion.

What I like most about goober is that I can actually write real CSS (no camelCased CSS-in-JS objects here unless you really want them):

// styles is a string like "go6kj345n" which can be applied to an HTML element's class attribute to apply the styles
const styles = css`
    align-items: center;
    padding: 8px var(--block-spacing-horizontal);
    margin-bottom: 16px;
`;

What’s more, goober supports nesting styles and parental effects:

const styles = css`
    /* This is a normal style rule */
    align-items: center;

    /* Nesting: Apply these styles to any element with the "child" class within the element these styles are applied to */
    & .child {
        font-size: 0.75em;
    }

    /* Parental effects: Only apply these styles to the element they're applied to if it's a child of an element with the "parent" class */
    .parent & {
        color: white;
    }
`;

Alternatives

  • The only real competitor here is using a style methodology such as BEM or CUBE CSS. These provide you with guidelines to compartmentalise CSS rules to the component level, ensuring you won’t see rules bleed and interfere with each other outside of your components. It’s Javascript free and it’s dead simple, but you’ll find yourself switching back and forth between files as you remember what rules need to apply where.

Developer experience

With everything above in place, we have a working zero-build application with component level styling, reactivity and fuss free dependency management. But what happens to the developer experience once you drop the build?

Typings

You won’t get Typescript support at all with this setup. Without a build step to check the typings and strip them away before they hit the browser, you’ll get syntax complaints or complicated, heavy client side parsing getting in the way of productivity.

But JSDoc comments allow us to provide typings into our code while ensuring our code remains valid Javascript.

/** @type {(name: string) => void} */
export const addTodo = (name) => {
    const id = Date.now();
    todos.set(id, { id, name, completed: false });
};
With JSDoc, your code editor can provide a typed signature for addTodo

If native types aren’t enough, JSDoc comments can also interact with a Typescript types file.

// src/app.d.ts
export interface ToDo {
    id: number;
    name: string;
    completed: boolean;
}

// src/services/data.mjs
/**
 * @typedef {import("../app").ToDo} ToDo
 */

/** @type {Map<ToDo['id'], ToDo>} */
export const todos = reactive(new Map());
Code editors now understand the ToDo complex type imported from the Typescript type file thanks to the JSDoc comment

You still aren’t going to get strict type checking and it’s hard to deny that the syntax is more verbose and less natural than what you may be used to in Typescript, but it’s a decent compromise to reach the zero-build goal.

Syntax highlighting

You may’ve been a bit horrified by the amount of markup and CSS shoved into strings when taking this approach. Vue’s object based components use template literal strings for their template argument, while goober is happy to take a string and parse any CSS it can find at runtime.

Here we begin to lean more heavily on the features of your code editor. JetBrains IDEs and VSCode can both be coerced to syntax highlight CSS and markup within template strings, though VSCode will require the es6-string-html extension to be installed. In both cases, you’ll find component markup displays with correct highlighting by prefixing the string with /* html */.

JetBrains IDEs will syntax highlight markup with a simple comment prefix

Meanwhile CSS will get automatic highlighting in VSCode because the string is passed to the css method. But JetBrains IDEs will need a little more help.

JetBrains IDEs are stricter with their highlighting, so you’ll need to add a comment of either // language=CSS or // language=SCSS depending on whether or not you’ll be using style nesting

Tradeoffs

I’ve thoroughly enjoyed the simplicity and lightweight nature of a zero-build approach over the last year, but it’s worth bearing in mind this is no replacement for any of the best practices across the web today. Writing SPAs this way:

  • Won’t ever give you strict type checking
  • Won’t ever run in a browser without Javascript
  • Is not good enough for production
    • SEO will hate your non-JS blank page
    • Users with flakey connections will hate your non-JS blank page
      • …although Service Workers might mitigate this (example)
    • Users with accessibility needs will hate your non-JS blank page

Resources