vincentdnl

How to add tags to gatsby-theme-blog?

February 16th, 2020

I recently wrote an article about how to refactor from starter to theme. Now we want tags on our site but the article from the Gatsby documentation doesn't cover the case where you used a theme (here gatsby-theme-blog) and want to extend the theme by shadowing its components.

The end result we want looks like this:

We will add a bar containing tags and the number of articles for the specific tag. We will also add tags link on every article on the posts.js and post.js page.

The tags page will look like the home page but will only contain the articles for a specific tag.

We will also do some refactoring on the queries so that the code looks cleaner!

Adding tags to your articles

The markdown field tags is already declared in gatsby-theme-blog. It means that you can start adding fields to the header of your articles, like this one for example:

---
...
tags: ["GatsbyJS"]
---

It should be an array of tags containing their display name as strings.

Creating tags-query.js template and TagsPage component

First, we will create a tags-query.js template in src/gatsby-theme-blog/templates/. We will follow the same convention as the gatsby-theme-blog: queries go in the template folder and React components go in components folder.

import {graphql} from "gatsby"
import TagsPage from "../components/tags"

export default TagsPage

export const query = graphql`
    query($tag: String) {
        site {
            ...SiteQuery
        }
        allBlogPost(
            sort: { fields: [date, title], order: DESC },
            limit: 1000,
            filter: { tags: { eq: $tag }}
        ) {
            totalCount
            ...EdgesQuery
        }
        tagsGroup: allBlogPost(limit: 1000) {
            ...TagsGroupQuery
        }
    }
`

Note that I used some GraphQL fragments for some parts of the query. After finishing the development, I saw that a lot of query code were duplicated so I created a src/graphQLFragments.js file that looks like this:

import {graphql} from "gatsby"

export const siteQuery = graphql`
    fragment SiteQuery on Site {
        siteMetadata {
            title
            social {
                name
                url
            }
        }
    }
`

export const tagsGroupQuery = graphql`
    fragment TagsGroupQuery on BlogPostConnection {
        group(field: tags) {
            fieldValue,
            totalCount
        }
    }
`

export const edgesQuery = graphql`
    fragment EdgesQuery on BlogPostConnection {
        edges {
            node {
                id
                excerpt
                slug
                title
                date(formatString: "MMMM DD, YYYY")
                tags
            }
        }
    }
`

These fragments will be used multiple time so we wont repeat ourselves.

Note that the TagsGroupQuery includes the totalCount of the article within a specific tag.

Also let's create the React component associated with this query: src/gatsby-theme-blog/components/tags.js

import React, {Fragment} from "react"
import {Link} from "gatsby"
import {css, Styled} from "theme-ui"

import Layout from "gatsby-theme-blog/src/components/layout"
import SEO from "gatsby-theme-blog/src/components/seo"
import Footer from "../components/home-footer"
import TagsBar from "./TagsBar"
import {Tag} from "../components/Tag"

const Tags = ({pageContext, data, location}) => {
    const {tag} = pageContext
    const {edges} = data.allBlogPost
    const siteTitle = data.site.siteMetadata.title
    const socialLinks = data.site.siteMetadata.social
    const tagsGroup = data.tagsGroup
    return (
        <Layout location={location} title={siteTitle}>
            <TagsBar {...{tagsGroup, activeTag: tag}}/>
            <main>
                {edges.map(({node}) => {
                    const title = node.title || node.slug
                    const keywords = node.keywords || []
                    return (
                        <Fragment key={node.slug}>
                            <SEO title="Tags" keywords={keywords}/>
                            <div>
                                <Styled.h2
                                    css={css({
                                        mb: 1,
                                    })}
                                >
                                    <Styled.a
                                        as={Link}
                                        css={css({
                                            textDecoration: `none`,
                                        })}
                                        to={node.slug}
                                    >
                                        {title}
                                    </Styled.a>
                                </Styled.h2>
                                <small>{node.date}</small>
                                {node.tags.length ? <>{` `} &bull; {` `}</> : null}
                                <small>{node.tags.map(
                                    (tag, i) =>
                                        <Tag tag={tag} isLast={i < node.tags.length - 1}/>
                                )}</small>
                                <Styled.p>{node.excerpt}</Styled.p>
                            </div>
                        </Fragment>
                    )
                })}
            </main>
            <Footer socialLinks={socialLinks}/>
        </Layout>
    )
}

