Going Buildless

The year is 2005. You're blasting a pirated mp3 of "Feel Good Inc" and chugging vanilla coke while updating your website.

It’s just a simple change, so you log on via FTP, edit your style.css file, hit save - and reload the page to see your changes live.

Did that story resonate with you? Well then congrats A) you’re a nerd and B) you’re old enough to remember a time before bundlers, pipelines and build processes.

Now listen, I really don’t want to go back to doing live updates in production. That can get painful real fast. But I think it’s amazing when the files you see in your code editor are exactly the same files that are delivered to the browser. No compilation, no node process, no build step. Just edit, save, boom.

There’s something really satisfying about a buildless workflow. Brad Frost recently wrote about it in “raw-dogging websites”, while developing the (very groovy) site for Frostapalooza.

So, how far are we away from actually working without builds in HTML, CSS and Javascript? The idea of “buildless” development isn’t new - but there have been some recent improvements that might get us closer. Let’s jump in.

The obvious tradeoff for a buildless workflow is performance. We use bundlers mostly to concatenate files for fewer network requests, and to avoid long dependency chains that cause "loading waterfalls". I think it's still worth considering, but take everything here with a grain of performance salt.

HTML

Permalink to “HTML”

The main reason for a build process in HTML is composition. We don’t want to repeat the markup for things like headers, footers, etc for every single page - so we need to keep these in separate files and stitch them together later.

Oddly enough, HTML is the one where native imports are still an unsolved problem. If you want to include a chunk of HTML in another template, your options are limited:

  • PHP or some other preprocessor language
  • server-side includes
  • frames?

There is no real standardized way to do this in just HTML, but Scott Jehl came up with this idea of using iframes and the onload event to essentially achieve html imports:

<iframe
    src="/includes/something.html"
    onload="this.before((this.contentDocument.body||this.contentDocument).children[0]);this.remove()"
></iframe>

Andy Bell then repackaged that technique as a neat web component. Finally Justin Fagnani took it even further with html-include-element, a web component that uses native fetch and can also render content into the shadow DOM.

For my own buildless experiment, I built a simplified version that replaces itself with the fetched content. It can be used like this:

<html-include src="./my-local-file.html"></html-include>

That comes pretty close to actual native HTML imports, even though it now has a Javascript dependency 😢.

Server-Side Enhancement

Permalink to “Server-Side Enhancement”

Right, so using web components works, but if you want to nest elements (fetch a piece of content that itself contains a html-include), you can run into waterfall situations again, and you might see things like layout shifts when it loads. Maybe progressive enhancement can help?

I’m hosting my experiment on Cloudflare Pages, and they offer the ability to write a “worker” script (very similar to a service worker) to interact with the platform.

It’s possible to use a HTML Rewriter in such a worker to intercept requests to the CDN and rewrite the response. So I can check if the request is for a piece of HTML and if so, look for the html-include element in there:

// worker.js
export default {
    async fetch(request, env) {
        const response = await env.ASSETS.fetch(request)
        const contentType = response.headers.get('Content-Type')

        if (!contentType || !contentType.startsWith('text/html')) {
            return response
        }

        const origin = new URL(request.url).origin
        const rewriter = new HTMLRewriter().on(
            'html-include',
            new IncludeElementHandler(origin)
        )

        return rewriter.transform(response)
    }
}

You can then define a custom handler for each html-include element it encounters. I made one that pretty much does the same thing as the web component, but server-side: it fetches the content defined in the src attribute and replaces the element with it.

// worker.js
class IncludeElementHandler {
    constructor(origin) {
        this.origin = origin
    }

    async element(element) {
        const src = element.getAttribute('src')
        if (src) {
            try {
                const content = await this.fetchContents(src)
                if (content) {
                    element.before(content, { html: true })
                    element.remove()
                }
            } catch (err) {
                console.error('could not replace element', err)
            }
        }
    }

    async fetchContents(src) {
        const url = new URL(src, this.origin).toString()
        const response = await fetch(url, {
            method: 'GET',
            headers: {
                'user-agent': 'cloudflare'
            }
        })
        const content = await response.text()
        return content
    }
}

This is a common concept known as Edge Side Includes (ESI), used to inject pieces of dynamic content into an otherwise static or cached response. By using it here, I can get the best of both worlds: a buildless setup in development with no layout shift in production.

Cloudflare Workers run at the edge, not the client. But if your site isn't hosted there - It should also be possible to use this approach in a regular service worker. When installed, the service worker could rewrite responses to stitch HTML imports into the content.

Maybe you could even cache pieces of HTML locally once they've been fetched? I don't know enough about service worker architecture to do this, but maybe someone else wants to give it a shot?

CSS

Permalink to “CSS”

Historically, we’ve used CSS preprocessors or build pipelines to do a few things the language couldn’t do:

  1. variables
  2. selector nesting
  3. vendor prefixing
  4. bundling (combining partial files)

