Add SEO to a Next.js blog
If you just fell in this post, this is part of a tutorial series on creating a blog using Next.js. You can read previous articles starting here or get the base code that I'm using to start this post.
At first I thought about using next-seo
package, but it is very rigid and incomplete in some areas, specially @type
for json+ld (this is not a tutorial on json+ld, but rather how to implement it in a project).
Why json+ld
A brief introduction to json+ld in case you never heard of. It's a way to structure content data of a page in a way that Google will rank you better (my definition, based on past experience).
Most people would add some meta
to the head
of the document and call it a day, it works, but Google prefers pages that it could show in special places (you've probably already seen search results with images, lists, search bars, etc). So if you add json+ld to your pages, Google will look at your content with different eyes.
You can find examples with images in their structured data docs.
Rolling our own SEO component
This is the typical SEO setup you are probably used to see: meta tags. The secret here is what we do with the children
prop.
import React from 'react'
import Head from 'next/head'
function SEO(props) {
const { children, title, description, image, canonical } = props
return (
<Head>
{/* General SEO */}
<meta name="description" content={description} />
<meta name="canonical" href={canonical} />
<meta name="author" content="Estevan Maito" />
<meta name="robots" content="index" />
{/* Social SEO */}
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:site_name" content="Estevan Maito" />
<meta property="og:type" content="website" />
<meta property="og:image" content={image} />
<meta name="twitter:card" content="summary_large_image" />
<title>{title}</title>
{children}
</Head>
)
}
export default SEO
Our post
template file will end like this (almost all of it's size now come from the SEO component):
import Nav from '@/components/Nav'
import SEO from '@/components/SEO'
function formatPath(path, replace = '') {
return path.replace(/\.md$/, replace)
}
export default function Post(frontMatter) {
return ({ children: content }) => {
const canonicalURL = `https://estevanmaito.me/${formatPath(frontMatter.__resourcePath)}`
const socialImageURL = `https://estevanmaito.me/${formatPath(
frontMatter.__resourcePath,
'.png'
).replace('blog', 'social')}`
return (
<>
<SEO
title={`${frontMatter.title} - Estevan Maito`}
description={frontMatter.description}
canonical={canonicalURL}
image={socialImageURL}
>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: `{
"@context": "https://schema.org",
"@type": "BlogPosting",
"url": "${canonicalURL}",
"name": "${frontMatter.title} - Estevan Maito",
"description": "${frontMatter.description}",
"datePublished": "${frontMatter.datePublished}",
"image": {
"@type": "ImageObject",
"url": "${socialImageURL}"
},
"author": {
"@type": "Person",
"name": "Estevan Maito",
"sameAs": [
"https://twitter.com/estevanmaito",
"https://github.com/estevanmaito"
]
}
}`,
}}
></script>
</SEO>
<Nav />
<div className="mx-auto my-10 prose">{content}</div>
</>
)
}
}
We pass the expected props to SEO
but then comes the content of it, which I'll explain in more detail below:
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: `{
"@context": "https://schema.org",
"@type": "BlogPosting",
"url": "${canonicalURL}",
"name": "${frontMatter.title} - Estevan Maito",
"description": "${frontMatter.description}",
"datePublished": "${frontMatter.datePublished}",
"image": {
"@type": "ImageObject",
"url": "${socialImageURL}"
},
"author": {
"@type": "Person",
"name": "Estevan Maito",
"sameAs": [
"https://twitter.com/estevanmaito",
"https://github.com/estevanmaito"
]
}
}`,
}}
></script>
Inside a script
with type="application/ld+json"
, we add the schema in a JSON format.
"@type": "BlogPosting" is telling the type of the current page. You'll see soon other types when I start to apply this to the home and about pages.
url
, name
and description
have the same values as the meta
tags. Note that we added a datePublished
that is coming from the frontMatter
. To do this, I had to add a line in the .md
file of the post:
datePublished: '2020-07-28T12:00Z'
The image
property needs it's own scope, so we give it a type
and pass the same image to url
.
To finish it, the author
property lets you inform a @Person
and some related urls for sameAs
(for a person, I usually list social networks).
This is (part of) the code for blog/index.js
:
const title = 'Blog - Estevan Maito'
const description = 'Random thoughts about web development, design, databases and code in general'
const canonical = 'https://estevanmaito.me/blog'
const image = 'https://estevanmaito.me/social-image.png'
return (
<>
<SEO title={title} description={description} canonical={canonical} image={image}>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: `{
"@context": "https://schema.org",
"@type": "Blog",
"url": "${canonical}",
"name": "${title}",
"description": "${description}",
"image": {
"@type": "ImageObject",
"url": "${image}"
},
"author": {
"@type": "Person",
"name": "Estevan Maito",
"sameAs": [
"https://twitter.com/estevanmaito",
"https://github.com/estevanmaito"
]
}
}`,
}}
></script>
</SEO>
...
Differences? We're now using "@type": "Blog". And for the home and about pages, the only change (besides the value of each property like url
, name
, etc) is the type again: "@type": "WebSite"
, which is documented here.