export default Tags

We will also create Tag.jsx:

import React from "react"
import {Styled, css} from "theme-ui"
import _ from "lodash"
import {Link} from "gatsby"

export const TagCount = ({tag, isLast, isActive}) =>
    <>
        <Styled.a
            as={Link}
            css={css({
                fontWeight: isActive ? "bold" : "normal"
            })}
            to={`/tags/${_.kebabCase(tag.fieldValue)}`}
        >
            #{tag.fieldValue} ({tag.totalCount})
        </Styled.a>{isLast && ", "}
    </>


export const Tag = ({tag, isLast}) =>
    <>
        <Styled.a
            as={Link}
            to={`/tags/${_.kebabCase(tag)}`}
        >
            #{tag}
        </Styled.a>{isLast && ", "}
    </>

This file contains the reusable Tag elements that will be used in our pages.

The TagsBar component (src/gatsby-theme-blog/components/TagsBar.tsx) will use the TagCount and display the tag name and their count:

import React from "react"
import {TagCount} from "./Tag"
import {Styled} from "theme-ui"
import _ from "lodash"


const TagsBar = ({tagsGroup, activeTag}) => {
    return <Styled.p>
        <span>These are the topics I like to write about: </span>
        {
            _.orderBy(tagsGroup.group, ["totalCount", "fieldValue"], ["desc", "desc"]).map(
                (tag, i) =>
                    <TagCount
                        key={i}
                        tag={tag}
                        isLast={i < tagsGroup.group.length - 1}
                        isActive={tag.fieldValue === activeTag}
                    />,
            )
        }
    </Styled.p>
}

export default TagsBar

Now, we should create tags pages!

Extending gatsby-node.js to add the creation of tags pages