Well good news: we now have native support for variables and nesting, and prefixing is not really necessary anymore in evergreen browsers (except for a few properties). That leaves us with bundling again.

CSS has had @import support for a long time - it’s trivial to include stylesheets in other stylesheets. It’s just … really frowned upon. 😅

Why? Damn performance waterfalls again. Nested levels of @import statements in a render-blocking stylesheet give web developers the creeps, and for good reason.

But what if we had a flat structure? If you had just one level of imports, wouldn’t HTTP/2 multiplexing take care of that, loading all these files in parallel?

Chris Ferdinandi ran some benchmark tests on precisely that and the numbers don’t look so bad.

So maybe we could link up a main stylesheet that contains the top-level imports of smaller files, split by concern? We could even use that approach to automatically assign cascade layers to them, like so:

/* main.css */
@layer default, layout, components, utils, theme;

@import 'reset.css' layer(default);
@import 'base.css' layer(default);
@import 'layout.css' layer(layout);
@import 'components.css' layer(components);
@import 'utils.css' layer(utils);
@import 'theme.css' layer(theme);

Design Tokens

Permalink to “Design Tokens”

Love your atomic styles? Instead of Tailwind, you can use something like Open Props to include a set of ready-made design tokens without a build step. They’ll be available in all other files as CSS variables.

You can pick-and-choose what you need (just get color tokens or easing curves) or use all of them at once. Open props is available on a CDN, so you can just do this in your main stylesheet:

/* main.css */
@import 'https://unpkg.com/open-props';

Javascript

Permalink to “Javascript”

Javascript is the one where a build step usually does the most work. Stuff like:

  • transpiling (converting modern ES6 to cross-browser supported ES5)
  • typechecking (if you’re using TypeScript)
  • compiling JSX (or other non-standard syntactic sugars)
  • minification
  • bundling (again)

A buildless worflow can never replace all of that. But it may not have to! Transpiling for example is not necessary anymore in modern browsers. As for bundling: ES Modules come with a built-in composition system, so any browser that understands module syntax…

<script src="/assets/js/main.js" type="module"></script>

…allows you to import other modules, and even lazy-load them dynamically:

// main.js
import './some/module.js'

if (document.querySelector('#app')) {
    import('./app.js')
}

The newest addition to the module system are Import Maps, which essentially allow you to define a JSON object that maps dependency names to a source location. That location can be an internal path or an external CDN like unpkg.

<head>
    <script type="importmap">
        {
            "imports": {
                "preact": "https://unpkg.com/htm/preact/standalone.module.js"
            }
        }
    </script>
</head>

Any Javascript on that page can then access these dependencies as if they were bundled with it, using the standard syntax: import { render } from 'preact'.

Conclusion

Permalink to “Conclusion”

So, can we all ditch our build tools soon?

Probably not. I’d say for production-grade development, we’re not quite there yet. Performance tradeoffs are a big part of it, but there are lots of other small problems that you’d likely run into pretty soon once you hit a certain level of complexity.

For smaller sites or side projects though, I can imagine going the buildless route - just to see how far I can take it.

Funnily enough, many build tools advertise their superior “Developer Experience” (DX). For my money, there’s no better DX than shipping code straight to the browser and not having to worry about some cryptic node_modules error in between.

I’d love to see a future where we get that simplicity back.

Permalink to “Links”

Webmentions

What’s this?
  1. Matt Wilcox
    @mxbck It’s good to see this stuff gaining traction. I tried burning npm and vite late last year. Had to go back; but only because browser support for nested css isn’t solid enough, and I refuse to code without it. Other than that; I will be going build less again ASAP. It was so much simpler.
  2. Max Böck
    @mattwilcox yeah nesting and bundling of partial files are the only reason I still use Sass. We're getting there though...
  3. Baldur Bjarnason
    Now the XML paths in browsers are barely maintained. Module JS in SVG, for example, basically doesn’t work. I encounter weird bugs in every browser’s SVG implementation any time I try to load a non-trivial illustration. I would actually love to be able to use XHTML occasionally for project where semantic extension made sense or where interop with EPUB was a plus. ????????‍♂️
  4. Baldur Bjarnason
    Whatever else you can say about XML, it’s a thousand times less annoying than TypeScript.
  5. elmuerte
    @baldur XML received a lot of bad publicity due to a few quite terrible applications of it. (And still does) The same nonsense arguments are still thrown around these days.Yes, XML is more verbose than JSON or YAML. But it does not have to be as horrible as the various enterprisey XML based standards people have seen.
