Injecting Tags with the transformIndexHtml Hook

Hardcoding meta tags, preload hints, or an analytics snippet into index.html is fragile — the values differ per environment and the hashed asset URLs are only known at build time. Vite’s transformIndexHtml hook lets a plugin inject tags programmatically with the correct positions and build-time values. This guide uses it precisely. It applies the advanced Vite plugin configuration hook model to HTML; the parent overview is Vite Configuration Ecosystem.

The reason this problem exists at all is that index.html is Vite’s entry point, not a static file copied verbatim. During a build, Vite parses the template, discovers the module graph rooted at your <script type="module">, bundles and hashes every asset it reaches, and then rewrites the HTML to point at the emitted filenames — main.ts becomes assets/index-a3f1c9.js. Any tag whose value depends on the outcome of that pipeline — a modulepreload for a chunk you do not name until Rollup has split it, a CSP hash for an inline script, a content attribute derived from import.meta.env — cannot be written into the source template because the correct value does not exist when you author the file. It only exists after bundling.

The consequence of ignoring this is a class of bugs that never show up in dev and only surface in the shipped bundle. A preload hint typed by hand goes stale the moment the referenced file’s content changes and its hash rolls, so the browser fetches a 404 and silently drops the hint. An analytics snippet pasted into the template runs during local development too, polluting your metrics with dev traffic and firing on every hot reload. A meta description hardcoded for production leaks into a staging deploy. transformIndexHtml is the sanctioned place to resolve these values because it runs inside the same pass that knows the bundle’s final shape, and it hands you a descriptor API instead of asking you to concatenate strings into a document you do not fully control.

transformIndexHtml injects tags at build time The transformIndexHtml hook receives the HTML and returns a list of tag descriptors that Vite injects at the requested position, so meta tags, preload hints and analytics snippets are added programmatically with build-time values rather than hardcoded. Return tag descriptors, Vite places them index.htmlsource template transformIndexHtmlreturn { tag, injectTo } output HTMLtags injected descriptors, not string concatenation — Vite handles placement and escaping
Figure: you return tag descriptors and Vite handles placement and escaping — no manual string surgery.

Prerequisites & reproducible setup

# Vite 5.4.x, Node 20+
npm create vite@latest html-inject-demo -- --template vanilla-ts
cd html-inject-demo && npm install
# We will inject a meta description and a preconnect from a plugin.

transformIndexHtml runs in two forms: a function returning transformed HTML or a { order, handler } object. The handler can return an HTML string or an array of HtmlTagDescriptor objects, each with tag, attrs, children, and injectTo.

Prefer the object form from the start. The bare-function shorthand is convenient but gives you no control over ordering relative to Vite’s own HTML processing, and the moment you need a preload that references an emitted asset you have to convert it anyway. The { order, handler } form is a superset — it does everything the function form does and exposes the two knobs (order and, through the descriptors, injectTo) that this hook exists to provide. Treating the object form as the default costs three extra lines and saves a rewrite later.

The attrs map is worth reading carefully because its value types are load-bearing. A string attribute renders as key="value". A boolean true renders as a bare valueless attribute — { defer: true } becomes defer, which is exactly what boolean HTML attributes want; writing { defer: 'true' } instead emits defer="true", still truthy but noisier and technically a different serialization. A boolean false or a null/undefined value omits the attribute entirely, which is the clean way to conditionally include one attribute without branching the whole descriptor. children is the inner content for tags that have a body — an inline <script> or <style> — and Vite does not escape it, so treat children as raw HTML and never interpolate untrusted input there.

A tag descriptor has tag, attrs, and injectTo Each HtmlTagDescriptor names the tag, its attributes, optional children, and an injectTo position such as head or body, so the plugin describes what to inject and where without writing raw HTML strings. tag + attrswhat childreninner content injectTowhere describe the tag, not the HTML string
Figure: the descriptor separates what to inject from where, so Vite can place it correctly.

How it works under the hood

transformIndexHtml is not a single event; it is a pipeline of contributions that Vite reduces into one document. When Vite processes an entry HTML, it collects every plugin’s transformIndexHtml and runs them in a defined sequence: hooks marked order: 'pre' first, then normal hooks, then Vite’s own internal HTML processing that rewrites <script> and <link> references and injects the emitted asset tags, then order: 'post' hooks last. Each hook receives the current HTML string plus a context object, and its return value — either replacement HTML or a list of descriptors — is folded back in before the next hook runs. This is why order matters so much: your position in that chain decides whether the emitted asset tags already exist in the HTML you are handed.