To create the tag pages we will have to add a gatsby-node.js file at the root of our project (if it doesn't exist yet). Fortunately, we will only have to add our own createPage calls. The other pages are created in gatsby-theme-blog-core and we won't have to handle this part.

const _ = require(`lodash`)

const withDefaults = require(`gatsby-theme-blog-core/utils/default-options`)

// These templates are simply data-fetching wrappers that import components
const PostsTemplate = require.resolve(`./src/gatsby-theme-blog/templates/posts-query`)
const TagsTemplate = require.resolve(`./src/gatsby-theme-blog/templates/tags-query`)

exports.createPages = async ({graphql, actions, reporter}, themeOptions) => {
    const {createPage} = actions
    const {basePath} = withDefaults(themeOptions)

    const result = await graphql(`
    {
      tagsGroup: allBlogPost(limit: 1000) {
        group(field: tags) {
          fieldValue
        }
      }
    }
    `)

    if (result.errors) {
        reporter.panic(result.errors)
    }

    const tags = result.data.tagsGroup.group

    // Make tag pages
    tags.forEach(tag => {
        createPage({
            path: `/tags/${_.kebabCase(tag.fieldValue)}/`,
            component: TagsTemplate,
            context: {
                tag: tag.fieldValue,
            },
        })
    })

    // Make posts page
    createPage({
        path: basePath,
        component: PostsTemplate,
        context: {},
    })
}

The posts page is created here. But it should have been created by the theme, right? It is. But we are going shadow the posts-query.js template so that it also fetches the tags so we need to recreate this page here.

Shadowing posts to get a list of tags on your posts list

Now let's shadow src/gatsby-theme-blog/templates/posts-query.js:

import {graphql} from "gatsby"
import PostsPage from "../components/posts"

export default PostsPage

export const query = graphql`
    query PostsQueryAndTagsGroup {
        site {
            ...SiteQuery
        }
        allBlogPost(sort: { fields: [date, title], order: DESC }, limit: 1000) {
            ...EdgesQuery
        }
        tagsGroup: allBlogPost(limit: 1000) {
            ...TagsGroupQuery
        }
    }
`

Again, we are using our fragments to not repeat ourselves.

Then the src/gatsby-theme-blog/components/posts.js file should look like this:

import React, {Fragment} from "react"
import {Link} from "gatsby"
import {css, Styled} from "theme-ui"

import Layout from "gatsby-theme-blog/src/components/layout"
import SEO from "gatsby-theme-blog/src/components/seo"
import Footer from "../components/home-footer"
import TagsBar from "./TagsBar"
import {Tag} from "./Tag"

const Posts = (data) => {
    const {location, posts, tagsGroup, siteTitle, socialLinks} = data
    return (
        <Layout location={location} title={siteTitle}>
            <TagsBar {...{tagsGroup}}/>
            <main>
                {posts.map(({node}) => {
                    const title = node.title || node.slug
                    const keywords = node.keywords || []
                    return (
                        <Fragment key={node.slug}>
                            <SEO title="Home" keywords={keywords}/>
                            <div>
                                <Styled.h2
                                    css={css({
                                        mb: 1,
                                    })}
                                >
                                    <Styled.a
                                        as={Link}
                                        css={css({
                                            textDecoration: `none`,
                                        })}
                                        to={node.slug}
                                    >
                                        {title}
                                    </Styled.a>
                                </Styled.h2>
                                <small>{node.date}</small>
                                {node.tags.length ? <>{` `} &bull; {` `}</> : null}
                                <small>{node.tags.map(
                                    (tag, i) =>
                                        <Tag tag={tag} isLast={i < node.tags.length - 1}/>
                                )}</small>
                                <Styled.p>{node.excerpt}</Styled.p>
                            </div>
                        </Fragment>
                    )
                })}
            </main>
            <Footer socialLinks={socialLinks}/>
        </Layout>
    )
}

// export default Posts
export default ({location, data}) => {
    const {site, allBlogPost, tagsGroup} = data
    return (
        <Posts
            location={location}
            posts={allBlogPost.edges}
            tagsGroup={tagsGroup}
            siteTitle={site.siteMetadata.title}
            socialLinks={site.siteMetadata.social}
        />
    )
}

Note that some code can be put in common between the tags and posts pages.

Shadowing post.js to have tags in the post

Let's create the src/gatsby-theme-blog/components/post.js. I used the one that is present in gatsby-theme-blog and modified it:

import React from "react"
import {Styled, css} from "theme-ui"

import PostFooter from "gatsby-theme-blog/src/components/post-footer"
import Layout from "gatsby-theme-blog/src/components/layout"
import SEO from "gatsby-theme-blog/src/components/seo"
import {MDXRenderer} from "gatsby-plugin-mdx"
import {Tag} from "./Tag"

const Post = ({
                  data: {
                      post,
                      site: {
                          siteMetadata: {title},
                      },
                  },
                  location,
                  previous,
                  next,
              }) => (
    <Layout location={location} title={title}>
        <SEO title={post.title} description={post.excerpt}/>
        <main>
            <Styled.h1>{post.title}</Styled.h1>
            <Styled.p
                css={css({
                    fontSize: 1,
                    mt: -3,
                    mb: 3,
                })}
            >
                {post.date}
                {` `} &bull; {` `}
                {post.tags.map(
                    (tag, i) =>
                        <Tag tag={tag} isLast={i < post.tags.length - 1}/>
                )}
            </Styled.p>
            <MDXRenderer>{post.body}</MDXRenderer>
        </main>
        <PostFooter {...{previous, next}} />
    </Layout>
)

export default Post

This will display the tags on one article, near the date.

Conclusion

We now have a functional tags system in our Gatsby blog and we made it by extending the gatsby-theme-blog. It avoided us a lot of boilerplate and it's easier to keep our blog up to date by updating the base theme instead of being in "ejected" mode with the starter.

The next step would probably be to create a blog theme with tags so that it's easier for other developers to use it!