Download the template for this blog from Github

Next.js / Ghost Tutorial: 10 - SEO

At the beginning of this article series I already mentioned some of the advantages of static sites in terms of Search Engine Optimization, short SEO.
These consist mainly in the fact that the published pages contain the full html markup, which makes possible for search engines to crawl and index them. In contrast, client-side rendered pages, for instance, have the disadvantage that they basically consist of JavaScript code to "fetch" and render the payload from the server.

This is fine but it alone will not be enough to get a good SEO ranking.
Even if the topic of SEO is a science in itself (feeding legions of specialists and agencies) there are some basics that every web developer should be aware of when configuring his/her blog.

In this article I will discuss some of the most important ones and how they can be implemented with Ghost CMS and Next.js.
At this point, it's worth mentioning that Ghost templates provide quite a bit of SEO functionality out of the box.
But since we build our own template with Next.js (💪), we have to take action here ourselves.


Meta tags

Meta tags allow search engines to retrieve information about a web page. The meta tags are located in the header of an HTML document and are not visible to the visitor of the web page.

So far, we have used next/head in the layout component only to set the <title></title>, which we pass as a parameter from the calling page:

// File: /components/layout.js

import Head from 'next/head'

export default function Layout({ home, _title, children }) {
    <Head>
        <Layout _title={_title}>
        ...

The time has come to use this module to set meta tags as well.
Since these are numerous, it would not be a good idea to pass them as individual parameters to the layout component.
We will instead use an object that carries the meta tags as properties. This object will be "filled" on the calling page and "unpacked" in the layout.

Parameter _metaData

Let's start by changing the Layout component parameter list as follows:

// File: /components/layout.js
...
export default function Layout({ home, _metaData, children }) {
...

Note that we no longer expect _title but _metaData.
Inside <Head> we can now access the individual properties (we'll take a closer look at where these come from later on).

...
<Head>
        <title>{_metaData.n_title}</title>
        <meta property="og:title" content={_metaData.n_title} key="title" />
        <meta name="description" content={_metaData.n_description} />
        <meta name="HandheldFriendly" content={_metaData.n_HandheldFriendly} />
        <meta property="og:site_name" content={_metaData.p_og_site_name} />
        <meta property="og:type" content={_metaData.p_og_type} />
        <meta property="og:description" content={_metaData.p_og_description} />
        <meta property="og:image" content={_metaData.p_og_image} />
        <link rel="canonical" href={_metaData.n_canonical_url} />
        <meta
          property="article:published_time"
          content={_metaData.p_article_published_time}
        />
        <meta
          property="article:modified_time"
          content={_metaData.p_article_modified_time}
        />
        <meta
          property="article:article_tag"
          content={_metaData.p_article_tag}
        />
        <meta
          property="article:publisher"
          content={_metaData.p_article_publisher}
        />
        <meta name="twitter:card" content={_metaData.n_twitter_card} />
        <meta name="twitter:title" content={_metaData.n_twitter_title} />
        <meta
          name="twitter:description"
          content={_metaData.n_twitter_description}
        />
        <meta name="twitter:image" content={_metaData.n_twitter_image} />
        <meta name="twitter:label1" content={_metaData.n_twitter_label1} />
        <meta name="twitter:data1" content={_metaData.n_twitter_data1} />
        <meta name="twitter:label2" content={_metaData.n_twitter_label2} />
        <meta name="twitter:data2" content={_metaData.n_twitter_data2} />
        <meta name="twitter:site" content={_metaData.n_twitter_site} />
        <meta name="twitter:creator" content={_metaData.n_twitter_creator} />
        <meta name="generator" content={_metaData.n_generator} />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      ...

Of course, you can omit some of them or add some more according to your needs.

metaObject

After adjusting the parameter list of our layout component to accept an object with the meta tags, we turn our attention to the calling page, namely all the places that make use of the layout component.

index.js

Let's start with /pages/index.js by modifying the Home function as follows:

export default function Home({ posts }) {
  let facebook_handle = 'YOUR FACEBOOK HANDLE'
  let twitter_handle = '@YOUR TWITTER HANDLE'
  let title =
    'YOUR BLOG TITLE'
  let description =
    'A NICE DESCRIPTION FOR YOUR BLOG'
  let metaObject = {
    n_title: title,
    n_description: description,
    n_HandheldFriendly: 'True',
    n_canonical_url: 'THE URL OF YOUR HOMEPAGE',
    p_og_site_name: 'YOUR BLOG TITLE',
    p_og_type: 'website',
    p_og_description: description,
    p_og_image: '/images/profilepic.jpg', // TODO: Website picture
    p_article_published_time: '', // TODO: Today + format date!
    p_article_modified_time: '', // TODO: Today + format date!
    p_article_tag: 'Personal Blog',
    p_article_publisher: 'YOUR NAME',
    n_twitter_card: 'summary_large_image',
    n_twitter_title: title,
    n_twitter_description: description,
    n_twitter_image: '/images/profilepic.jpg', // TODO: Website picture
    n_twitter_label1: 'Written by',
    n_twitter_data1: 'YOUR NAME',
    n_twitter_label2: 'Filed under',
    n_twitter_data2: 'SOME LABELS OF YOUR CHOICE',
    n_twitter_site: twitter_handle,
    n_twitter_creator: twitter_handle,
    n_generator: 'Filipe Matos next.js + Ghost CMS',
  }
  return (
      <Layout home _metaData={metaObject}>
        <ul>
          {posts.map((post) => (
            <li>
              <PostPreviewCard blogpost={post} />
            </li>
          ))}
        </ul>
      </Layout>
  )
}

Since the index page doesn't fetch any data from Ghost, we are filling all properties manually. Change the values according to your preferences but bear in mind to keep all the fields as expected in Layout.

As you can see, we now call Layout with two parameters: home and _metaData.
We are done with index, let's move on.

The way metaObject is used in the individual pages is exactly the same, only the source for the values of the individual meta tags differs partially.

Before we continue with the remaining pages, let's turn our attention to the Ghost CMS Editor. Each article and blog page has a set of fields that are accessible from the sidebar and are intended for the management of meta tags.

Let us take a closer look at them:

ghost_sidebar_meta_tags

Next, we will access these fields to populate the object key value pairs. This assumes of course that you have provided your articles and blog pages with information, otherwise the meta tags will be empty as well.

/pages/blogpages/[slug].js

Now let's take care of the blogpages. Change the PostPage function as follows:

export default function PostPage({ page }) {
  // Render post title and content in the page from props
  let _title = page.meta_title + ' - YOUR BLOG TILE'
  let facebook_handle = 'YOUR FACEBOOK HANDLE'
  let twitter_handle = '@YOUR TWITTER HANDLE'
  let metaObject = {
    n_title: _title,
    n_description: page.meta_description,
    n_HandheldFriendly: 'True',
    n_canonical_url: page.canonical_url,
    p_og_site_name: 'YOUR BLOG TITLE',
    p_og_type: 'website',
    p_og_description: page.meta_description,
    p_og_image: page.feature_image,
    p_article_published_time: page.published_at, // TODO: format date!
    p_article_modified_time: page.updated_at, // TODO: format date!
    p_article_tag: '', // TODO: object with tags
    p_article_publisher: 'https://www.facebook.com/' + facebook_handle,
    n_twitter_card: 'summary_large_image',
    n_twitter_title: _title,
    n_twitter_description: page.meta_description,
    n_twitter_image: page.feature_image,
    n_twitter_label1: 'Written by',
    n_twitter_data1: 'YOUR NAME',
    n_twitter_label2: 'Filed under',
    n_twitter_data2: '', // TODO: object with tags
    n_twitter_site: twitter_handle,
    n_twitter_creator: twitter_handle,
    n_generator: 'Filipe Matos next.js + Ghost CMS',
  }
  return (
    <Layout _metaData={metaObject}>
      <div className="blogInnerHTML">
        <h1>{page.title}</h1>
        <div dangerouslySetInnerHTML={{ __html: page.html }} />
      </div>
    </Layout>
  )
}

As you can see, the structure of the object is the same but we are now fetching different information from the page object that was dynamically queried from Ghost.

/pages/posts/[slug].js

Again, let's change our component to use metaObject:

export default function PostPage({ post }) {
  // Render post title and content in the page from props
  let _title = post.meta_title + ' - YOUR BLOG TILE'
  let facebook_handle = 'YOUR FACEBOOK HANDLE'
  let twitter_handle = '@YOUR TWITTER HANDLE'
  let metaObject = {
    n_title: _title,
    n_description: post.meta_description,
    n_HandheldFriendly: 'True',
    n_canonical_url: post.canonical_url,
    p_og_site_name: 'YOUR BLOG TITLE',
    p_og_type: 'article',
    p_og_description: post.meta_description,
    p_og_image: post.feature_image,
    p_article_published_time: post.published_at, // TODO: format date!
    p_article_modified_time: post.updated_at, // TODO: format date!
    p_article_tag: '', // TODO: object with tags
    p_article_publisher: 'https://www.facebook.com/' + facebook_handle,
    n_twitter_card: 'summary_large_image',
    n_twitter_title: _title,
    n_twitter_description: post.meta_description,
    n_twitter_image: post.feature_image,
    n_twitter_label1: 'Written by',
    n_twitter_data1: 'YOUR NAME',
    n_twitter_label2: 'Filed under',
    n_twitter_data2: '', // TODO: object with tags
    n_twitter_site: twitter_handle,
    n_twitter_creator: twitter_handle,
    n_generator: 'Filipe Matos next.js + Ghost CMS',
  }
  return (
    <Layout _metaData={metaObject}>
      <div className="blogInnerHTML">
        <h1>{post.title}</h1>
        <div dangerouslySetInnerHTML={{ __html: post.html }} />
      </div>
      {/* <Link href="/Home" as={'/'}>
        <a>-- go to homepage --</a>
      </Link> */}
    </Layout>
  )
}

Only minor changes here:

  • Instead of page we now access post.
  • Instead of p_og_type: 'website' we now have p_og_type: 'article'.

Everything else should already be familiar.

/pages/tags/[slug].js

Let's change the TagPage function as follows:

export default function TagPage(tagData) {
  let _title = tagData.meta_title + ' - YOUR BLOG TILE'
  let facebook_handle = 'YOUR FACEBOOK HANDLE'
  let twitter_handle = '@YOUR TWITTER HANDLE'
  let metaObject = {
    n_title: _title,
    n_description: tagData.tag.meta_description,
    n_HandheldFriendly: 'True',
    n_canonical_url: '',
    p_og_site_name: 'YOUR BLOG TITLE',
    p_og_type: 'website',
    p_og_description: tagData.tag.meta_description,
    p_og_image: tagData.tag.feature_image,
    p_article_published_time: '', 
    p_article_modified_time: '', 
    p_article_tag: '',
    p_article_publisher: 'https://www.facebook.com/' + facebook_handle,
    n_twitter_card: 'summary_large_image',
    n_twitter_title: _title,
    n_twitter_description: tagData.tag.meta_description,
    n_twitter_image: tagData.tag.feature_image,
    n_twitter_label1: 'Written by',
    n_twitter_data1: 'YOUR NAME',
    n_twitter_label2: 'Filed under',
    n_twitter_data2: '',
    n_twitter_site: twitter_handle,
    n_twitter_creator: twitter_handle,
    n_generator: 'Filipe Matos next.js + Ghost CMS',
  }
  return (
    <Layout _metaData={metaObject}>
      <div className="my-10">
        <h2 className="py-1 mx-2 my-8  text-indigo-900 text-xl">
          Posts tagged with{' '}
          <span className="font-bold">{tagData.tag.name}</span>
        </h2>
        <ul>
          {tagData.posts.map((post) => (
            <li>
              <div className="cursor-pointer py-4 bg-gray-100 hover:bg-gray-200 m-2 rounded-md border-gray-200 border-2">
                <Link href="/posts/[slug]" as={`/posts/${post.slug}`}>
                  <div className="mx-10">
                    <h3 className="font-medium text-indigo-900">
                      {post.title}
                    </h3>

                    {/* TODO refactor START */}
                    <p className="text-gray-600 text-xs">
                      {new Intl.DateTimeFormat('default', {
                        year: 'numeric',
                        month: 'short',
                        day: 'numeric',
                      }).format(new Date(post.published_at))}
                    </p>
                    {/* TODO refactor END */}
                    <p className="italic text-gray-700 text-xs">
                      (reading time: {post.reading_time} min.)
                    </p>
                  </div>
                </Link>
              </div>
            </li>
          ))}
        </ul>
      </div>
    </Layout>
  )
}

The only difference here is that instead of post, we now access tagData.

/pages/tags/tagoverview.js

It only remains for us to revise the tagoverview page. Let's change the TagOverview function here as follows:

export default function TagOverview({ tagObjects }) {
  let _title = tagObjects.meta_title + ' - YOUR BLOG TILE'
  let description = 'Overview of all tags used on my blog articles'
  let facebook_handle = 'YOUR FACEBOOK HANDLE'
  let twitter_handle = '@YOUR TWITTER HANDLE'
  let metaObject = {
    n_title: _title,
    n_description: description,
    n_HandheldFriendly: 'True',
    n_canonical_url: 'https://YOURBLOGURL/tags/tagoverview',
    p_site_name: 'YOUR BLOG TITLE',
    p_og_type: 'website',
    p_og_description: description,
    p_og_image: '', // TODO:  create image for Tag overview
    p_article_published_time: '', 
    p_article_modified_time: '', // 
    p_article_tag: '', // 
    p_article_publisher: 'https://www.facebook.com/' + facebook_handle,
    n_twitter_card: 'summary_large_image',
    n_twitter_title: _title,
    n_twitter_description: description,
    n_twitter_image: '', // TODO:  create image for Tag overview
    n_twitter_label1: 'Written by',
    n_twitter_data1: 'YOUR NAME',
    n_twitter_label2: 'Filed under',
    n_twitter_data2: '', 
    n_twitter_site: twitter_handle,
    n_twitter_creator: twitter_handle,
    n_generator: 'Filipe Matos next.js + Ghost CMS',
  }
  return (
    <Layout _metaData={metaObject}>
      <div className="my-10">
        <h2 className="py-1 mx-2 my-8 text-indigo-900 text-2xl font-bold">
          Tag Overview
        </h2>
        {tagObjects.map((tag) => (
          <div className="mb-10">
            <h2 className="py-1 mx-2 my-8 text-indigo-900 text-xl">
              {tag.posts.length} post{tag.posts.length > 1 ? 's' : ''} tagged
              with <span className="font-bold">{tag.tag.name}</span>
            </h2>
            <ul>
              {tag.posts.map((post) => (
                <li className="cursor-pointer py-4 bg-gray-100 hover:bg-gray-200 m-2 rounded-md border-gray-200 border-2">
                  <Link href="/posts/[slug]" as={`/posts/${post.slug}`}>
                    <div className="mx-10">
                      <h3 className="font-medium text-indigo-900">
                        {post.title}
                      </h3>
                      <p className="text-gray-600 text-xs">
                        {new Intl.DateTimeFormat('default', {
                          year: 'numeric',
                          month: 'short',
                          day: 'numeric',
                        }).format(new Date(post.published_at))}
                      </p>
                    </div>
                  </Link>
                </li>
              ))}
            </ul>
          </div>
        ))}
      </div>
    </Layout>
  )
}

We are now finished with our meta tags part.

Sitemap

A sitemap describes your site structure by listing the contained pages and other media content, as well as their relation to each other.
Search engines are able to process sitemaps in order to index them into their database which in turn makes your site easier to find on the web.
You can learn more about sitemaps under the section "Further reading" at the bottom of this article.

Many of the most prominent publishing platforms such as Ghost, Wordpress and other CMS tools offer extensions to generate sitemaps from published posts.

Since we are exporting a static site using Next.js, such extensions do not help us.
Therefore, we use a script that generates a sitemap from the exported pages and saves it as sitemap.xml in the top folder.

This script is heavily based on this article by Jason Leung:
Generate Sitemap and RSS for Vercel Next.js App with Dynamic Routes | A Blog by Hangindev

First, create a top-level folder named scripts.
Inside this new folder, create the file generate-sitemap-postbuild.js with the following code:

// File: /scripts/generate-sitemap-postbuild.js

const fs = require('fs')
const path = require('path')

function isPageFile(filename) {
 return path.extname(filename) === '.html' && !filename.endsWith('404.html')
}

function getPageFiles(folders, file = []) {
 folders.map((folder) => {
  const entries = fs.readdirSync(folder, { withFileTypes: true })
  entries.forEach((entry) => {
   const absolutePath = path.resolve(folder, entry.name)
   if (entry.isDirectory()) {
    getPageFiles(absolutePath, files)
   } else if (isPageFile(absolutePath)) {
    files.push(absolutePath)
   }
  })
})
return files
}

function buildSiteMap(websiteUrl, outDirectory, pageFiles) {
 const urls = pageFiles.map((file) => {
  let f = file.split('/')
  let folder = file.split('/')[f.length - 2]
  return websiteUrl + '/' + folder + '/' + path.parse(file).name
 })
 // Hack: add index.html manually (adding it in the "folders" array isn't working)
 urls.push(websiteUrl + '/')
 const sitemap = <?xml version="1.0" encoding="UTF-8"?>
 <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
 
 $(urls
  .map(
   (url) => `
   <url>
    <loc>${url}</loc>
   </url>
   `,
  )
  .join('')}</urlset>
 `
 
 // write to the output static folder
 fs.writeFileSync(path.join(outDirectory, 'sitemap.xml'), sitemap)
}

function main() {
 const websiteUrl = 'https://YOUR_URL_HERE '
 const baseDirectory = "./.next/server/pages' 
 const outDirectory = './out/'
 const folders = [
  baseDirectory + '/blogpages',
  baseDirectory + '/posts',
  baseDirectory + '/tags',
 ]
 
 const pageFiles = getPageFiles(folders)
 buldSiteMap(websiteUrl, outDirectory, pageFiles)
}

main()

Remember to change the constant websiteUrl to the URL of your blog and to modify the folders array according to your folder structure.

We want the script to run each time we export the site, thus we need to add a new line to the "scripts" section of package.json:

"postexport": "node scripts/generate-sitemap-postbuild.js"

The whole section should now look like this:

  "scripts": { 
    "dev": "NODE_OPTIONS='--inspect' next dev", 
    "build": "next build", 
    "start": "next start", 
    "export": "next export", 
    "postexport": "node scripts/generate-sitemap-postbuild.js" 

That should be it. Build and export your static site and check for sitemap.xml under the /out/ folder.

robots.txt

Put a robots.txt file in the public directory of your project (this will translate to the root folder of your site after build), with the following content:

+User-agent: * 
+Disallow: 
+Sitemap: https://yoururlhere/sitemap.xml 

The URL tells the search engines where your sitemap is located at (it should point to your site, of course).


In summary

Although SEO requires much more than meta data, sitemaps and robots.txt (and even these can be optimized almost arbitrarily), your blog should now be ready to be crawled by search engines.
In addition, I recommend you to submit the website directly to the search engine providers like Google and Bing. For this purpose they provide specific portals, a few of which are listed below.

Happy blogging!
Filipe


Further reading