Enhance Website Visibility: SEO Metadata And Sitemap

- Lire en français
Resources: garabit

For a content-focused website, Search Engine Optimization (SEO) is essential. It enables users to find and access your content via search engines like Google or Bing, which is the aim of this blog. If this is not your objective, feel free to skip this article.

Note

Search Engine Optimization (SEO) enhances the quality and quantity of website traffic from search engines. It focuses on organic traffic instead of direct or paid traffic.

The Fundamentals

Let us begin by integrating basic metadata into our website.

Initially, we must specify the language of our website by adding the lang key in the config.mts file:

ts
import { defineConfig } from 'vitepress'

export default defineConfig({
  lang: 'en-US',
})

Next, we establish the title and description of our website by inserting the title and description keys in the config.mts file:

ts
import { defineConfig } from 'vitepress'

export default defineConfig({
  // ...
  title: 'Garabit',
  description: 'A place that reflects my thoughts and ideas',
})

The title will also serve as the suffix for each page's title. For instance, the title of the blog page will be Blog | Garabit.

We can verify these additions by examining the generated HTML code in the .vitepress/dist directory.

html
<!DOCTYPE html>
<html lang="en-US" dir="ltr">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>Blog | Garabit</title>
    <meta name="description" content="The latest articles, tutorials, and resources on web development, programming, and more.">
    <meta name="generator" content="VitePress v1.4.1">
    <!-- ... -->
  </head>
</html>

Note

VitePress incorporates its own metadata into the generated HTML code, resulting in more meta tags than we manually added.

Static Metadata

Let's now add some static metadata to enhance SEO capabilities. These metadata apply uniformly across all website pages.

To add static metadata, insert the head key in the config.mts file:

ts
import { defineConfig } from 'vitepress'

export default defineConfig({
  // ...
  head: [
    ['meta', { name: 'twitter:site', content: '@soubiran_' }], // Please, change this before deploying
    ['meta', { name: 'twitter:card', content: 'summary_large_image' }],
    ['meta', { property: 'og:image:width', content: '1200' }],
    ['meta', { property: 'og:image:height', content: '630' }],
    ['meta', { property: 'og:image:type', content: 'image/png' }],
    ['meta', { property: 'og:site_name', content: 'Garabit' }],
    ['meta', { property: 'og:type', content: 'website' }],
  ],
})

These metadata are utilized by social platforms like Twitter and Facebook to display a preview of the website when shared. The og prefix indicates Open Graph, a protocol that enables web pages to become rich objects in a social network.

Note

Future articles will explore automatic Open Graph image generation for blog posts.

Dynamic Metadata

For optimal SEO, each page should feature an Open Graph title, description, and canonical URL. These are dynamic metadata and vary for each page.

We have two methods for adding dynamic metadata to our site:

  • In each Markdown file's frontmatter: Though simple, this method is not the most maintainable. Adding new metadata requires updating every Markdown file and writing YAML with nested arrays, which can be cumbersome.
md
---
title: My Awesome Blog Post
description: A blog post that will change your life
head:
  - - meta
    - property: og:title
      content: My Awesome Blog Post
  - - meta
    - property: og:description
      content: A blog post that will change your life
---

Due to redundancy and complexity, I wish to avoid this method.

Thankfully, a second method exists:

  • In the config.mts file: A more maintainable solution involves using the transformPageData option to insert dynamic metadata into each page, modifying the data prior to rendering.
ts
import { defineConfig } from 'vitepress'

export default defineConfig({
  // ...
  transformPageData: (page, { siteConfig }) => {
    // Initialize the `head` frontmatter if it doesn't exist.
    pageData.frontmatter.head ??= []

    // Add basic meta tags to the frontmatter.
    pageData.frontmatter.head.push(
      [
        'meta',
        {
          property: 'og:title',
          content:
            pageData.frontmatter.title || pageData.title || siteConfig.site.title,
        },
      ],
      [
        'meta',
        {
          name: 'twitter:title',
          content:
            pageData.frontmatter.title || pageData.title || siteConfig.site.title,
        },
      ],
      [
        'meta',
        {
          property: 'og:description',
          content:
            pageData.frontmatter.description || pageData.description || siteConfig.site.description,
        },
      ],
      [
        'meta',
        {
          name: 'twitter:description',
          content:
            pageData.frontmatter.description || pageData.description || siteConfig.site.description,
        },
      ],
    )
  },
})

This approach is efficient, allowing for the addition of multiple dynamic metadata without modifying every Markdown file or duplicating metadata. In that case, we define some fallbacks in case the metadata is missing.

Note

Should you wish to add more dynamic metadata, you can do so in the same manner as the Open Graph metadata using this technique.

Canonical URL

The canonical URL specifies the preferred version of a web page, preventing duplicate content issues. Without it, search engines may struggle to identify the same page regardless of URL variations, potentially delaying indexing. This situation can occur more frequently than anticipated. For instance, a blog post shared in a newsletter could include a tracking parameter, leading the search engine to index the URL with the parameter instead of the original URL, or worse, not at all. Thus, it is crucial to add a canonical URL to each page.

In the transformPageData function, we can access the path of the page to infer the canonical URL.

Initially, install the ufo package, a URL manipulation library by UnJS:

bash
pnpm add -D ufo