The context argument is the part people underuse. Its path field is the request path or output filename, which is how a multi-page build tells one entry from another. Its filename field is the absolute path to the HTML file on disk. Its server field is the dev server instance in development and undefined during a production build, which is the cleanest available signal for the dev-versus-build branch. And its bundle and chunk fields are populated only during build, giving you the full Rollup output bundle and the entry chunk — this is where you look up an emitted asset’s final hashed filename so a preload can reference it correctly. Reaching into ctx.bundle from a post hook is the canonical way to preload a chunk you did not name yourself.

Placement itself is deterministic. When Vite injects a descriptor, head appends before the closing </head>, head-prepend inserts immediately after <head>, body appends before </body>, and body-prepend inserts after <body>. There is no ambiguity and no dependence on where other tags already sit — the position is anchored to the document’s structural tags, not to sibling elements. If two descriptors target the same position, they are emitted in array order, so you control relative ordering within a position simply by ordering the array you return.

Diagnosis workflow

  1. Confirm the value is build-time. A meta description that varies by environment, or a preload for a hashed asset, cannot be hardcoded — that is the case for the hook. If the value is a genuine constant that never changes between environments and does not depend on the bundle, it belongs in the static template, not in a plugin; reaching for the hook to inject a fixed <meta charset> is over-engineering. The test is whether the value is knowable when you write the file. If it is, write it in the template; if it depends on the environment or the build output, the hook is the right tool.
  2. Pick the injectTo position. head for meta/preload, head-prepend to win over other head tags, body/body-prepend for scripts that must run early or late. The precedence question is real: a <meta http-equiv="content-security-policy"> or an early feature-detection script should use head-prepend so it lands before any other head content and takes effect before the browser parses the tags it governs. A late-loading analytics or error-reporting script belongs at body so it never delays the initial render. If you are unsure, head for descriptive/resource-hint tags and body for behavior-bearing scripts is the safe default.
  3. Decide the order. pre runs before Vite’s core HTML handling, post after; a preload that must reference Vite’s emitted assets runs post. The mistake to avoid is choosing pre for a tag whose value comes from the bundle: at pre time Vite has not yet rewritten asset references or populated the emitted filenames into the HTML, so any attempt to reference them finds nothing. Use pre for tags whose values you compute yourself (env-derived meta, static preconnects) and post for tags that must see what Vite emitted (modulepreload for a specific chunk, a CSP hash covering Vite’s injected inline scripts).
injectTo positions map to intent head and head-prepend place meta and preload tags with head-prepend winning precedence, while body and body-prepend place scripts, and the order pre or post decides whether the handler runs before or after Vite's own HTML processing. headmeta / preload head-prependwins precedence bodylate scripts body-prependearly scripts four positions, chosen by what the tag does
Figure: the position is chosen by the tag's job — precedence, early script, or late script.

Annotated solution config

// vite.config.ts — Vite 5.4.x, inject env-specific tags at build time.
import { defineConfig, type Plugin } from 'vite';

function htmlTagsPlugin(): Plugin {
  return {
    name: 'html-tags',
    transformIndexHtml: {
      order: 'pre',   // run before Vite injects its own asset tags
      handler(_html, ctx) {
        const isProd = ctx.server === undefined;   // build vs dev
        return [
          {
            tag: 'meta',
            attrs: { name: 'description', content: 'Injected at build time.' },
            injectTo: 'head',
          },
          {
            tag: 'link',
            attrs: { rel: 'preconnect', href: 'https://api.example.com' },
            injectTo: 'head',
          },
          // Only add analytics in production builds.
          ...(isProd
            ? [{
                tag: 'script',
                attrs: { src: 'https://cdn.example.com/analytics.js', defer: true },
                injectTo: 'body' as const,
              }]
            : []),
        ];
      },
    },
  };
}

export default defineConfig({ plugins: [htmlTagsPlugin()] });
One handler returns environment-conditional descriptors The handler inspects ctx.server to tell dev from build, always returns the meta and preconnect descriptors for the head, and conditionally adds the analytics script to the body only in production builds. alwaysmeta + preconnect→ head prod onlyanalytics script→ body ctx.server distinguishes dev from build
Figure: ctx.server is how one handler serves dev and build differently — no separate config.

