Updated 2026-10-26

VitePress SEO: meta tags, Open Graph, canonical URLs and the sitemap

VitePress prerenders every Markdown page to static HTML and then hydrates it as a single-page app, so crawlers get real content without running JavaScript. Each page gets a <title> and a <meta name="description">, and that's all: no Open Graph tags, no X card and no canonical link unless you add them. This guide goes through the settings in order, for VitePress 1.x (VitePress: site config).

1. Title and description for the whole site

Set the site-wide values in .vitepress/config.mts:

import { defineConfig } from 'vitepress'

export default defineConfig({
  title: 'Example',
  titleTemplate: ':title | Example Docs',
  description: 'Reference and guides for the Example CLI.',
})

2. Per-page frontmatter

Each page can set its own values (frontmatter config):

---
title: Configure the CLI
titleTemplate: :title | Example Docs
description: Every option in the config file, with defaults and an example for monorepos.
head:
  - - meta
    - name: robots
      content: noindex
---

Without a frontmatter title, VitePress uses the page's first # heading. That's usually fine, but two pages that both start with # Overview end up with the same title. Frontmatter head uses the same [tag, attributes] format as the config and is added after the site's head entries.

To find the pages that still inherit the site description, build and count:

grep -rhoE --include='*.html' '<meta name="description" content="[^"]*"' .vitepress/dist | sort | uniq -c | sort -rn | head

Any count above 1 is a shared description. Duplicate titles and descriptions on a docs site has a transformPageData hook that lists them during the build.

3. Site-wide head tags

The config's head array adds tags to every page. Each entry is [tag, attributes] or [tag, attributes, innerHTML]:

export default defineConfig({
  head: [
    ['link', { rel: 'icon', href: '/favicon.svg' }],
    ['meta', { property: 'og:site_name', content: 'Example Docs' }],
    ['meta', { name: 'twitter:site', content: '@example' }],
  ],
})

Static entries can't carry per-page values such as og:title or og:url. For those you need one of the two build hooks.

4. Per-page Open Graph tags with transformHead

transformHead runs once per page during vitepress build (not in dev) and returns extra head entries, which VitePress merges into the page. Its context includes the composed title, the description and the page's pageData:

const site = 'https://docs.example.com'

export default defineConfig({
  cleanUrls: true,
  transformHead({ pageData, title, description }) {
    const path = pageData.relativePath
      .replace(/(^|\/)index\.md$/, '$1')
      .replace(/\.md$/, '')
    const image = pageData.frontmatter.image ?? '/og/default.png'
    return [
      ['meta', { property: 'og:type', content: 'article' }],
      ['meta', { property: 'og:title', content: title }],
      ['meta', { property: 'og:description', content: description }],
      ['meta', { property: 'og:url', content: `${site}/${path}` }],
      ['meta', { property: 'og:image', content: new URL(image, site).href }],
      ['meta', { name: 'twitter:card', content: 'summary_large_image' }],
    ]
  },
})

Put the default image in public/og/default.png (a PNG or JPG around 1200×630; previewers don't render SVG) and set image in the frontmatter of pages that deserve their own card. og:image must be absolute, which is why the example builds it from site. If a card still has no picture, og:image not showing covers the other causes.

The other hook, transformPageData, can push the same tags onto pageData.frontmatter.head. It also runs in dev and its tags follow client-side navigation, but crawlers and link previewers load each URL as a fresh page, so transformHead is enough for them.

5. A canonical URL

VitePress writes no canonical link. The docs' own example adds one in transformPageData (adding a canonical URL):

transformPageData(pageData) {
  const canonicalUrl = `https://docs.example.com/${pageData.relativePath}`
    .replace(/index\.md$/, '')
    .replace(/\.md$/, '.html')
  pageData.frontmatter.head ??= []
  pageData.frontmatter.head.push(['link', { rel: 'canonical', href: canonicalUrl }])
}

That version ends in .html, which matches the default links. With cleanUrls: true, change the last replace to .replace(/\.md$/, '') so the canonical matches the URLs your pages link to.

6. cleanUrls

By default VitePress links to /guide/intro.html. cleanUrls: true makes links and the sitemap use /guide/intro; the file on disk is still guide/intro.html, so the host has to serve it for the extensionless URL without a redirect. Netlify, Vercel, Cloudflare Pages and GitHub Pages do. Check one page after deploying:

curl -sI https://docs.example.com/guide/intro | grep -iE '^(HTTP|location)'

A 200 is what you want. A 301 or 308 to the .html URL means your canonicals and sitemap point at redirects; turn cleanUrls off or fix the host's rewrite.

7. The sitemap and lastUpdated

VitePress writes sitemap.xml to the output folder when you set a hostname (sitemap generation):

export default defineConfig({
  lastUpdated: true,
  sitemap: {
    hostname: 'https://docs.example.com',
    transformItems: (items) => items.filter((item) => !item.url.startsWith('drafts/')),
  },
})

Add Sitemap: https://docs.example.com/sitemap.xml to public/robots.txt and submit it in Search Console.

8. Check the build

Everything above ends up in .vitepress/dist (or your outDir). After vitepress build docs, list pages without a preview image:

grep -rL --include='*.html' 'property="og:image"' .vitepress/dist

Then check a few deployed pages with the free SEO checker, which shows the Google result and the LinkedIn, Slack and X cards, or the site checker for up to 50 pages from your sitemap.

Automating it with Headlark

The config is one-time work. Writing a distinct title and description for every page, and keeping them current, is the part that slips. Headlark's build step runs after vitepress build on the HTML in .vitepress/dist:

"build": "vitepress build docs && headlark docs/.vitepress/dist --site-url https://docs.example.com"

The path after headlark is the output folder: docs/.vitepress/dist when your source is in docs/, .vitepress/dist when it's at the root. With a base, include it: --site-url https://example.github.io/repo/. Install it with npm i -D headlark and set HEADLARK_KEY in the build environment.

Try it

The free plan covers one site and 25 pages. Add your site, copy its secret key, and your next build ships with SEO.