More and more frequently, I’ve been working with little single-page applications that are built independently and then mounted into something more complex. Think of things like interactive calculators, multi-step forms, or other tools intended to plug into a bigger system, like a CMS.

Overall, it’s a useful approach. Rather than being required to mess with a clunky monolith in order to build out a small piece of it, we need to deal only with our little self-contained app, and when it’s ready, plug it into the monolith. Modern tooling makes this even easier. If you’re using something like Vite, it’s almost silly how easy it is to stand up a production-ready SPA with a snappy development experience. Despite the post-development build, publish, and mount steps required to get the tool where it’ll be used, the gains in development ergonomics are well-worth the trade-off.

The Inherent Challenge

But there are some noteworthy disadvantages, the primary one being that you’re building a tool intended to be used in a specific context, totally outside that context. It inevitably brings about some annoyances, usually after you’ve gone through the work of publishing your latest version of the tool and mounting it to the (let’s assume from here on out it’s a) CMS. You’ll ship it and then find things like this:

  • Global styles loaded by the CMS are bleeding into your app (or vice versa).
  • Third-party analytics scripts aren’t tracking activity tool activity as expected.
  • A sticky header on the page unexpectedly overlaps part of the tool as someone’s using it.
  • You realize you double-shipped a ton of CSS you didn’t know would have been automatically provided in the page rendered by the CMS.

None of these issues are inherently difficult to solve. The problem is that the feedback loop to become aware of them is too big.

Thankfully, modern tooling can give us some of the best of both worlds — a snappy, lightweight development experience, and a local environment that mimics how the tool will actually exist in production. And Vite makes it real easy.

Virtual (Development) Reality with Vite

Intercepting & manipulating requests during local development with build tooling isn’t new. In fact, most bundlers (Webpack, Parcel, etc.) support it out of the box. And Vite isn’t even the first tool I’ve seen used to project a local SPA onto a production page — front-end boss Lennart Kroneman blew my mind doing it with Webpack just a couple of years ago. But the ease at which it’s possible with Vite is a whole new level of bonkers, and well-worth laying out.

An Imaginary Setup

To prototype this, I’ve made an example “production” page published with Netlify. It’s got a bit of content and a little SPA mounted to it (”My Production App”). If you’d like to dabble, the repository lives here, and the published page is here. Just pretend it’s a published page hosted by an enterprise CMS.

Rendered between the page’s content is an empty DOM node used to mount the app, and a script tag to load the bundle:

<!-- app mounted here -->
<div id="app"></div>

<!-- app's bundle loaded here -->
<script type="module" src="/static/production-bundle.js"></script>

A Vite Config

The vite.config.js file for our little app is pretty simple at this point, simply defining how the package will be bundled on build.

import { resolve } from "path";
import { defineConfig } from "vite";
import preact from "@preact/preset-vite";

export default defineConfig({
  plugins: [preact()],
  build: {
    lib: {
      entry: resolve(__dirname, "app.jsx"),
      name: "MyApp",
      fileName: () => "production-bundle.js",
    },
  },
});

Even though I’m using Preact, the framework of choice is irrelevant. The basic assumption is that it’s something that mounts to #app once the bundle loads. Our application code looks like this:

// app.js

const App = () => {
  return <h2>My DEVELOPMENT App!</h2>;
};

export default App;

Which is then locally rendered in a local-dev.jsx file that’s directly referenced by our index.html file. This file is only intended to be used for local development. It’ll never be bundled up with the final product.

// local-dev.jsx

import { render } from "preact";
import App from "./app";

render(<App />, document.getElementById("app"));

Now, let’s spin things up. To start, it doesn’t look like much. If all goes well, we’ll end with this same application being rendered locally, but as if it’s on a production page.

Two Possible Approaches

In order to run our local dev server in the context of a production page without actually spinning up the CMS itself, there are two paths we can take.

Approach #1: Via Proxy Rules

