Skip to content

contentful-developer-relations/workshop-gatsby-photo-blog

Repository files navigation

Gatsby Contentful Photo Blog

Set up your personal photo blog with markdown, hashtags and pagination with Gatsby & Contentful

You need a free Contentful account to get started.

Step 0 - Preparation

  1. We will use environment variables to securily store and pass our connection credentials. These will to enable us to use Contentfuls preview feature while development and Contentfuls regular API for production.
  2. Install dotenv: npm i dotenv
  3. Add this to the top of your gatsby-config.js
const activeEnv = process.env.GATSBY_ACTIVE_ENV || process.env.NODE_ENV || "development"

console.log(`Using environment config: '${activeEnv}'`)

require("dotenv").config({
  path: `.env.${activeEnv}`,
})

Step 1 - Connect to contentful

  1. Create new content delivery tokens via https://app.contentful.com/spaces/YOUR_SPACE_ID/api/keys
  2. Install our source plugin: npm i gatsby-source-contentful
  3. Duplicate .env.example to .env.development & .env.production
  4. Adjust these files .env files:
    • Both should contain your Space ID
    • .env.development should use your Content Preview API - access token
    • .env.development uncomment the line that sets the host to https://cpa.contentful.com
    • .env.production should use your Content Delivery API - access token
  5. Enable the gatsby-source-contentful plugin in your gatsby-config.js
{
  resolve: `gatsby-source-contentful`,
  options: {
    spaceId: process.env.SPACE_ID,
    accessToken: process.env.CONTENTFUL_ACCESS_TOKEN,
    host: process.env.CONTENTFUL_HOST,
    environment: process.env.CONTENTFUL_ENVIRONMENT || "master",
  },
},

Step 1b - Starting the server and executing our first GraphQL query.

  1. Execute npm start and visit htt
  2. Run your first GraphQL query to see all the posts you just added to Contentful:
query MyQuery {
  allContentfulPost {
    nodes {
      title
      slug
      image {
        file {
          fileName
        }
      }
      body {
        body
      }
      hashtags
    }
  }
}

In the next step we are going to render these posts on the home page.

Step 2 - Render posts on home

  1. Add a page query to src/pages/index.js and render the results:
 import React from "react"
+import { graphql } from "gatsby"

 import Layout from "../components/layout"
 import SEO from "../components/seo"
+import PostTeaser from "../components/post-teaser"

-const IndexPage = () => (
-  <Layout>
-    <SEO title="Home" />
-    <h1>Hi people</h1>
-    <p>Welcome to your new Gatsby &amp; Contentful based photo blog.</p>
-  </Layout>
-)
+import * as styles from "./index.module.css"
+
+const IndexPage = ({ data }) => {
+  const posts = data.allContentfulPost.nodes
+
+  return (
+    <Layout>
+      <SEO title="Home" />
+      <h1>Hi people</h1>
+      <p>Welcome to your new Gatsby &amp; Contentful based photo blog.</p>
+      <div className={styles.postsWrapper}>
+        {posts.map(post => (
+          <PostTeaser post={post} key={post.slug} />
+        ))}
+      </div>
+    </Layout>
+  )
+}

 export default IndexPage
+
+export const query = graphql`
+  query IndexQuery {
+    allContentfulPost {
+      nodes {
+        title
+        slug
+        image {
+          file {
+            url
+          }
+        }
+        body {
+          body
+        }
+        hashtags
+        createdAt(formatString: "MMMM Do YYYY, H:mm")
+      }
+    }
+  }
+`

Step 2b - Load fitting image based on viewport

Render multiple variants of the post images with Contentfuls Image API and the freshly released gatsby-plugin-image:

  1. Execute: npm i gatsby-plugin-image
  2. Add gatsby-plugin-image to the plugins in your gatsby-config.js
  3. Adjust the query in src/pages/index.js to tell Gatsby to generate multiple variants of our post images:
         title
         slug
         image {
-          file {
-            url
-          }
+          gatsbyImageData(
+            aspectRatio: 1.778
+            cropFocus: CENTER
+            layout: CONSTRAINED
+            resizingBehavior: FILL
+            placeholder: BLURRED
+          )
         }
  1. Adjust rendering in src/components/post-teaser.js:
 import React from "react"
