Appearance
Vite HTML Includes Plugin
When to use
Use this plugin when you need to compose HTML pages from reusable fragments at build time. The pattern replaces custom <link rel="include"> tags with the contents of the referenced file before the browser ever sees the HTML. Use it when you want server-include-style composition without a server — Vite handles everything during the build step.
The pattern
A Vite plugin hooks into transformIndexHtml to find <link rel="include"> elements, read the referenced files, and inline their content in place of the link tag. Includes are processed recursively, so a fragment can itself include other fragments.
Source HTML
Each page uses <link rel="include"> tags to pull in shared fragments. The href attribute points to the fragment file using a relative path from the current HTML file:
html
<!-- pages/home/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="include" href="../../shared/head.html">
</head>
<body>
<link rel="include" href="../../shared/header.html">
<main id="app"></main>
<script type="module" src="./main.js"></script>
</body>
</html>Fragment
Fragments are plain HTML files containing the markup to be inlined. They have no <html> or <body> wrapper — just the content that will replace the <link rel="include"> tag:
html
<!-- shared/head.html -->
<meta charset="utf-8">
<meta name="viewport"
content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/styles/global.css">Build output
After the plugin runs, every <link rel="include"> tag is replaced by the fragment's content. The browser receives fully-composed HTML with no trace of the include mechanism:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport"
content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/styles/global.css">
</head>
<body>
<!-- header content inlined here -->
<main id="app"></main>
<script type="module" src="./main.js"></script>
</body>
</html>Plugin implementation
The htmlIncludes function returns a Vite plugin object that hooks into transformIndexHtml. It parses each HTML entry point into a DOM tree, resolves all include links, then serializes the result back to a formatted string:
javascript
import { createRequire } from "node:module";
import { JSDOM } from "jsdom";
import { format } from "prettier";
function htmlIncludes() {
return {
name: "vite-plugin-html-includes",
transformIndexHtml(html, { filename }) {
const document = new DOMParser()
.parseFromString(html);
handleIncludes(document, filename);
return formatHTML(document.toString());
},
};
}Recursive include resolution
The handleIncludes function queries the DOM for all <link rel="include"> elements, reads each referenced file, parses it into a document fragment, and recurses so that fragments can themselves contain includes:
javascript
function handleIncludes(node, contextPath) {
const links = node.querySelectorAll(
`link[rel="include"][href]`
);
for (const link of links) {
const href = link.getAttribute("href");
const includePath =
createRequire(contextPath).resolve(href);
const includeHTML = readFileSync(includePath, "utf8");
const template = document.createElement("template");
template.innerHTML = includeHTML;
// Recurse into the included content
handleIncludes(template.content, includePath);
link.replaceWith(template.content);
}
}Key details:
- Path resolution uses
createRequire()from the context file's path so that relativehrefvalues resolve correctly even when includes are nested. - Recursion means a fragment can reference other fragments. The depth is bounded only by the file structure.
- DOM manipulation via
jsdomkeeps the transform reliable. String-based regex replacement would break on edge cases like attributes containing>.
DOM parsing helper
The plugin uses jsdom to provide a server-side DOMParser that converts raw HTML strings into a traversable DOM tree with a toString method for serialization:
javascript
class DOMParser {
parseFromString(string, mimeType = "text/html") {
if (mimeType !== "text/html") {
throw new Error(
`Only "text/html" is supported.`
);
}
const dom = new JSDOM(string, {
contentType: mimeType,
});
const { document } = dom.window;
document.toString = dom.serialize.bind(dom);
return document;
}
}Output formatting
The final HTML is passed through Prettier so the build output stays readable regardless of how fragments were indented in their source files:
javascript
function formatHTML(html) {
return format(html, { parser: "html" });
}Trade-offs
Advantages:
- Eliminates copy-paste duplication of shared HTML across pages (nav bars, meta tags, footers).
- Build-time processing means zero runtime cost — the browser receives fully-composed HTML.
- Recursive includes let you compose at any depth without special configuration.
- Uses standard DOM APIs (via
jsdom) so the transform is predictable and testable.
Limitations:
- Adds
jsdomand Prettier as build dependencies. - Only works with Vite — not usable with other bundlers without adaptation.
- Fragment changes require a dev-server reload (Vite HMR does not track
<link rel="include">dependencies by default). - The
createRequirepath-resolution trick ties the implementation to Node.js module semantics.