Show All Webmentions (35)
  1. Shrutarshi Basu
    @baldur JSX is such an abomination.
  2. Max Böck
    @andre yes, any templating system (i.e. a static site generator or something) works of course, but I tried finding a solution that's as vanilla as possible.For design system components, adopting stylesheets directly in web components is also interesting...
  3. Tuxedo Wa-Kamen
    @baldur The only reason I have a "build step" is because I prefer TypeScript to JavaScript.As soon as type annotations are also supported in JavaScript, that step goes out of the window.
  4. supersole
    @mxbck that’s a nice summary! Thank you
  5. Rich Felker
    @mxbck Yes! Please!
  6. Aral Balkan
    @baldur I guess it’s technically just-in-time builds instead of buildless but the experience with Kitten is basically a buildless one (and one of the reasons why it uses JavaScript instead of TypeScript and HTML template strings instead of – *spit* – JSX.)https://kitten.small-web.org
  7. Brian LeRoux ????
    @mxbck you need to check out https://enhance.dev esp the showcase sites. No builds. Absolutely production ready. Enhance
  8. Miron
  9. Max Böck
    @brianleroux ????????????
  10. McNeely
    @mxbck I always wonder why I don't see more about import maps getting used just like this!
  11. mnmlst
    @baldur Heck, even SGML had includes. It also had custom elements and you could even define (and therefore validate) their content models and attributes.
  12. Kerfuffle
  13. The Other Brook
    @baldur I never really thought of myself as a dev. I was more like a DOM specialist, and had an extremely high level of expertise with XSLT. I watched that whole ecosystem collapse just because people with CS degrees couldn't be bothered to understand how it solved problems that were peripheral to their work, but central to the work of those of us who cared deeply about semantics, transformation, presentation, usability, and accessibility.Coming up with buildless includes for HTML is cool, but that's only a small part of what was lost when XML fell out of favor.
  14. Stanley Jones
    @mxbck I agree with your conclusion that we’re not quite there for production but I recently took exactly this approach for a hackathon and it was lovely.Anybody who hasn’t been following the progress browser APIs have made might be surprised and how much you can do “out of the box” these days.
  15. [email protected]
    @baldur A proper way to include HTML inside another static HTML document would be a real boon. It's possible to use <object> with HTML content, but then you have to specify the dimensions as pixel values which is a huge no-no for responsive designs. <object> also doesn't work inside <head>.What I want: Use the <html> tag inside any other HTML tag:<div id="navigation><html href="navigation.html"></html></div>
  16. Priit Pirita
    @mxbck we at Sleeknote have been build-less pretty much forever. Pretty straightforward and easy. Life is good :-) Right now we serve pure ES6 modules. Just minify in production, but that is just to get rid of comments.
  17. Phillip Upton
    @baldur I can’t get my head around concatenating js and css to reduce network requests and then wanting to downlaod individual html fragments.Sounds to me like the “build” phase should just create a static web page.
  18. Max Böck
    @atirip cool! Any insight on performance or cross-browser issues? Would love to read about actual production experience ????
  19. The Doctor
    @baldur Whatever just happened to just writing a web page?
  20. The Doctor
    @baldur (I say this knowing full week I use a static site generator.)
  21. tekhedd
    @baldur when I look at all the libraries I use, it feels like I'm the only person in the world who doesn't like TypeScript.
  22. Priit Pirita
    @mxbck Not much, it just works in all browsers. If anything I would recommend to use lots of dynamic loading as this is now easy and helps app load faster. Service Worker too is beneficial.On the other hand, majority of our code is written in house and we do not practice “modern fileitis”, our stuff is approx 500sh lines per file in average, that means way less files than usual.
  23. Priit Pirita
    @mxbck ah one thing - when using 3rd party code, we do have one “transpiling” thing, instead of import maps (pain), we just rewrite the import clauses, we add proper paths and extensions if needed.
  24. Carl James
    @mxbck I remember 1998 when I updated style in HTML 3 without CSS
  25. Joshua
    @andre @mxbck concatenating/bundling hasn't mattered 1/100th as much with http2 multiplexing. Indeed there are some advantages to no-concat, as browsers don't need to redownload unchanged files. Minifying is fine, but unminified compresses much better so you're not saving much. Most sites will see more kB variation from changing a jpg from a cat to a dog than they'd ever save from minifying.
  26. Joshua
    @McNeely @mxbck it's been pretty unstable for a while but has recently settled down. I tried going all-in with them a couple years ago and there were serious last-mile problems.
  27. Joshua
    @mxbck I was probably on jsx longer than anyone except Meta employees (started with its spiritual predecessor xhp on php), and I think JSX has long outlived its usefulness. You can support a declarative syntax with JS already. Half the code I see nowadays is just spreading object props in anyway.Creating an entire expanded syntax around calling one function is a big ask, so it needs to provide big value. It doesn't. You don't even need the end tags when your editor will highlight for you.
  28. Joshua
    @andre @mxbck I'm sure you do, don't take it personally. I'm only pointing out that many of the big advantages of doing a minify+concat don't add the same value they used to.
  29. Daniel Schulz
    @mxbck How can open the article saying Feel Good Inc is 19 years old and then expect me to pay attention to the rest? I'm busy having an existential crisis!