+import { GatsbyImage } from "gatsby-plugin-image"

 import Hashtag from "./hashtag"
 import * as styles from "./post-teaser.module.css"
   return (
     <div className={styles.wrapper}>
       <figure className={styles.figure}>
-        <img src={post.image.file.url} alt={post.title} />
+        <GatsbyImage image={post.image.gatsbyImageData} alt={post.title} />
         <figcaption className={styles.figcaption}>{post.title}</figcaption>
       </figure>
       <div className={styles.date}>Posted: {post.createdAt}</div>

Step 3 - Add pagination

  1. Move ./src/pages/index.html to ./src/templates/post-listing.html. Move and rename the index.module.css as well.
  2. Install the plugin: npm i gatsby-awesome-pagination
  3. Create the listing pages:

gatsby-node.js

/**
 * Implement Gatsby's Node APIs in this file.
 *
 * See: https://www.gatsbyjs.com/docs/node-apis/
 */
const path = require("path")

const { paginate } = require("gatsby-awesome-pagination")

exports.createPages = async ({ actions, graphql }) => {
  const { createPage } = actions

  const result = await graphql(
    `
      query IndexQuery {
        allContentfulPost(sort: { fields: [createdAt], order: DESC }) {
          nodes {
            title
            slug
          }
        }
      }
    `
  )

  if (result.errors) {
    throw result.errors
  }

  const blogPosts = result.data.allContentfulPost.nodes

  paginate({
    createPage,
    items: blogPosts,
    itemsPerPage: 2,
    pathPrefix: "/",
    component: path.resolve("./src/templates/post-listing.js"),
  })
}
  1. Adjust the query and rendering of the freshly renamed post-listing.js:
  • Add sort, skip and limit filters to the page query to ensure we get only posts for the desired page
  • Add pagination links to allow navigation back and forth
 import React from "react"
-import { graphql } from "gatsby"
+import { graphql, Link } from "gatsby"

 import Layout from "../components/layout"
 import SEO from "../components/seo"
 import PostTeaser from "../components/post-teaser"

-import * as styles from "./index.module.css"
+import * as styles from "./post-listing.module.css"

-const IndexPage = ({ data }) => {
+const PostListingTemplate = ({ data, pageContext }) => {
   const posts = data.allContentfulPost.nodes

   return (
...
           <PostTeaser post={post} key={post.slug} />
         ))}
       </div>
+      <div className={styles.pagination}>
+        {pageContext.previousPagePath && (
+          <Link to={pageContext.previousPagePath}>Previous</Link>
+        )}
+        {pageContext.nextPagePath && (
+          <Link to={pageContext.nextPagePath}>Next</Link>
+        )}
+      </div>
     </Layout>
   )
 }

-export default IndexPage
+export default PostListingTemplate

-export const query = graphql`
-  query IndexQuery {
-    allContentfulPost {
+export const pageQuery = graphql`
+  query PostListingQuery($skip: Int!, $limit: Int!) {
+    allContentfulPost(
+      sort: { fields: [createdAt], order: DESC }
+      skip: $skip
+      limit: $limit
+    ) {
       nodes {
         title
         slug

Step 4 - Create a detail page for every post

  1. Create a new template for the post detail pages:

./src/templates/post.js

import React from "react"
import { graphql } from "gatsby"
import { GatsbyImage } from "gatsby-plugin-image"

import Layout from "../components/layout"
import SEO from "../components/seo"
import Hashtag from "../components/hashtag"
import * as styles from "./post.module.css"

function PageTemplate({ data, pageContext }) {
  const post = data.contentfulPost

  return (
    <Layout>
      <SEO title="Home" />
      <div className={styles.imageWrapper}>
        <GatsbyImage image={post.image.gatsbyImageData} alt={post.title} />
      </div>
      <div className={styles.title}>{post.title}</div>
      <div className={styles.date}>Posted: {post.createdAt}</div>
      <div
        className={styles.body}
        dangerouslySetInnerHTML={{ __html: post.body.childMarkdownRemark.html }}
      />
      <div className={styles.hashtags}>
        {post.hashtags.map(hashtag => (
          <Hashtag key={hashtag} title={hashtag} />
        ))}
      </div>
    </Layout>
  )
}

export default PageTemplate

export const pageQuery = graphql`
  query postQuery($id: String!) {
    contentfulPost(id: { eq: $id }) {
      id
      title
      body {
        childMarkdownRemark {
          html
        }
      }
      hashtags
      image {
        gatsbyImageData(layout: FULL_WIDTH, placeholder: BLURRED)
      }
      createdAt(formatString: "MMMM Do YYYY, H:mm")
    }
  }
`
  1. Use the template to create the detail pages

gatsby-node.js

       query IndexQuery {
         allContentfulPost(sort: { fields: [createdAt], order: DESC }) {
           nodes {
+            id
             title
             slug
           }
         }
...
     pathPrefix: "/",
     component: path.resolve("./src/templates/post-listing.js"),
   })
+
+  // Detail pages
+  blogPosts.forEach(post => {
+    const { id, slug } = post
+
+    createPage({
+      path: `/post/${slug}`,
+      component: path.resolve(`./src/templates/post.js`),
+      context: {
+        id,
+      },
+    })
+  })
 }
  1. Link the posts from the listing pages

./src/templates/post-teaser.js

@@ -1,4 +1,6 @@
 import React from "react"
+import { Link } from "gatsby"
+
 import { GatsbyImage } from "gatsby-plugin-image"

 import Hashtag from "./hashtag"
...

 const PostTeaser = ({ post }) => {
   return (
-    <div className={styles.wrapper}>
+    <Link to={`/post/${post.slug}`} className={styles.wrapper}>
       <figure className={styles.figure}>
         <GatsbyImage image={post.image.gatsbyImageData} alt={post.title} />
         <figcaption className={styles.figcaption}>{post.title}</figcaption>
...
           <Hashtag key={hashtag} title={hashtag} />
         ))}
       </div>
