Using Vite to Develop an SPA in the Context of a Production Page
Tags: vite, spa, proxy
Published: 2022-09-08 04:32:03

---
title: Using Vite to Develop an SPA in the Context of a Production Page
published: true
date: 2022-09-06 05:00:00 UTC
tags: vite, spa, proxy
canonical_url: https://macarthur.me/posts/project-local-spa-onto-production-page
---

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.

![](https://macarthur.me/proxy/824e282f-9b3c-4b3f-b3da-e0ca16b5367d)

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](https://vitejs.dev/), 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](https://kroneman.io/) 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](https://github.com/alexmacarthur/vite-proxy-demo), and the [published page is here](https://vite-proxy-demo.netlify.app/some-page/). Just pretend it’s a published page hosted by an enterprise CMS.

![](https://macarthur.me/proxy/06ba5982-67a1-4ebf-928e-881e27ff5e0a)

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.

![](https://macarthur.me/proxy/b23647e6-474c-40da-9085-af261718b601)

## 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](https://github.com/http-party/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:

![](https://macarthur.me/proxy/2b7331d3-183d-4a12-84a4-be186e7e919d)

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 `404`s, since the server is attempting to load them locally rather than from production (the only place they live):

![](https://macarthur.me/proxy/6e75bef6-474d-408b-ba26-49a68dde536c)

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:

![](https://macarthur.me/proxy/0e2deadd-c465-4c37-972d-4888410ae2a1)

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!

![](https://macarthur.me/proxy/33671be4-94f8-422d-83e1-4815433b126c)

### 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](https://vitejs.dev/guide/api-plugin.html#vite-specific-hooks). 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](https://www.netlify.com/blog/2021/12/21/optimize-your-sites-with-post-processing-on-netlify/#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`](http://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!