Vite supports HTTP proxying powered by node-http-proxy, which allows us to match against request endpoints, and then perform variety of things on that request. For us, we’re simply interested in rewriting the URL. We’ll point some to our local development server, and some to the production page.

Worth noting: this approach will only work if your bundle is loaded via relative path (<script type="module" src="/path-to-bundle"></script>), since we can only intercept requests using the same domain.

For our page on Netlify, there are two types of assets being loaded — a CSS file and a JavaScript file (our SPA’s bundle):

<link rel="stylesheet" href="/static/main.css">
<script type="module" src="/static/production-bundle.js"></script>

Let’s set up some rules that will:

  • render the production page’s HTML, rather than the root index.html.
  • proxy all static asset requests back to the production page (since we won’t actually have them locally).
  • proxy the deployed bundle’s URL to our local version of the tool.

All of the rules we introduce will live within the server.proxy section of our config:

import { resolve } from "path";
import { defineConfig } from "vite";
import preact from "@preact/preset-vite";

export default defineConfig({
  plugins: [preact(), HtmlRewriter()],
  build: {
    // ... build config
  },
+ server: {
+   proxy: {},
+ },
});

First up, let’s rewrite the base path to the production page rather than our default index.html file. We’ll do that by matching on a short regular expression and telling Vite to serve a specific page from our Netlify host:

server: {
  proxy: {
    "^/$": {
      // Serve from Netlify instead of localhost.
      target: "https://vite-proxy-demo.netlify.app",

      // Change the "origin" header to the target host.
      changeOrigin: true,

      // Serve this path under the new target host.
      rewrite: () => "/some-page/",
    },
  },
};

After refreshing the local development server, the page looks more like this:

You’ll see all of the production content, without any of the static assets being loaded correctly. In fact, your console will show a couple of 404s, since the server is attempting to load them locally rather than from production (the only place they live):

We’ll fix that by setting up another rule. This time, for all static assets, pull from production.

server: {
  // Send anything starting with "/static" back to the production page.
  "/static": {
    target: "https://vite-proxy-demo.netlify.app",
    changeOrigin: true,
  },

  // ... previous rules
}

Notice that we’re adding this rule on top of the previous. That’s important because as a request comes in, Vite will match against the proxy rules we’ve configured in order. When the first one matches, it won’t bother with any of the others.

After that addition, our local version of the page should be looking much prettier:

But… we’re still seeing “My Production App!” For the final rule, we’ll point the production-bundle.js file to our local entry point. Again, be sure to add that more specific rule on top of the previous ones:

server: {
  proxy: {
    // Point the SPA bundle back to our local server.
    "/static/production-bundle.js": {
      target: "http://localhost:5173",
      changeOrigin: true,
      rewrite: () => "./local-dev.js",
    },

    // ... previous rules
  },
};

With that in place, we’ll see our local version of the tool rendered (”My Development App”) in the context of the deployed production page, greatly minimizing the number of styling, UI, and other gotchas we’ll encounter as we continue development. It’s virtual reality!

Approach #2: Rewriting Incoming HTML

If you don’t want to mess with proxying & URL rewriting, Vite has a more aggressive, yet equally effective approach we can take: the transformIndexHtml hook from its plugin API. When it’s wired up, we have full control over what’s rendered by the local development server.

This approach is actually preferred if your assets are loaded from a CDN on a different domain (we wouldn’t be able to proxy those anyway). We’ll mimic that on Netlfy by enabling the Asset Optimization feature, which will throw them up on CloudFront, making the URLs look like this:

<link href="https://d33wubrfki0l68.cloudfront.net/css/676d321d412b8c9364138a174fc4bfe260d2c5bb/some-page/main.css" rel="stylesheet"/>

In order to use the transformIndexHtml hook, we’ll need to write a small Vite plugin, whose bones look like this:

// vite.config.js

const HtmlRewriter = () => ({
  name: "html-rewriter",

  async transformIndexHtml(html) {
    // Transformation happens here.
    return html;
  },
});