-    </div>
+    </Link>
   )
 }

Step 4b - Render markdown

  1. Install Remark transformer plugin for markdonw transformation: npm i gatsby-transformer-remark
  2. Add gatsby-transformer-remark to the plugins in your gatsby-config.js
  3. Render post body markdown as HTML

./src/templates/post.js

       </div>
       <div className={styles.title}>{post.title}</div>
       <div className={styles.date}>Posted: {post.createdAt}</div>
-      <div className={styles.body}>{post.body.body}</div>
+      <div
+        className={styles.body}
+        dangerouslySetInnerHTML={{ __html: post.body.childMarkdownRemark.html }}
+      />
       <div className={styles.hashtags}>
         {post.hashtags.map(hashtag => (
           <Hashtag key={hashtag} title={hashtag} />
...
       id
       title
       body {
-        body
+        childMarkdownRemark {
+          html
+        }
       }
       hashtags
       image {
  1. Render body in teaser as excerpt

./src/components/post-teaser.js

         <figcaption className={styles.figcaption}>{post.title}</figcaption>
       </figure>
       <div className={styles.date}>Posted: {post.createdAt}</div>
+      <div className={styles.excerpt}>
+        {post.body.childMarkdownRemark.excerpt}
+      </div>
       <div className={styles.hashtags}>
         {post.hashtags.map(hashtag => (
           <Hashtag key={hashtag} title={hashtag} />

./src/templates/post-listing.js

           )
         }
         body {
-          body
+          childMarkdownRemark {
+            excerpt(format: PLAIN, truncate: false, pruneLength: 60)
+          }
         }
         hashtags
         createdAt(formatString: "MMMM Do YYYY, H:mm")

Step 5 - Add detail page pagination

  1. Add paths to previous and next post to the post detail page context

gatsby-node.js

   })

   // Detail pages
-  blogPosts.forEach(post => {
+  blogPosts.forEach((post, i) => {
     const { id, slug } = post

     createPage({
...
       component: path.resolve(`./src/templates/post.js`),
       context: {
         id,
+        previousPost: blogPosts?.[i - 1] && `/post/${blogPosts[i - 1].slug}`,
+        nextPost: blogPosts?.[i + 1] && `/post/${blogPosts[i + 1].slug}`,
       },
     })
   })
  1. Render previous and next buttons based on page context data

./src/templates/post.js

 import React from "react"
-import { graphql } from "gatsby"
+import { graphql, Link } from "gatsby"
 import { GatsbyImage } from "gatsby-plugin-image"

 import Layout from "../components/layout"
...
 function PageTemplate({ data, pageContext }) {
   const post = data.contentfulPost

+  const { previousPost, nextPost } = pageContext
+
   return (
     <Layout>
       <SEO title={post.title} />
       <div className={styles.imageWrapper}>
         <GatsbyImage image={post.image.gatsbyImageData} alt={post.title} />
+        {previousPost && (
+          <Link
+            to={previousPost}
+            className={`${styles.controls} ${styles.controlPrevious}`}
+            title="Previous post"
+          >
+
+          </Link>
+        )}
+        {nextPost && (
+          <Link
+            to={nextPost}
+            className={`${styles.controls} ${styles.controlNext}`}
+            title="Next post"
+          >
+
+          </Link>
+        )}
       </div>
       <div className={styles.title}>{post.title}</div>
       <div className={styles.date}>Posted: {post.createdAt}</div>

Step 5b - Enable swipe gestures for post detail navigation

  1. Install react-swipeable: npm i react-swipeable
  2. Add swipe gesture handlers to the posts image

./src/templates/post.js

 import React from "react"
-import { graphql, Link } from "gatsby"
+import { graphql, Link, navigate } from "gatsby"
 import { GatsbyImage } from "gatsby-plugin-image"
+import { useSwipeable } from "react-swipeable"

 import Layout from "../components/layout"
 import SEO from "../components/seo"
...

   const { previousPost, nextPost } = pageContext

+  const swipeHandlers = useSwipeable({
+    onSwiped: eventData => {
+      const { dir } = eventData
+
+      if (dir === "Right" && previousPost) {
+        navigate(previousPost)
+      }
+      if (dir === "Left" && nextPost) {
+        navigate(nextPost)
+      }
+    },
+    preventDefaultTouchmoveEvent: true,
+  })
+
   return (
     <Layout>
       <SEO title={post.title} />
-      <div className={styles.imageWrapper}>
+      <div {...swipeHandlers} className={styles.imageWrapper}>
         <GatsbyImage image={post.image.gatsbyImageData} alt={post.title} />
         {previousPost && (
           <Link

Step 6 - Hashtag listing pages

  1. Create the new template for the hashtag listing pages. ./src/templates/hashtag-listing.js
import React from "react"
import { graphql, Link } from "gatsby"

import Layout from "../components/layout"
import SEO from "../components/seo"
import PostTeaser from "../components/post-teaser"

import * as styles from "./post-listing.module.css"

const TagListingTemplate = ({ data, pageContext }) => {
  const posts = data.allContentfulPost.nodes
  const { hashtag } = pageContext
  const title = `#${hashtag}`

  return (
    <Layout title={title}>
      <SEO title={title} />
      <div className={styles.postsWrapper}>
        {posts.map(post => (
          <PostTeaser post={post} key={post.slug} />
        ))}
      </div>
      <div className={styles.pagination}>
        {pageContext.previousPagePath && (
          <Link to={pageContext.previousPagePath}>◂ Previous</Link>
        )}
        {pageContext.nextPagePath && (
          <Link to={pageContext.nextPagePath}>Next ▸</Link>
        )}
      </div>
    </Layout>
  )
}

export default TagListingTemplate

export const pageQuery = graphql`
  query TagListingQuery($skip: Int!, $limit: Int!, $hashtag: String!) {
    allContentfulPost(
      filter: { hashtags: { eq: $hashtag } }
      sort: { fields: [createdAt], order: DESC }
      skip: $skip
      limit: $limit
    ) {
      nodes {
        title
        slug
        image {
          gatsbyImageData(
            aspectRatio: 1.778
            width: 960
            cropFocus: CENTER
            layout: CONSTRAINED
            resizingBehavior: FILL
            placeholder: BLURRED
          )
        }
        body {
          childMarkdownRemark {
            excerpt(format: PLAIN, truncate: false, pruneLength: 60)
          }
        }
        hashtags
        createdAt(formatString: "MMMM Do YYYY, H:mm")
      }
    }
  }
`
  1. Group posts by hashtag and create paginated pages

gatsby-node.js

             id
             title
             slug
+            hashtags
           }
         }
       }
...
     component: path.resolve("./src/templates/post-listing.js"),
   })

+  const hashtagsMap = new Map()
+
   // Detail pages
   blogPosts.forEach((post, i) => {
-    const { id, slug } = post
+    const { id, slug, hashtags } = post
+
+    // Gather unique hashtags
+    hashtags.map(hashtag => {
+      const postList = hashtagsMap.get(hashtag) || []
+      postList.push(post)
+      hashtagsMap.set(hashtag, postList)
+    })

     createPage({
       path: `/post/${slug}`,
...
       },
     })
   })
+
+  // Hashtag listing pages
+  hashtagsMap.forEach((postList, hashtag) => {
+    paginate({
+      createPage,
+      items: postList,
+      itemsPerPage: 2,
+      pathPrefix: `/hashtag/${hashtag}`,
+      component: path.resolve("./src/templates/hashtag-listing.js"),
+      context: { hashtag },
+    })
+  })
 }
  1. Link hashtag component to the listing pages

./src/components/hashtag.js

 import React from "react"
+import { Link } from "gatsby"

 import * as styles from "./hashtag.module.css"

 const Hashtag = ({ title }) => {
-  return <div className={styles.tag}>#{title}</div>
+  return (
+    <Link to={`/hashtag/${title}`} className={styles.tag}>
+      #{title}
+    </Link>
+  )
 }

 export default Hashtag

About

Workshop: Create your personal image blog with Gatsby and Contenful

Topics

Resources

License

Stars

Watchers

Forks