Next, employ this package to craft the canonical URL:

ts
import { joinURL, withoutTrailingSlash } from 'ufo'
import { defineConfig } from 'vitepress'

export default defineConfig({
  // ...
  transformPageData: (page) => {
    // Initialize the `head` frontmatter if it doesn't exist.
    pageData.frontmatter.head ??= []

    // ...

    // Create the canonical URL
    pageData.frontmatter.head.push([
      'link',
      {
        rel: 'canonical',
        href: joinURL(
          'https://garabit.barbapapazes.dev', // Modify this prior to deployment
          withoutTrailingSlash(pageData.filePath.replace(/(index)?\.md$/, '')),
        ),
      },
    ])

    pageData.frontmatter.head.push([
      'meta',
      {
        property: 'og:url',
        content: joinURL(
          'https://garabit.barbapapazes.dev', // Modify this prior to deployment
          withoutTrailingSlash(pageData.filePath.replace(/(index)?\.md$/, '')),
        ),
      },
    ])
  },
})

The joinURL function combines the website's base URL with the page's path, addressing the / to prevent // in the URL. The withoutTrailingSlash function removes the trailing slash from the page's path. If you prefer a trailing slash, withTrailingSlash can be used alternatively. Additionally, index.md or .md must be removed from the path to generate a clean URL.

Each page now features a canonical URL. Inspect the generated HTML code to verify the implementation.

html
<!DOCTYPE html>
<html lang="en-US" dir="ltr">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>My Awesome Blog Post | Garabit</title>
    <meta name="description" content="A blog post that will change your life">
    <meta name="twitter:site" content="@soubiran_">
    <meta name="twitter:card" content="summary_large_image">
    <meta property="og:image:width" content="1200">
    <meta property="og:image:height" content="630">
    <meta property="og:image:type" content="image/png">
    <meta property="og:site_name" content="Garabit">
    <meta property="og:type" content="website">
    <meta property="og:url" content="https://garabit.barbapapazes.dev">
    <meta property="og:title" content="My Awesome Blog Post">
    <meta name="twitter:title" content="My Awesome Blog Post">
    <meta property="og:description" content="A blog post that will change your life">
    <meta name="twitter:description" content="A blog post that will change your life">
    <link rel="canonical" href="https://garabit.barbapapazes.dev/my-awesome-blog-post">
    <!-- ... -->
  </head>
</html>

Concerning clean URLs, you might notice the .html extension at our URLs' end. VitePress generates HTML files for each Markdown file, simplifying deployment since the path /my-exceptional-blog-post.html directly correlates to the my-exceptional-blog-post.html file. However, this isn't very SEO-friendly or visually appealing. By setting cleanUrls to true in the config.mts file, we can eliminate the .html extension from the URL:

ts
import { defineConfig } from 'vitepress'

export default defineConfig({
  // ...
  cleanUrls: true,
})

When this setting is enabled, the server must be configured to serve the /my-exceptional-blog-post URL with the my-exceptional-blog-post.html file, without a redirect. Most deployment services like Cloudflare Pages, Vercel, or Netlify accommodate this. Custom servers may need reverse proxy configuration adjustments.

Warning

Always ensure the canonical URL matches the server-served URL. If you opt not to enable this setting, append the .html extension to the canonical URL.

Sitemap

A sitemap assists search engines in indexing a website. It's an XML file listing all website URLs, allowing search engines to efficiently discover new URLs without entirely crawling the site.

VitePress simplifies sitemap generation. Just add three lines of code in the config.mts file:

ts
import { defineConfig } from 'vitepress'

export default defineConfig({
  // ...
  sitemap: {
    hostname: 'https://garabit.barbapapazes.dev', // Please, change this before deploying
  },
})

This will produce a sitemap.xml file in the .vitepress/dist directory when building the website with pnpm run build. You can submit this file to the SEO console of your preferred search engine.

xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset
  xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
  xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"
  xmlns:xhtml="http://www.w3.org/1999/xhtml"
  xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"
  xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
  <url>
    <loc>https://garabit.barbapapazes.dev/blog.html</loc>
  </url>
  <url>
    <loc>https://garabit.barbapapazes.dev/blog/getting-started-with-vue3-and-vite.html</loc>
  </url>
  <url>
    <loc>https://garabit.barbapapazes.dev/blog/getting-started-with-laravel.html</loc>
  </url>
  <url>
    <loc>https://garabit.barbapapazes.dev/</loc>
  </url>
</urlset>

And there you have it, the sitemap is primed for inclusion in the SEO console of your choice.

Layout Automation

To conclude this section, let's streamline our blog's article layout. Previously, we needed to manually set the layout key to blog-show in each article's frontmatter. While not challenging, this step is forgettable and repetitive across articles, much like metadata.

To automate this, reuse the transformPageData option to insert the layout key into the frontmatter of each article, verifying if the filePath is located in the src/blog directory.

ts
import { defineConfig } from 'vitepress'

export default defineConfig({
  // ...
  transformPageData: (page) => {
    // Set layout for blog articles
    if (pageData.filePath.startsWith('blog/')) {
      pageData.frontmatter.layout = 'blog-show'
    }
  },
})

This approach allows for removing the layout key from each article's frontmatter, reducing oversight risks and minimizing redundancy. This exemplifies the strength of automation!