Walk the config against the mechanism above. order: 'pre' is correct here because none of these three tags needs to see Vite’s emitted asset references — the meta and preconnect are static, and the analytics script points at an external CDN URL, not a bundled chunk. The handler branches on ctx.server === undefined rather than reading process.env.NODE_ENV or import.meta.env.PROD because ctx.server is the hook’s own, authoritative signal for the dev-versus-build distinction and does not depend on how the process was invoked. The conditional spread — ...(isProd ? [descriptor] : []) — is the idiomatic way to include a descriptor only sometimes without leaving a null or false in the array that Vite would have to skip; it keeps the returned list clean. Note the as const on the conditional descriptor’s injectTo: without it, TypeScript widens the literal 'body' to string, which no longer matches the HtmlTagDescriptor union and fails the type check.

When the value genuinely does depend on the bundle — the case that justifies this hook most — you need order: 'post' and the build-only context fields. The following plugin preloads the first CSS asset Vite emits, whose hashed name is not knowable until Rollup has run:

// vite.config.ts — Vite 5.4.x, preload an emitted CSS asset by its hashed name.
import { defineConfig, type Plugin } from 'vite';

function preloadEmittedCss(): Plugin {
  return {
    name: 'preload-emitted-css',
    transformIndexHtml: {
      order: 'post',   // run after Vite has emitted and hashed assets
      handler(_html, ctx) {
        // ctx.bundle exists only during build; skip in dev.
        if (!ctx.bundle) return [];
        const cssFile = Object.keys(ctx.bundle).find((f) => f.endsWith('.css'));
        if (!cssFile) return [];
        return [
          {
            tag: 'link',
            attrs: { rel: 'preload', as: 'style', href: `/${cssFile}` },
            injectTo: 'head',
          },
        ];
      },
    },
  };
}

export default defineConfig({ plugins: [preloadEmittedCss()] });

The if (!ctx.bundle) return [] guard is not optional: in the dev server ctx.bundle is undefined, and referencing .keys() on it would throw and break every page load. Returning an empty array in dev is the correct no-op — the hook contributes nothing when there is no bundle to read from.

Verification

# Vite 5.4.x — build and inspect the emitted HTML.
npx vite build
grep -c 'name="description"' dist/index.html      # expect 1
grep -c 'rel="preconnect"'   dist/index.html      # expect 1
grep -c 'analytics.js'       dist/index.html      # expect 1 (prod build)
# In dev, analytics is absent:
npx vite &  curl -s http://localhost:5173/ | grep -c analytics   # expect 0

The built index.html contains the injected meta, preconnect, and (in production) the analytics script in the positions you specified, while the dev server omits the analytics tag — the environment branch working as intended.

The three grep -c counts should each read exactly 1. A count of 0 means the descriptor never made it into the output — usually because the plugin was not registered, the handler returned early, or the branch condition was false. A count of 2 or higher means the tag is being injected more than once, which typically happens when the same plugin is added twice or when both a static template tag and an injected descriptor emit the same element; deduplicate by removing whichever source is redundant. Do not just eyeball the file — the counts are the assertion, and wiring these three lines into a CI step turns “the tags are there” into a check that fails the build when they are not.

For the dev-side check, prefer inspecting the transformed HTML directly over racing a background server. curl against a freshly backgrounded vite & can hit the server before it is listening and report a spurious 0; if you see flakiness, add a short wait or poll the port until it responds, then grep. The intent of the dev assertion is specifically to prove absence — that the analytics branch did not fire — so a false 0 from a not-yet-ready server would pass for the wrong reason. Confirm the server actually served a page (the HTML contains your app root) before trusting the analytics count.

Tags present in build, analytics absent in dev Verification greps the output: the production HTML contains the meta, preconnect and analytics tags in their positions, while the dev server HTML omits the analytics script, confirming the environment branch. build: all three tagspositioned correctly dev: no analyticsbranch working grep the two outputs to confirm both branches
Figure: grepping both outputs confirms the shared tags and the environment-specific one.

Gotchas & edge cases

Four transformIndexHtml edge cases Returning a raw string instead of descriptors loses positioning; the wrong order runs before Vite's asset tags exist so a preload cannot reference them; a script without defer or async blocks parsing; and only index.html is transformed so multi-page apps need each entry handled. raw string return→ use descriptors for placement wrong order for preload→ order post to see assets blocking script→ add defer or async multi-page app→ handle each entry HTML
Figure: to preload a hashed asset, run the handler post so Vite's emitted asset tags already exist.

