Skip to content

Vue Multi-Page Application

When to use

Use this architecture by default for new Vue applications. MPAs are simpler to reason about, produce smaller bundles per page, give each page its own URL without client-side routing, and avoid the complexity SPAs introduce around history management, code splitting, and hydration. Each page gets its own HTML entry point and Vue app instance while still using Vue’s reactivity, components, and composables internally.

The pattern

Directory structure

Each page lives in its own folder under pages/. The folder contains an index.html entry point, a root Vue component, a mount script, and any page-specific components:

ui/
├── components/           # Shared across all pages
│   ├── Button.vue
│   ├── Dialog.vue
│   ├── Form.vue
│   ├── Input.vue
│   ├── Layout.vue
│   └── index.js
├── pages/
│   ├── home/
│   │   ├── index.html
│   │   ├── main.js
│   │   ├── HomePage.vue
│   │   ├── ExpensesPlot.vue
│   │   └── CashAccountsTable.vue
│   ├── goals/
│   │   ├── index.html
│   │   ├── main.js
│   │   ├── GoalsPage.vue
│   │   ├── GoalsTable.vue
│   │   ├── NewGoalForm.vue
│   │   └── EditGoalForm.vue
│   └── settings/
│       ├── index.html
│       ├── main.js
│       └── SettingsPage.vue
└── vite.config.js

Key conventions:

  • Co-location. Page-specific components sit next to the page that uses them. Only truly shared components go in the top-level components/ folder.
  • Flat within a page. No nested folders inside a page directory. Each page’s components are siblings of its index.html.
  • Naming. The root component is named after the page (HomePage.vue, GoalsPage.vue). Supporting components use descriptive names.

HTML entry point

Each page's index.html is a standalone document:

html
<!DOCTYPE html>
<html lang="en">
<head>
  <link rel="include"
    href="../../shared/head.html">
</head>
<body>
  <div id="app"></div>
  <script type="module" src="./main.js"></script>
</body>
</html>

The <link rel="include"> tag pulls in shared <head> content at build time (see html-includes.md). Each page points to its own main.js.

Mount script

Each main.js creates and mounts a fresh Vue app:

javascript
import { createApp } from "vue";
import HomePage from "./HomePage.vue";

createApp(HomePage).mount("#app");

No router. No shared app state beyond what components import explicitly. Each page is fully independent.

Page-specific vs. shared components

Page components import shared UI from the top-level components/ folder using the ! alias:

javascript
import {
  Layout, Button, Dialog, MaterialIcon,
} from "!/components/";

Page-specific components use relative imports since they live in the same directory. The ./ prefix signals that a component belongs to this page alone:

javascript
import NewGoalForm from "./NewGoalForm.vue";
import GoalsTable from "./GoalsTable.vue";

This distinction makes dependencies visible at a glance: aliased imports (!/) are shared, relative imports (./) are local.

Vite configuration

The Vite config sets appType: "mpa" and auto-discovers HTML entry points so new pages are zero-config:

javascript
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { globbySync } from "globby";

export default defineConfig({
  root: "pages",
  appType: "mpa",
  plugins: [vue()],
  resolve: {
    alias: {
      "!": dirname(fileURLToPath(import.meta.url)),
    },
  },
  build: {
    rollupOptions: {
      input: htmlEntryPoints(),
    },
  },
});

See vite.md for the full entry-point discovery implementation.

Adding a new page

Create a folder under pages/ with the three required files — an HTML entry point, a mount script, and a root Vue component. The glob-based entry discovery picks up the new index.html automatically, so no config changes are needed:

ui/pages/reports/
├── index.html
├── main.js
└── ReportsPage.vue

The HTML entry point follows the same structure as every other page, pulling in shared <head> content via the includes plugin and loading the page's mount script:

html
<!DOCTYPE html>
<html lang="en">
<head>
  <link rel="include"
    href="../../shared/head.html">
</head>
<body>
  <div id="app"></div>
  <script type="module" src="./main.js"></script>
</body>
</html>

The mount script creates a standalone Vue app instance for this page. Each page gets its own createApp call, keeping bundles isolated:

javascript
import { createApp } from "vue";
import ReportsPage from "./ReportsPage.vue";

createApp(ReportsPage).mount("#app");

The root component imports shared UI through the ! alias and provides the page-specific markup:

html
<script setup>
  import { Layout } from "!/components/";
</script>

<template>
  <Layout>
    <h1>Reports</h1>
  </Layout>
</template>

Trade-offs

Advantages:

  • Isolation. Each page is an independent Vue app with its own bundle. A bug or dependency in one page cannot affect another.
  • Zero-config pages. Adding a page means creating a folder — no router entries, no config edits.
  • Co-location. Page-specific components live next to the page, making it easy to find and modify related code.
  • Smaller bundles. Only the code a page actually imports is included in its bundle.
  • Independent work. Teams can work on separate pages without touching shared files.

Limitations:

  • Full page loads. Navigation between pages triggers a browser reload. There is no client-side transition between pages.
  • Shared state is manual. Without a single Vue app instance, sharing state across pages requires cookies, localStorage, or server-side sessions.
  • Duplicate framework code. Each page bundles its own copy of Vue unless manualChunks groups the framework into a shared chunk.
  • No prefetching. An SPA can prefetch the next route’s code; an MPA cannot anticipate which page the user will visit next.