Appearance
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.jsKey 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.vueThe 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
manualChunksgroups 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.