Back to All Posts

Develop an SPA Against a Remote Page w/ Vite

There are serious benefits to building small applications in the context of a complex, remote page. Vite makes it pretty simple to pull it off.

More and more frequently, I’ve been working with little small 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” page, like one hosted by CMS.

Overall, it’s a helpful approach. Rather than being required to mess with a clunky application in order to work on just a small piece of it, you need to deal only with a little, self-contained application.

The Inherent Challenge

But there are some noteworthy challenges. The main one: you’re building a tool intended to be used in a specific context, totally outside that context. This inevitably brings about some gotchas & annoyances, often 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.
  • Third-party analytics scripts aren’t tracking the tool’s activity as expected.
  • A sticky header on the page unexpectedly overlaps part of the tool as someone’s using it.
  • You didn’t realize a ton of library styles are already being loaded by the CMS, and so you double-shipped a ton of CSS.

Individually, none of these issues are 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. In particular, Vite makes it real easy.

Remote Development with Vite

To be fair, Vite isn’t even the first tool I’ve seen used to project a local SPA onto a remote page — my friend Lennart Kroneman showed me how to do it with Webpack a couple of years ago, and I know other tools are capable of proxying requests in a similar fashion as well. But the ease at which it’s possible with Vite is at a whole different level.

An Sample 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>

Our Little App’s Vite Configuration

The vite.config.js file for our little, independent 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 that remote, production page.

Manipulating Incoming HTML w/ Vite

It’s possible to pull all of this off with Vite’s built-in support for proxying requests. In fact, when I first started dabbling, this is the approach I took. But because we’re dealing with multiple request types with various sources, it gets cumbersome real quick, and so I don’t even recommend it anymore.

Instead, Vite has a simpler, 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 with a relatively small amount of code.

We’ll start with 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 out all of the HTML for “your mom”:

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

	async transformIndexHtml(html) {
		return "your mom";
	},
});

Or, the HTML for a different page altogether:

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

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

		// Return the HTML.
		return response.text();
  },
});

At this point, if you were to reload our local application, it’d look exactly like that remote page we’re targeting, even though we’re serving it on localhost.

The next step is to surgically rewrite the script tag to point to our development version of the application, rather than the one that’s currently rendering.

Hijacking the Root Node

This’ll highly depend on how your production bundle is being loaded for a page you’re targeting. But for us, the application code is being loaded from a CloudFront URL. All we’ll do is target that remote script and swap it out for our local local-dev.jsx. Notice that since we’ll be serving it locally, we can even stick to using a relative path.

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 nearly the same result as before. All code is coming in from a production page except the script we targeted. And because of this, it’s our local version of the application running on the page — no longer the remote one.

With that, we’re largely set. We can iterate on our application with a far less chance of interference from unexpected code running on the page, without running any other application locally.

Avoiding a Couple of Gotchas

Before we’re wrapped up, there are a couple more things to keep in mind as you implement any of this:

#1: Guarantee hot module reloading fires consistently.

I initially ran into some issues with changes not being propagated through Vite to the browser consistently. In order to guarantee that they do, 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"));

That single line will force Vite to fully propagate an update, trigging a module reload, and keeping your development experience snappy:

#2: 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.

All this in a Plugin

I’ve quickly found myself implementing this setup in multiple projects, so I wrapped it up in a plugin: vite-plugin-proxy-page. It’s been tested in a variety of different projects, and you’re welcome to leverage it too. As an added benefit, there are a few other nice-to-haves handled by it too. Things like:

  • Locally caching the remote HTML so you’re not refetching it on every page load.
  • Being able to create a root node in the remote page’s HTML if it doesn’t already exist (and mount it in the correct spot).

Tightening the Feedback Loop

Embracing these tactics has 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!


Alex MacArthur is a software engineer working for Dave Ramsey in Nashville-ish, TN.
Soli Deo gloria.

Get irregular emails about new posts or projects.

No spam. Unsubscribe whenever.
Leave a Free Comment

2 comments
  • Toms

    Awesome implementation, i've been going down the exact rabbit hole and i just wish i had found this article faster haha.

    I've been using this for development with back-end languages like Php (in my case laravel specifically) without using laravel-vite package (which basically forces you to run laravel locally as well). So i end up just running front-end only while having backend run off a domain.


    1 reply
    • Toms

      Only thing lacking for such implementation would be possibility to have dynamic routing - so the development server serves data from specific routes IE: If i write /product-item-1 , it will also serve data from the specific route


  • adam

    nice!!! i use this method!!!!!!!!!!!


    1 reply