Making a Full-Stack App with Vue, Vite and Express that supports Hot Reload

Creating an app in a single repository without compromising on Developer Experience

Introduction

With each passing day, web applications are becoming more and more reactive. JavaScript front-end frameworks are designed for this particular purpose. However, it is common for applications that use a front-end framework to split the front-end and back-end code into separate repositories.

While this works for many teams, an advantage of having all application code in a single repo is that new changes are easier to release, as they don’t require coordination between two separate deployments.

This article presents a way to use a Vue front-end with an Express back-end in a single repository, while preserving features such as HMR (Hot Module Replacement) to improve the Developer Experience.

Hot Module Replacement allows us to edit a file during local development and see the changes in real-time in our browser tab, without having to reload the page or restart the app.

Getting Started

We’ll start by creating the Vite application. If you have any issues reproducing the steps, you can take a look at the repository linked at the end of the post, which has all the code from this blog post.

Let’s create a Vue app using Vite. To do this, just run the command:

npm create vite@latest vue-express-webapp -- --template vue
cd vue-express-webapp
npm install
npm run dev

Open http://localhost:5173/ to see the app.

Default Vite App

Here are some notable things:

  • Two images show up: the Vite logo and the Vue logo
  • This is a front-end-only application
  • If you change anything in the file src/App.vue file, your page will automatically be updated in the browser.

You app’s directory structure should like this:

.
├── public
│   └── vite.svg
├── src
│   ├── App.vue
│   ├── assets
│   │   └── vue.svg
│   ├── components
│   │   └── HelloWorld.vue
│   ├── main.js
│   └── style.css
├── index.html
├── package.json
├── package-lock.json
├── README.md
└── vite.config.js

Create the Express Server

Now let’s create a back-end for our application.

To do that, we will install:

  • express: To handle the routes in our back-end
  • nodemon: To add auto-reload to our back-end, so whenever we update a file in the back-end it will restart the app.
  • concurrently: To easily run two Node commands in parallel.
npm install express
npm install --save-dev nodemon concurrently

Create the server file under server/index.js. Let’s also add an example endpoint so we can make sure that it works:

import express from "express";

const port = process.env.PORT || 3000;

const app = express();

app.get("/api/v1/hello", (_req, res) => {
  res.json({ message: "Hello, world!" });
});

app.listen(port, () => {
  console.log("Server listening on port", port);
});

To start our server, we need to update the dev command.

Open up your package.json, add a main file and replace the dev command with the following:

   "version": "0.0.0",
   "type": "module",
+  "main": "server/index.js",
   "scripts": {
-    "dev": "vite",
+    "dev:frontend": "vite",
+    "dev:backend": "nodemon server/index.js",
+    "dev": "concurrently 'npm:dev:frontend' 'npm:dev:backend'",
     "build": "vite build",
     "preview": "vite preview"
   },

That way it will run the Vite server and Nodemon in parallel.

Start the server by running again:

npm run dev

So if we go over to http://localhost:3000/api/v1/hello, we can see that the message what as we expect:

{
  "message": "Hello, world!"
}

And if we head over to http://localhost:5173/ we can see that the app is still running!

That means we now have a front-end and a back-end running, but they don’t talk to each other yet.

The Integration Plan

We want our app to be optimized when it’s build for production. But at the same time, we want to use HMR when developing locally. For that reason, the app will work differently in these two environments.

Here’s how we want the app to work during development:

Sequence Diagram - How the app works during development

And here’s how we want the app to work in production:

Sequence Diagram - How the app works in production

