How to add metadata, canonical URLs, and structured data to your VuePress site

Oftentimes when utilizing a framework or other packaged codebase (ahem, such as VuePress), you have to get creative in order to get the exact functionality you're looking for. At the time of writing, VuePress is a bit limited as to what types of metadata and other structured data can be added out of the box to your pages for

SEOSearch Engine Optimization
.

In this post, I'll outline the solutions I came up with to add metadata, canonical URLs, and structured data to my site.

Why add additional metadata, structured data, and canonical URLs?

All websites should take care to incorporate metadata in order to make finding the page via search engines and other sites as easy as possible. This includes page meta tags, Schema.org structured data, Open Graph tags, and Twitter Card tags. For sites that are not pre-rendered and run as an SPA, this content is even more important since the page is initially loaded as an empty container (meaning search indexing bots don't have much to look at). This metadata helps to determine whether your page is relevant enough to display in search results, and can be used to give users a preview of the content they will find on your website.

All sites should also include a canonical URL link tag in the <head> of the page. Canonical URLs are a technical solution that essentially tells search engines which URL to send traffic to for content that it deems worthy as a search result. Another way to think of it is the canonical URL is the preferred URL for the content on the page.

How does VuePress handle metadata?

VuePress serves pre-rendered static HTML pages (which is way better); however, generating all of the desired tags and metadata is still, well, mostly a manual process.

Adding page metadata is supported; however, without using a plugin, the information must be defined in the .vuepress/config.js file, or has to be hard-coded into the YAML frontmatter block at the top of each individual page (the data cannot be dynamically generated in the YAML block). This creates a problem if, like me, you're kind of lazy and don't like doing the same task over and over. 🙄

At the time of writing, VuePress does not have a default way to add canonical URL tags onto a page. VuePress 1.7.1 supports adding the canonical URL to each page via frontmatter.canonicalUrl! 🎉

In order to add the desired metadata and other tags to my site, I needed to hook into the VuePress Options API and compile process so that I could access all of the data available in my posts and pages. Some of the data is also sourced from custom themeConfig properties outlined below. Since I couldn't find much information available on the web as to how to solve the issue, I figured I'd write it all up in a post in case anyone is looking to build a similar solution.

Now that you understand why this extra data should be included, let's walk through how you can replicate the functionality in your own project!

Add metadata to the VuePress page object with a plugin

To dynamically add our tags into the site during the build, we will take advantage of the extendPageData property of the VuePress Option API. This option allows you to extend or edit the $page object and is invoked once for each page at compile time, meaning we can directly add corresponding data to each unique page.

I have thought about releasing this plugin as an open-source package; however, at the time of writing, it's likely more helpful to manually install into your project on your own since customizing the desired tags and data really varies depending on the content and structure of your site.

The VuePress plugin included below is geared towards a site similar to this one, meaning most of the properties relate to a single author, and a site about a single person. It should be fairly easy to customize the tags you would like to use as well as their values.

Install dependencies

The plugin utilizes dayjs to parse, validate, manipulate, and displays dates. You'll need to install dayjs in your project in order to utilize the code that follows (or modify to substitute another date library).

Update the VuePress themeConfig

In order to feed all the valid data into the plugin, you will need to add some additional properties to the themeConfig object in your .vuepress/config.js file that will help extend the data for each page. If there are properties listed below that are unneeded for your particular project, you should be able to simply leave them out (or alternatively just pass a null value).

// .vuepress/config.js

module.exports = {
  // I'm only showing the themeConfig properties needed for the plugin
  // your site likely has many more
  title: 'Back to the Future', // The title of your site
  themeConfig: {
    domain: 'https://www.example.com', // Base URL of the VuePress site
    // Absolute path to the main image preview for the site
    // (Example location: ./vuepress/public/img/default-image.jpg)
    defaultImage: '/img/default-image.jpg',
    personalInfo: {
      name: 'Marty McFly', // Your full name
      email: 'marty@thepinheads.com', // Your email address
      website: 'https://www.example.com/', // Your website
      avatar: '/img/avatar.jpg', // Path to avatar/image
      company: 'Twin Pines Mall', // Employer
      title: 'Lead Guitarist', // Job title
      about: 'https://www.example.com/about/', // Link to page about the author
      gender: 'male', // Gender of author (or exclude if unwanted)
      social: [
        // Add an object for each of your social media sites
        // You may include others (pinterest, linkedin, etc.) just
        // add the objects to the array, following the same format
        {
          title: 'GitHub', // Social Site title
          // I use https://github.com/Justineo/vue-awesome for FontAwesome icons
          // you can omit this property if not needed
          icon: 'brands/github',
          account: 'username', // Your username at the site
          url: 'https://github.com/username', // Your profile/page URL on the site
        },
        {
          title: 'Twitter',
          icon: 'brands/twitter',
          // Your username at the site; do not include the @ sign for Twitter
          account: 'username',
          url: 'https://twitter.com/username',
        },
        {
          title: 'Instagram',
          icon: 'brands/instagram',
          account: 'username',
          url: 'https://instagram.com/username',
        },
      ],
    },
    // .. More themeConfig properties...
  },
}

Add page frontmatter properties

The plugin will utilize several required frontmatter properties, including title, description, image, etc. as shown in the block below to help extend the data of each page.

To allow the canonical URL to be utilized by both our plugin and our structured data component, you'll also need to manually add the canonicalUrl property here to the frontmatter of each page. Unfortunately, the $page.path is computed after plugins are initialized, so at the time of writing, manually adding the canonical URL to each page is the best solution.

To fully utilize the capabilities of the plugin, make sure you include all of the frontmatter properties shown below on each page of your site:

title: This is the page title
description: This is the page description that will be used

# Publish date of this page/post
date: 2020-08-22

# If using vuepress-plugin-blog, the category for the post
category: tutorials

# The list of tags for the post
tags:
  - VuePress
  - JavaScript

# Absolute path to the main image preview for this page
# Example location: ./vuepress/public/img/posts/page-image.jpg
image: /img/path/page-image.jpg

# Add canonical URL to the frontmatter of each page
# Make sure this is the final, permanent URL of the page
canonicalUrl: https://example.com/blog/path-to-this-page/

Pages on the site can also have additional tags and data added depending on your needs. If you have an About Me page, Contact page, or a Homepage (all included by default), or another "special" type of page you'd like to customize the tags for, simply add the corresponding entry shown below in only that page's frontmatter. Then, you can customize the plugin and structured data component by checking for the existence of the frontmatter page{NAME} property.

# Homepage
pageHome: true

# About page
pageAbout: true

# Contact page
pageContact: true

# Other custom special page
pageCustomName: true

Add plugin file structure

Next, you will need to add the plugin directory (suggested) and source file. Modify your site's .vuepress directory so that it includes the following:

.
|── config.js
└── .vuepress/
└── theme/
└── plugins/
└── dynamic-metadata.js

Add plugin code

Now let's add the dynamic metadata code to the new plugin file we created. The plugin extends the $page object via the extendPageData method of the VuePress Options API.

You need to copy and insert all of the code included below (click below to view the file content) into the dynamic-metadata.js file we just created.

Click to view the contents of dynamic-metadata.js
const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc')
const timezone = require('dayjs/plugin/timezone')
dayjs.extend(utc)
dayjs.extend(timezone)
// Customize the value to your timezone
// https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List
dayjs.tz.setDefault('America/Kentucky/Louisville')

module.exports = (options = {}, ctx) => ({
  extendPageData($page) {
    const { frontmatter, path } = $page

    const metadata = {
      title: frontmatter.title
        ? frontmatter.title.toString().replace(/["|'|\\]/g, '')
        : $page.title
        ? $page.title.toString().replace(/["|'|\\]/g, '')
        : null,
      description: frontmatter.description
        ? frontmatter.description
            .toString()
            .replace(/'/g, "'")
            .replace(/["|\\]/g, '')
        : null,
      url:
        frontmatter.canonicalUrl && typeof frontmatter.canonicalUrl === 'string'
          ? frontmatter.canonicalUrl.startsWith('http')
            ? frontmatter.canonicalUrl
            : ctx.siteConfig.themeConfig.domain + frontmatter.canonicalUrl
          : null,
      image:
        frontmatter.image && typeof frontmatter.image === 'string'
          ? frontmatter.image.startsWith('http')
            ? frontmatter.image
            : ctx.siteConfig.themeConfig.domain + frontmatter.image
          : null,
      type: meta_isArticle(path) ? 'article' : 'website',
      siteName: ctx.siteConfig.title || null,
      siteLogo: ctx.siteConfig.themeConfig.domain + ctx.siteConfig.themeConfig.defaultImage,
      published: frontmatter.date
        ? dayjs(frontmatter.date).toISOString()
        : $page.lastUpdated
        ? dayjs($page.lastUpdated).toISOString()
        : null,
      modified: $page.lastUpdated ? dayjs($page.lastUpdated).toISOString() : null,
      author: ctx.siteConfig.themeConfig.personalInfo ? ctx.siteConfig.themeConfig.personalInfo : null,
    }

    let meta_articleTags = []
    if (meta_isArticle(path)) {
      // Article info
      meta_articleTags.push(
        {
          property: 'article:published_time',
          content: metadata.published,
        },
        {
          property: 'article:modified_time',
          content: metadata.modified,
        },
        {
          property: 'article:section',
          content: frontmatter.category ? frontmatter.category.replace(/(?:^|\s)\S/g, (a) => a.toUpperCase()) : null,
        },
        {
          property: 'article:author',
          content: meta_isArticle(path) && metadata.author.name ? metadata.author.name : null,
        },
      )
      // Article tags
      // Todo: Currently, VuePress only injects the first tag
      if (frontmatter.tags && frontmatter.tags.length) {
        frontmatter.tags.forEach((tag, i) =>
          meta_articleTags.push({
            property: 'article:tag',
            content: tag,
          }),
        )
      }
    }

    let meta_profileTags = []
    if (frontmatter.pageAbout && metadata.author.name) {
      meta_profileTags.push(
        {
          property: 'profile:first_name',
          content: metadata.author.name.split(' ')[0],
        },
        {
          property: 'profile:last_name',
          content: metadata.author.name.split(' ')[1],
        },
        {
          property: 'profile:username',
          content: metadata.author.social.find((s) => s.title.toLowerCase() === 'twitter').account
            ? '@' + metadata.author.social.find((s) => s.title.toLowerCase() === 'twitter').account
            : null,
        },
        {
          property: 'profile:gender',
          content: metadata.author.gender ? metadata.author.gender : null,
        },
      )
    }

    let meta_dynamicMeta = [
      // General meta tags
      { name: 'description', content: metadata.description },
      {
        name: 'keywords',
        content: frontmatter.tags && frontmatter.tags.length ? frontmatter.tags.join(', ') : null,
      },
      { itemprop: 'name', content: metadata.title },
      { itemprop: 'description', content: metadata.description },
      {
        itemprop: 'image',
        content: metadata.image ? metadata.image : null,
      },
      // Open Graph
      { property: 'og:url', content: metadata.url },
      { property: 'og:type', content: metadata.type },
      { property: 'og:title', content: metadata.title },
      {
        property: 'og:image',
        content: metadata.image ? metadata.image : null,
      },
      {
        property: 'og:image:type',
        content: metadata.image && meta_getImageMimeType(metadata.image) ? meta_getImageMimeType(metadata.image) : null,
      },
      {
        property: 'og:image:alt',
        content: metadata.image ? metadata.title : null,
      },
      { property: 'og:description', content: metadata.description },
      { property: 'og:updated_time', content: metadata.modified },
      // Article info (if meta_isArticle)
      ...meta_articleTags,
      // Profile (if /about/ page)
      ...meta_profileTags,
      // Twitter Cards
      { property: 'twitter:url', content: metadata.url },
      { property: 'twitter:title', content: metadata.title },
      { property: 'twitter:description', content: metadata.description },
      {
        property: 'twitter:image',
        content: metadata.image ? metadata.image : null,
      },
      { property: 'twitter:image:alt', content: metadata.title },
    ]

    // Remove tags with empty content values
    meta_dynamicMeta = meta_dynamicMeta.filter((meta) => meta.content && meta.content !== '')
    // Combine frontmatter
    meta_dynamicMeta = [...(frontmatter.meta || []), ...meta_dynamicMeta]

    // Set frontmatter after removing duplicate entries
    meta_dynamicMeta = getUniqueArray(meta_dynamicMeta, ['name', 'content', 'itemprop', 'property'])

    frontmatter.meta = meta_dynamicMeta
  },
})

/**
 * Removes duplicate objects from an Array of JavaScript objects
 * @param {Array} arr Array of Objects
 * @param {Array} keyProps Array of keys to determine uniqueness
 */
function getUniqueArray(arr, keyProps) {
  return Object.values(
    arr.reduce((uniqueMap, entry) => {
      const key = keyProps.map((k) => entry[k]).join('|')
      if (!(key in uniqueMap)) uniqueMap[key] = entry
      return uniqueMap
    }, {}),
  )
}

/**
 * Returns boolean indicating if page is a blog post
 * @param {String} path Page path
 */
function meta_isArticle(path) {
  // Include path(s) where blog posts/articles are contained
  return ['articles', 'posts', '_posts', 'blog'].some((folder) => {
    let regex = new RegExp('^\\/' + folder + '\\/([\\w|-])+', 'gi')
    // Customize /category/ and /tag/ (or other sub-paths) below to exclude, if needed
    return regex.test(path) && path.indexOf(folder + '/category/') === -1 && path.indexOf(folder + '/tag/') === -1
  })
    ? true
    : false
}

/**
 * Returns the meme type of an image, based on the extension
 * @param {String} img Image path
 */
function meta_getImageMimeType(img) {
  if (!img) {
    return null
  }
  const regex = /\.([0-9a-z]+)(?:[\?#]|$)/i
  if (Array.isArray(img.match(regex)) && ['png', 'jpg', 'jpeg', 'gif'].some((ext) => img.match(regex)[1] === ext)) {
    return 'image/' + img.match(regex)[1]
  } else {
    return null
  }
}

TIP

See the highlighted lines in the dynamic-metadata.js file indicating where changes are likely necessary.

Initialize the plugin

Finally, in your .vuepress/theme/index.js file, add the plugin reference as shown below (only relevant lines shown):

// .vuepress/theme/index.js

const path = require('path')

module.exports = (options, ctx) => {
  const { themeConfig, siteConfig } = ctx

  return {
    plugins: [
      // Ensure the path below matches where you saved the dynamic-metadata.js file
      require(path.resolve(__dirname, './plugins/dynamic-metadata.js')),
    ],
  }
}

Now that the plugin is initialized, the metadata portion of our solution is complete! 🎉

Preview your project by running the local VuePress server and you will now see the metadata update after each page change with all of the corresponding tags in the head of the rendered HTML page.

Now we're ready to add the canonical URL to all pages.

Add the canonical URL to every page

Update

As of VuePress version 1.7.1 (and thanks to help from me 🎉) the canonical URL can now be set in the frontmatter of your VuePress pages by providing a canonicalUrl entry. Refer to the VuePress canonical URL documentation for more details.

Since we already added the canonical_url property in the frontmatter of all of the pages on our VuePress site, we're ready to add the corresponding <link> tag to the <head> of each page.

To add the canonical URL, you will need to add some methods to the GlobalLayout.vue file in your theme. If you do not utilize globalLayout in your theme (see here for more details) you will need to add the following code to another file that is used on every page of your site (e.g. in a layout component).

Inside your layout component, we'll add a new method that will manipulate the DOM to add/update the canonical URL tag in the <head> of the page. We will invoke this method both in the beforeMount and mounted hooks:

Click to view code for custom implementation
// Inside your layout component (preferrably GlobalLayout.vue)

beforeMount() {
    // Add/update the canonical URL on initial load
    this.updateCanonicalUrl()
},

mounted() {
    // Update the canonical URL after navigation
    this.$router.afterEach((to, from) => {
        this.updateCanonicalUrl()
    })
},

methods: {
    updateCanonicalUrl() {
        let canonicalUrl = document.getElementById('canonicalUrlLink')
        // If the element already exists, update the value
        if (canonicalUrl) {
            canonicalUrl.href = this.$site.themeConfig.domain + this.$page.path
        }
        // Otherwise, create the element and set the value
        else {
            canonicalUrl = document.createElement('link')
            canonicalUrl.id = 'canonicalUrlLink' // Ensure no other elements on your site use this ID. Customize as needed.
            canonicalUrl.rel = 'canonical'
            canonicalUrl.href = this.$site.themeConfig.domain + this.$page.path
            document.head.appendChild(canonicalUrl)
        }
    },
},

With these methods now in place, the canonical URL will be added to the initial page before mount, and then subsequently updated on every following page when $router.afterEach is called by Vue Router.

Next up, we will create a new component to utilize within our GlobalLayout.vue file that will inject Schema.org structured data into all the pages on our VuePress site.

Add structured data to VuePress pages

To add structured data to our pages, we will create a new Vue component that will utilize both the same $page data and frontmatter properties we previously added.

Create structured data component

Create a new SchemaStructuredData.vue file in your project wherever you store components (likely in .vuepress/theme/components). It should look something like this:

.
└── .vuepress/
└── theme/
└── components/
└── SchemaStructuredData.vue

Now let's add the code to the component file. You need to copy and insert all of the code included below (hidden for size) into the SchemaStructuredData.vue file we just created.

Click to view the contents of SchemaStructuredData.vue
<template>
  <script v-if="meta_structuredData" type="application/ld+json" v-html="meta_structuredData"></script>
</template>

<script>
  import * as dayjs from 'dayjs'
  import utc from 'dayjs/plugin/utc'
  import timezone from 'dayjs/plugin/timezone'

  dayjs.extend(utc)
  dayjs.extend(timezone)
  // Customize the value to your timezone (https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List)
  dayjs.tz.setDefault('America/Kentucky/Louisville')

  export default {
    name: 'SchemaStructuredData',

    computed: {
      meta_data() {
        if (!this.$page || !this.$site) {
          return
        }

        return {
          title: this.$page.title ? this.$page.title.toString().replace(/["|'|\\]/g, '') : null,
          description: this.$page.frontmatter.description
            ? this.$page.frontmatter.description.toString().replace(/["|'|\\]/g, '')
            : null,
          image: this.$page.frontmatter.image ? this.$site.themeConfig.domain + this.$page.frontmatter.image : null,
          type: this.meta_isArticle ? 'article' : 'website',
          siteName: this.$site.title || null,
          siteLogo: this.$site.themeConfig.domain + this.$site.themeConfig.defaultImage,
          published: dayjs(this.$page.frontmatter.date).toISOString() || dayjs(this.$page.lastUpdated).toISOString(),
          modified: dayjs(this.$page.lastUpdated).toISOString(),
          author: this.$site.themeConfig.personalInfo ? this.$site.themeConfig.personalInfo : null,
        }
      },
      // If page is a blog post
      meta_isArticle() {
        // Include path(s) where blog posts/articles are contained
        return ['articles', 'posts', '_posts', 'blog'].some((folder) => {
          let regex = new RegExp('^\\/' + folder + '\\/([\\w|-])+', 'gi')
          // Customize /category/ and /tag/ (or other sub-paths) below to exclude, if needed
          return (
            regex.test(this.$page.path) &&
            this.$page.path.indexOf(folder + '/category/') === -1 &&
            this.$page.path.indexOf(folder + '/tag/') === -1
          )
        })
          ? true
          : false
      },
      // Generate canonical URL (requires additional themeConfig data)
      meta_canonicalUrl() {
        if (!this.$page.frontmatter.canonicalUrl || !this.$page.path || !this.$site.themeConfig.domain) {
          return null
        }
        return this.$page.frontmatter.canonicalUrl
          ? this.$page.frontmatter.canonicalUrl
          : this.$site.themeConfig.domain + this.$page.path
      },
      meta_sameAs() {
        if (!this.meta_data.author.social || !this.meta_data.author.social.length) {
          return []
        }
        let socialLinks = []
        this.meta_data.author.social.forEach((s) => {
          if (s.url) {
            socialLinks.push(s.url)
          }
        })
        return socialLinks
      },
      // Generate Schema.org data for 'Person' (requires additional themeConfig data)
      schema_person() {
        if (!this.meta_data.author || !this.meta_data.author.name) {
          return null
        }

        return {
          '@context': 'https://schema.org/',
          '@type': 'Person',
          'name': this.meta_data.author.name,
          'url': this.$site.themeConfig.domain,
          'image': this.meta_data.author.avatar ? this.$site.themeConfig.domain + this.meta_data.author.avatar : null,
          'sameAs': this.meta_sameAs,
          'jobTitle': this.meta_data.author.title || null,
          'worksFor': {
            '@type': 'Organization',
            'name': this.meta_data.author.company || null,
          },
        }
      },
      // Inject Schema.org structured data
      meta_structuredData() {
        let structuredData = []
        // Home Page
        if (this.$page.frontmatter.pageHome) {
          structuredData.push({
            '@context': 'https://schema.org/',
            '@type': 'WebSite',
            'name':
              this.meta_data.title + (this.$page.frontmatter.subtitle ? ' | ' + this.$page.frontmatter.subtitle : '') ||
              null,
            'description': this.meta_data.description || null,
            'url': this.meta_canonicalUrl,
            'image': {
              '@type': 'ImageObject',
              'url': this.$site.themeConfig.domain + this.$site.themeConfig.defaultImage,
            },
            '@id': this.meta_canonicalUrl,
          })
        }
        // About Page
        else if (this.$page.frontmatter.pageAbout) {
          // Person
          structuredData.push(this.schema_person)
          // About Page
          structuredData.push({
            '@context': 'https://schema.org/',
            '@type': 'AboutPage',
            'name': this.meta_data.title || null,
            'description': this.meta_data.description || null,
            'url': this.meta_canonicalUrl,
            'primaryImageOfPage': {
              '@type': 'ImageObject',
              'url': this.meta_data.image || null,
            },
            'image': {
              '@type': 'ImageObject',
              'url': this.meta_data.image || null,
            },
            'mainEntityOfPage': {
              '@type': 'WebPage',
              '@id': this.meta_canonicalUrl,
            },
            'author': this.schema_person || null,
          })
          // Breadcrumbs
          structuredData.push({
            '@context': 'https://schema.org/',
            '@type': 'BreadcrumbList',
            'itemListElement': [
              {
                '@type': 'ListItem',
                'position': 1,
                'name': 'Home',
                'item': this.$site.themeConfig.domain || null,
              },
              {
                '@type': 'ListItem',
                'position': 2,
                'name': this.meta_data.title || null,
                'item': this.meta_canonicalUrl,
              },
            ],
          })
        }
        // Contact Page
        else if (this.$page.frontmatter.pageContact) {
          // Contact Page
          structuredData.push({
            '@context': 'https://schema.org/',
            '@type': 'ContactPage',
            'name': this.meta_data.title || null,
            'description': this.meta_data.description || null,
            'url': this.meta_canonicalUrl,
            'primaryImageOfPage': {
              '@type': 'ImageObject',
              'url': this.meta_data.image || null,
            },
            'image': {
              '@type': 'ImageObject',
              'url': this.meta_data.image || null,
            },
            'mainEntityOfPage': {
              '@type': 'WebPage',
              '@id': this.meta_canonicalUrl,
            },
            'author': this.schema_person || null,
          })
          // Breadcrumbs
          structuredData.push({
            '@context': 'https://schema.org/',
            '@type': 'BreadcrumbList',
            'itemListElement': [
              {
                '@type': 'ListItem',
                'position': 1,
                'name': 'Home',
                'item': this.$site.themeConfig.domain || null,
              },
              {
                '@type': 'ListItem',
                'position': 2,
                'name': this.meta_data.title || null,
                'item': this.meta_canonicalUrl,
              },
            ],
          })
        }
        // Article
        else if (this.meta_isArticle) {
          structuredData.push({
            '@context': 'https://schema.org/',
            '@type': 'Article',
            'name': this.meta_data.title || null,
            'description': this.meta_data.description || null,
            'url': this.meta_canonicalUrl,
            'discussionUrl': this.meta_canonicalUrl + '#comments',
            'mainEntityOfPage': {
              '@type': 'WebPage',
              '@id': this.meta_canonicalUrl,
            },
            'headline': this.meta_data.title || null,
            'articleSection': this.$page.frontmatter.category
              ? this.$page.frontmatter.category.replace(/(?:^|\s)\S/g, (a) => a.toUpperCase())
              : null,
            'keywords': this.$page.frontmatter.tags || [],
            'image': {
              '@type': 'ImageObject',
              'url': this.meta_data.image || null,
            },
            'author': this.schema_person || null,
            'publisher': {
              '@type': 'Organization',
              'name': this.meta_data.author.name || '',
              'url': this.$site.themeConfig.domain || null,
              'logo': {
                '@type': 'ImageObject',
                'url': this.meta_data.siteLogo || null,
              },
            },
            'datePublished': dayjs(this.meta_data.published).toISOString() || null,
            'dateModified': dayjs(this.meta_data.modified).toISOString() || null,
            'copyrightHolder': this.schema_person || null,
            'copyrightYear':
              dayjs(this.meta_data.published).format('YYYY') || dayjs(this.meta_data.modified).format('YYYY'),
          })

          // Breadcrumbs
          structuredData.push({
            '@context': 'https://schema.org/',
            '@type': 'BreadcrumbList',
            'itemListElement': [
              {
                '@type': 'ListItem',
                'position': 1,
                'name': 'Home',
                'item': this.$site.themeConfig.domain || null,
              },
              {
                '@type': 'ListItem',
                'position': 2,
                'name': 'Blog',
                'item': this.$site.themeConfig.domain + '/blog/',
              },
              {
                '@type': 'ListItem',
                'position': 3,
                'name': this.meta_data.title || null,
                'item': this.meta_canonicalUrl,
              },
            ],
          })
        }
        // Blog Index
        else if (this.$page.path === '/blog/') {
          // Breadcrumbs
          structuredData.push({
            '@context': 'https://schema.org/',
            '@type': 'BreadcrumbList',
            'itemListElement': [
              {
                '@type': 'ListItem',
                'position': 1,
                'name': 'Home',
                'item': this.$site.themeConfig.domain || null,
              },
              {
                '@type': 'ListItem',
                'position': 2,
                'name': this.meta_data.title || null,
                'item': this.meta_canonicalUrl,
              },
            ],
          })
        }
        // Blog Category or Tag Page
        else if (this.$page.path === '/blog/category/' || this.$page.path === '/blog/tag/') {
          // Breadcrumbs
          structuredData.push({
            '@context': 'https://schema.org/',
            '@type': 'BreadcrumbList',
            'itemListElement': [
              {
                '@type': 'ListItem',
                'position': 1,
                'name': 'Home',
                'item': this.$site.themeConfig.domain || null,
              },
              {
                '@type': 'ListItem',
                'position': 2,
                'name': 'Blog',
                'item': this.$site.themeConfig.domain + '/blog/',
              },
              {
                '@type': 'ListItem',
                'position': 3,
                'name': this.meta_data.title || null,
                'item': this.meta_canonicalUrl,
              },
            ],
          })
        }

        // Inject webpage for all pages
        structuredData.push({
          '@context': 'https://schema.org/',
          '@type': 'WebPage',
          'name': this.meta_data.title || null,
          'headline': this.meta_data.title || null,
          'description': this.meta_data.description || null,
          'url': this.meta_canonicalUrl,
          'mainEntityOfPage': {
            '@type': 'WebPage',
            '@id': this.meta_canonicalUrl,
          },
          'keywords': this.$page.frontmatter.tags || [],
          'primaryImageOfPage': {
            '@type': 'ImageObject',
            'url': this.meta_data.image || null,
          },
          'image': {
            '@type': 'ImageObject',
            'url': this.meta_data.image || null,
          },
          'author': this.schema_person || null,
          'publisher': {
            '@type': 'Organization',
            'name': this.meta_data.author.name || '',
            'url': this.$site.themeConfig.domain || null,
            'logo': {
              '@type': 'ImageObject',
              'url': this.meta_data.siteLogo || null,
            },
          },
          'datePublished': dayjs(this.meta_data.published).toISOString() || null,
          'dateModified': dayjs(this.meta_data.modified).toISOString() || null,
          'lastReviewed': dayjs(this.meta_data.modified).toISOString() || null,
          'copyrightHolder': this.schema_person || null,
          'copyrightYear':
            dayjs(this.meta_data.published).format('YYYY') || dayjs(this.meta_data.modified).format('YYYY'),
        })

        return JSON.stringify(structuredData, null, 4)
      },
    },
  }
</script>

TIP

See the highlighted lines in the SchemaStructuredData.vue file above indicating where customizations are likely necessary.

Utilize the component on each page of your site

To ensure our SchemaStructuredData.vue component adds data to every page, we will import it into our theme's GlobalLayout.vue file. If you do not utilize globalLayout in your theme (see here for more details) you should import into another layout file that is used on every page of your site (e.g. in a footer component).

Below, I'll show you how to import the component. Notice the :key property on the component, which assists Vue in knowing when the content in the component is stale. Only the relevant code is included:

<template>
  <div class="your-layout-component">
    <!-- Other layout code... -->
    <SchemaStructuredData :key="$page.path"></SchemaStructuredData>
  </div>
</template>

<script>
  // Ensure the path matches the location of the component in your project
  import SchemaStructuredData from '@theme/components/SchemaStructuredData.vue'

  export default {
    name: 'YourLayoutComponent',
    components: {
      SchemaStructuredData,
    },
  }
</script>

Once the component is in place, every page on our site will be dynamically updated with the corresponding structured data for search engines and other site scrapers to parse!

That's a wrap!

After implementing the solutions above, your site should now be prepped and ready for search engines and social media sites alike.

I really can't guess how every site would utilize the different aspects of this solution -- if you've made it this far, you likely already know what you're doing -- but if you need help with implementation, customizing the code to meet your needs, or have suggestions, reach out on twitter @adamdehaven.

Maybe in a future version of VuePress, some of this functionality will be baked right in. In the meantime, stay safe, stay healthy, and keep learning!