DEV Community

Cover image for How to add metadata, canonical URLs, and structured data to your VuePress site
Adam DeHaven
Adam DeHaven

Posted on • Edited on • Originally published at adamdehaven.com

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 SEO.

In this post, I'll outline the solutions I came up with to add metadata, canonical URLs, and structured data to my site as referred to previously in my writeup about building my new site with VuePress.

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. If you have any feedback or questions, feel free to post a message in the comments πŸ‘‡.

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).

npm install dayjs
Enter fullscreen mode Exit fullscreen mode

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
        defaultImage: '/img/default-image.jpg', // Absolute path to the main image preview for the site (Example location: ./vuepress/public/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 (Example: ./vuepress/public/img/avatar.jpg)
            company: 'Twin Pines Mall', // Employer
            title: 'Lead Guitarist', // Job title
            about: 'https://www.example.com/about/', // Link to page about yourself/the author
            gender: 'male', // Gender of author, accepts 'male' or 'female' (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
                    icon: 'brands/github', // I use https://github.com/Justineo/vue-awesome for FontAwesome icons - you can omit this property if not needed
                    account: 'username', // Your username at the site
                    url: 'https://github.com/username', // Your profile/page URL on the site
                },
                {
                    title: 'Twitter', // Social Site title
                    icon: 'brands/twitter', // I use https://github.com/Justineo/vue-awesome for FontAwesome icons - you can omit this property if not needed
                    account: 'username', // Your username at the site; do not include the @ sign for Twitter
                    url: 'https://twitter.com/username', // Your profile/page URL on the site
                },
                {
                    title: 'Instagram', // Social Site title
                    icon: 'brands/instagram', // I use https://github.com/Justineo/vue-awesome for FontAwesome icons - you can omit this property if not needed
                    account: 'username', // Your username at the site
                    url: 'https://instagram.com/username', // Your profile/page URL on the site
                },
            ],
        },
        // .. More themeConfig properties...
    },
}
Enter fullscreen mode Exit fullscreen mode

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/
---
Enter fullscreen mode Exit fullscreen mode

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
---
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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')),
        ],
    }
}
Enter fullscreen mode Exit fullscreen mode

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

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](#add-page-frontmatter-properties) in the frontmatter of all of the pages on our VuePress site, we're ready to add the corresponding `` tag to the `` 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](https://vuepress.vuejs.org/theme/option-api.html#globallayout)) 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 `` of the page. We will invoke this method both in the `beforeMount` and `mounted` hooks:
// 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)
        }
    },
},
Enter fullscreen mode Exit fullscreen mode
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](https://router.vuejs.org/guide/advanced/navigation-guards.html#global-after-hooks).

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
Enter fullscreen mode Exit fullscreen mode

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.

<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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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, leave a note down in the comments, below.

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!

Top comments (0)