Skip to content

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 relative href values 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 jsdom keeps 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 jsdom and 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 createRequire path-resolution trick ties the implementation to Node.js module semantics.