export default defineConfig({
  // Plugin registered here:
  plugins: [preact(), HtmlRewriter()],

  // ... the rest of our config.
});

With this in place, we have full power to do whatever we want with that HTML, like completely swap it out for a different page:

const HtmlRewriter = () => ({
  name: "html-rewriter",

  async transformIndexHtml(html) {
    const response = await fetch(
      "https://vite-proxy-demo.netlify.app/some-page/"
    );
    const remoteHtml = await response.text();

    return remoteHtml;
  },
});

And then surgically rewrite the script tag for the production bundle to point to our local entry point. You can get as crazy (or not) with this as you wish, but in our case, I’m looking for any CloudFront URL loading my production-bundle.js file and swapping it out for my local-dev.jsx.

const HtmlRewriter = () => ({
  name: "html-rewriter",

  async transformIndexHtml(html) {
    const response = await fetch(
      "https://vite-proxy-demo.netlify.app/some-page/"
    );
    const remoteHtml = await response.text();

-   return remoteHtml;
+   return remoteHtml.replace(
+     /(https:\/\/(.*?)cloudfront\.net(.*?)production-bundle\.js)/,
+     `./local-dev.jsx`
+   );
  },
});

If you refresh the page, you’ll see the same result as before. All code is coming in from a production page except our application’s entry point.

Which Path Should I Take?

For the types of little apps I’ve worked on, I haven’t encountered any clear-cut reasons why you should choose one over the other. But here are some thoughts that may factor into your decision:

Go with proxy rules if:

  • The idea of mixing regular expressions and HTML gives you the sweats.
  • You have some firm conviction against writing your own (tiny) Vite plugin.
  • Intercepting & rewriting requests feels a little more elegant to you.

Go with the HTML rewrite approach if:

  • Your needs are pretty simple (just swapping out a single bundle).
  • You’ve been burned by HTTP proxying and URL rewriting in the past.
  • You’ve got a weird thing for matching regular expressions against HTML.

Hot Module Replacement Issues

Regardless of which path you take, you’ll likely experience issues with hot module reloading. If I knew the internals of Vite more deeply, I could probably articulate why that is and offer an elegant solution. Unfortunately, I don’t, but there is a something that reliably does the trick: include import.meta.hot in your entry point file:

// local-dev.jsx

+ import.meta.hot;

import { render } from "preact";
import App from "./app";

render(<App />, document.getElementById("app"));

Including that single line will force Vite to fully propagate an update, trigging a module reload. It irks me that I don’t understand this trick as much as I should. If you’ve got some insight, clue me in!

Additional Gotchas

These approaches have been incredibly helpful in building out self-contained apps outside of where they’ll be used. Even so, it’s been occasionally tripped up by a few gotchas. A couple come to mind:

  • Before you can do any of this, you need some page deployed somewhere, and it would ideally have the root node on the page (#app), as well as a script tag pointing to a bundle you can override. It’s possible to get around some of this with approach #2 (HTML rewriting), but would require you to hack at some more HTML with the transformIndexHTML hook than you might be comfortable with. In most of the projects I’ve worked on, a version of the page can be safely shipped to a lower environment and then worked on from there. It’s not a true “production” page at this point, but includes most of the relevant surrounding context (CSS, JS, etc.) that won’t change between there and production.
  • Your script tag must have a type="module" attribute on it. Without it, Vite won’t be able to load and execute your local entry point. It’s a small thing that may also be easily remedied by HTML rewriting again. Nevertheless, it can trip you up.

In all, working out the winkles in our setups like this has been somewhat of an ongoing process, and I expect the same for you. If you’ve hit any gotchas not mentioned here, or have any other feedback for making local development even less painful, let ‘em fly!

Tightening the Feedback Loop

Embracing these tactics have done some wonders for shrinking the formerly gigantic feedback loop we’d otherwise need to navigate when building in this sort of architecture. Hoping it does the same for you!