Return descriptors, not strings. The symptom is a tag that lands in the wrong place or gets double-escaped. The root cause is returning a raw HTML string: Vite treats a string return as a full HTML replacement and, when it needs to place loose tags, appends them at a default spot with no injectTo control. The fix is to return HtmlTagDescriptor objects so Vite owns placement and attribute serialization. Confirm by grepping the output for your tag and checking it sits inside the head or body where you asked, not appended after </html> or mangled.

order for asset-aware tags. The symptom is a preload pointing at a filename that does not exist, or at nothing at all. The root cause is running the handler at pre, before Vite has injected or hashed its asset tags, so there is nothing for your descriptor to reference. The fix is order: 'post' plus reading ctx.bundle for the emitted filename, as in the CSS preload example above. Confirm by opening dist/index.html and checking the href of your preload matches an actual file in dist/assets.

Blocking scripts. The symptom is a measurable regression in first paint or Time to Interactive after adding an injected script. The root cause is a <script src> with neither defer nor async, which forces the parser to stop, fetch, and execute before continuing. The fix is to add defer: true (preserves execution order, runs after parse) or async: true (runs as soon as fetched, order not guaranteed) unless the script genuinely must run synchronously at that point in the document. Confirm in the network panel that the script no longer sits on the critical parsing path.

Multi-page apps. The symptom is tags appearing on index.html but missing from admin.html or other entries. The root cause is that transformIndexHtml runs once per HTML entry and a handler that assumes a single page silently only serves one of them. The fix is to branch on ctx.path or ctx.filename so each entry gets the descriptors it needs, or to return the shared tags unconditionally so every entry receives them. Confirm by grepping each emitted HTML file in dist, not just index.html.

When not to use this

Reach for transformIndexHtml only when the value depends on the environment or the build output. If the tag is a fixed constant — a charset, a viewport meta, a favicon link that never changes — put it in the static index.html template. The template is checked in, diffable, and obvious to the next reader; a plugin that injects a constant hides the tag behind a build step and makes the HTML lie about its own contents when read in isolation. The hook earns its complexity precisely when the template cannot express the value, and adds only noise when it can.

There are also higher-level tools that may fit better. If you are managing document head state that reacts to client-side routing — per-route titles and descriptions in an SPA — a runtime head manager updates tags after hydration, which transformIndexHtml cannot do because it runs at build time and produces one static document. Use the hook for tags that are correct for the initial HTML of a given entry, and a runtime solution for tags that must change as the user navigates without a full reload. Mixing the two is fine: inject the build-time defaults with the hook so the first paint is correct, and let the runtime manager take over from there.

Performance considerations

The hook runs once per entry per build, not per request, so its own cost is negligible against the total build time — do not micro-optimize the handler itself. What does move the needle is what you inject. Every preconnect and preload you add is a directive the browser acts on immediately, and they compete for the same connection and bandwidth budget as your actual assets. A handful of well-chosen resource hints speeds the critical path; a dozen speculative ones slow it by crowding out the resources that matter. Inject the hints you can justify with a waterfall, not every origin the app might eventually touch.

In dev, keep the handler cheap and side-effect-free. Because the hook participates in the dev server’s HTML transform, an expensive computation or a synchronous file read inside it runs on the request path and shows up as latency on every full page load and every time the entry HTML is re-served. If you must compute something costly — reading and hashing a file, calling out to another tool — gate it behind the ctx.bundle check so it only runs during build, or memoize it across invocations. The preloadEmittedCss example above is a good template: it does real work only when ctx.bundle is present and returns immediately otherwise.

Comparison with static template replacement

Vite already supports %VITE_*%-style and import.meta.env replacement in the HTML template, and for simple string substitution that is the lighter tool. Writing <meta name="build" content="%VITE_BUILD_ID%"> in index.html and defining VITE_BUILD_ID gets you an env-driven value with no plugin at all. Where template replacement stops is anything structural: it can substitute a string inside an existing tag, but it cannot add or remove a whole tag conditionally, cannot choose injectTo position, and cannot read the emitted bundle to reference a hashed filename. The rule of thumb is that template replacement handles values inside tags you already wrote, while transformIndexHtml handles the existence, placement, and build-derived attributes of tags you did not. When a task needs only the former, prefer it; when it needs the latter, the hook is the only option that reaches the bundle.