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.',
})
titleis the site name. A page's<title>is its own title followed by| Example, unless you settitleTemplate.titleTemplatereplaces that suffix;:titlestands for the page title. Set it tofalseto drop the suffix entirely.descriptionis rendered as<meta name="description">on every page that doesn't set its own. On a docs site that means one sentence repeated across most pages, which Search Console reports as duplicate descriptions and Google mostly replaces with text it picks from the page.
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/')),
},
})
- With a
basesuch as/docs/, append it tohostname:https://example.com/docs/. transformItemsis where you drop pages that shouldn't be in search, such as the ones you marknoindex.lastUpdated: truetakes each page's last git commit time; it shows on the page and adds<lastmod>to the sitemap. In CI, check out the full history (fetch-depth: 0inactions/checkout), or every page gets the same date.
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.
- Each page gets a title, a description, Open Graph and X tags written from its own content, and alt text for images that have none. These replace the inherited site description and generic titles.
- An existing
og:image, such as the one yourtransformHeadsets, is kept. Headlark doesn't create images; without one it uses an image already on the page, if there is a suitable one. - Your canonical and robots tags are kept. A page without a canonical gets one built from
--site-url, without.html, so it matches your links whencleanUrlsis on. - VitePress switches pages in the browser after the first load and sets the title from its route data, so the tab may show your original title after a click. Crawlers and link previewers load each page's HTML and get the rewritten tags.
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.