Note: After the integration is complete, we will only access the app from the Express server (at http://localhost:3000), even during development. The front-end will automatically fetch data from the Vite server (port 5173).

Connect the client and the server

As we can see, the Development server and the Production server both have two different behaviors. For that reason, we’ll need to have a dynamic index.html file, that can change based on the environment. We can use a template engine such as ejs for that. Install it with:

npm install ejs

Move and rename the index.html template to views/index.html.ejs.

Create a router for our home page, under server/homepageRouter.js:

import express from "express";

const router = express.Router();

router.get("/*", (_req, res) => {
  res.render("index.html.ejs");
});

export default router;

Now let’s attach the router to our Express application. Under server/index.js, add the following:

 import express from "express";
+import homepageRouter from "./homepageRouter.js";

 const port = process.env.PORT || 3000;

 const app = express();

 app.get("/api/v1/hello", (_req, res) => {
   res.json({ message: "Hello, world!" });
 });

+app.use(homepageRouter);

 app.listen(port, () => {
   console.log("Server listening on port", port);
 });

By now, our application will be able to load the same Index as before, but from the Express server instead of the Vite Server.

But before opening the server again we need to do some changes for it to work. Update the views/index.html.ejs file to point the script tags to the Vite Server. That way we can access the front-end app during development.

   </head>
   <body>
     <div id="app"></div>
-    <script type="module" src="/src/main.js"></script>
+    <script type="module" src="http://localhost:5173/@vite/client"></script>
+    <script type="module" src="http://localhost:5173/src/main.js"></script>
   </body>
 </html>

The first script will load the HMR client in our application. The second script will call the main script from Vite itself.

Finally, just to be sure that the front-end can talk to the back-end, let’s make an HTTP request in the front-end. Add the following to src/components/HelloWorld.vue:

 const count = ref(0)
+
+const serverHello = ref({})
+
+fetch(/api/v1/hello)
+  .then((r) => r.json())
+  .then(({ message }) => {
+    serverHello.value = message
+  })
 </script>

 <template>
   <h1>{{ msg }}</h1>
+  <h2>{{ serverHello }}</h2>

   <div class="card">

So we can see that after running npm run dev and loading the application at http://localhost:3000, it works! Well, almost.

Vite App that Fails to Load Image

Here’s what we can see:

  • The page loads our app,
  • If you edit a file you’ll see that HMR works
  • The counter is still counting
  • Our HTTP request was successful

But the images are not loading correctly. Why is that?

Well, now that we aren’t using Vite’s client to access the images, it can’t find them.

By inspecting the HTML, we can see that these are the images that the app is trying to load:

<div data-v-7a7a37b1="">
  <a href="https://vitejs.dev" target="_blank" data-v-7a7a37b1="">
    <img src="/vite.svg" class="logo" alt="Vite logo" data-v-7a7a37b1="" />
  </a>
  <a href="https://vuejs.org/" target="_blank" data-v-7a7a37b1="">
    <img
      src="/src/assets/vue.svg"
      class="logo vue"
      alt="Vue logo"
      data-v-7a7a37b1=""
    />
  </a>
</div>

Both of these can be found in different places. /vite.svg should be in the /public directory, and /src/assets/vue.svg is in our source code.

For the first image, we just need to tell Express to load our public/ directory as a static directory. Inside server/index.js, add the following:

 import express from "express";
+import path from "path";
 import homepageRouter from "./homepageRouter.js";

 const port = process.env.PORT || 3000;
+const publicPath = path.join(path.resolve(), "public");

 const app = express();

 app.get("/api/v1/hello", (_req, res) => {
   res.json({ message: "Hello, world!" });
 });

+app.use("/", express.static(publicPath));
 app.use(homepageRouter);

 app.listen(port, () => {

Now if we reload the page we can see that the image on the left (/vite.svg) loads!

Now for the image on the right, we need to fetch it from Vite. We can do this for all assets in which the path starts with /src.

Create the server/assetsRouter.js file:

import express from "express";

const router = express.Router();

const supportedAssets = ["svg", "png", "jpg", "png", "jpeg", "mp4", "ogv"];

const assetExtensionRegex = () => {
  const formattedExtensionList = supportedAssets.join("|");

  return new RegExp(/.+\.(${formattedExtensionList})$);
};

router.get(assetExtensionRegex(), (req, res) => {
  res.redirect(303, http://localhost:5173/src${req.path});
});

export default router;

Add it to our server/index.js file:

 import express from "express";
 import path from "path";
 import homepageRouter from "./homepageRouter.js";
+import assetsRouter from "./assetsRouter.js";

 const port = process.env.PORT || 3000;
 const publicPath = path.join(path.resolve(), "public");

 const app = express();

 app.get("/api/v1/hello", (_req, res) => {
   res.json({ message: "Hello, world!" });
 });

 app.use("/", express.static(publicPath));
+app.use("/src", assetsRouter);
 app.use(homepageRouter);

 app.listen(port, () => {

Now all images are loading properly!

Vite App that loads images successfully

We have only one final challenge: Building and running the app in production.

Running in production

To run the app in production, we need to tune the Vite build configuration a little bit. By default, Vite builds its own index.html file based on ours. However, since we’ll need a more dynamic behavior on our template, we can’t use Vite’s index.html.

Under vite.config.js, add the following:

 export default defineConfig({
   plugins: [vue()],
+  build: {
+    manifest: true,
+    rollupOptions: {
+      input: './src/main.js',
+    },
+  },
 })

This will tell Vite to create a Manifest file when building, using src/main.js as the entry point. That leaves us responsible for creating the Index template.

The Manifest file will contain the map of all of our build files. It looks like the following:

{
  "src/assets/vue.svg": {
    "file": "assets/vue-5532db34.svg",
    "src": "src/assets/vue.svg"
  },
  "src/main.css": {
    "file": "assets/main-ef37148b.css",
    "src": "src/main.css"
  },
  "src/main.js": {
    "file": "assets/main-b0950e31.js",
    "src": "src/main.js",
    "isEntry": true,
    "css": ["assets/main-ef37148b.css"],
    "assets": ["assets/vue-5532db34.svg"]
  }
}

To use that map in Production we will need to update our index template. Under views/index.html.ejs, add:

+    <% if (environment === 'production') { %>
+    <link rel="stylesheet" href="<%= manifest['src/main.css'].file %>" />
+    <% } %>
   </head>
   <body>
     <div id="app"></div>
+    <% if (environment === 'production') { %>
+    <script type="module" src="<%= manifest['src/main.js'].file %>"></script>
+    <% } else { %>
     <script type="module" src="http://localhost:5173/@vite/client"></script>
     <script type="module" src="http://localhost:5173/src/main.js"></script>
+    <% } %>
   </body>
 </html>

The way ejs works is that everything under the <% %> block is executed as JavaScript code, on the server side. For that reason, we can use an if statement here.

In our case, we’re ensuring that whenever the app is running in the production environment, it will attach the correct JavaScript and CSS files. Otherwise, it will call the Vite server (as we were doing before).

Now that the index.html.ejs template depends on these values, we need our back-end to provide them to it.

Under server/homepageRouter.js, edit the following:

 import express from "express";
+import fs from "fs/promises";
+import path from "path";

 const router = express.Router();

-router.get("/*", (_req, res) => {
-  res.render("index.html.ejs");
-});

+const environment = process.env.NODE_ENV;
+
+router.get("/*", async (_req, res) => {
+  const data = {
+    environment,
+    manifest: await parseManifest(),
+  };
+
+  res.render("index.html.ejs", data);
+});

+const parseManifest = async () => {
+  if (environment !== "production") return {};
+
+  const manifestPath = path.join(path.resolve(), "dist", "manifest.json");
+  const manifestFile = await fs.readFile(manifestPath);
+
+  return JSON.parse(manifestFile);
+};
+
 export default router;

This will load the manifest.json file from our dist/ directory, parse it as JSON, and inject it into our template.

That takes care of the JavaScript files and consequentially, our assets under src/.

We still need to load static assets in Production. To do that, we just need to ensure that the app uses dist/ as the static directory in Production, as opposed to public/.

Edit the server/index.js file. Define the dist/ path:

 const port = process.env.PORT || 3000;
 const publicPath = path.join(path.resolve(), "public");
+const distPath = path.join(path.resolve(), "dist");

 const app = express();

Use dist/ instead of public/ for production. We can also disable the Assets Router as the built assets will always be loaded properly in production.

-app.use("/", express.static(publicPath));
-app.use("/src", assetsRouter);
+if (process.env.NODE_ENV === "production") {
+  app.use("/", express.static(distPath));
+} else {
+  app.use("/", express.static(publicPath));
+  app.use("/src", assetsRouter);
+}
+
 app.use(homepageRouter);

The last change that we need to make is to add a script to start our app in Production mode. To do that, add the following line to our package.json scripts:

   "scripts": {
     "dev:frontend": "vite",
     "dev:backend": "nodemon server/index.js",
     "dev": "concurrently 'npm:dev:frontend' 'npm:dev:backend'",
+    "start": "NODE_ENV=production node server/index.js",
     "build": "vite build",
     "preview": "vite preview"
   },

This will start our server using node and also inject the NODE_ENV environment variable, set as production.

Now if we run:

npm run build
npm start

And go to http://localhost:3000:

Voilà! You now have a full-stack application!

If you faced any issues following this blogpost, you can also check out the full repository here: https://github.com/rhian-cs/cm42-blogpost-vue-express-webapp

Conclusion

A good Developer Experience (DX) is one of the most important aspects of modern development. It allows us to do our jobs at an exponentially faster pace. Everything we’ve done here is to preserve a good DX, while still using the tools that we need to deliver features.

An alternative approach would be to use a full-stack framework such as Nuxt.js but that may depend on your application needs.

References

We want to work with you. Check out our "What We Do" section!