Set up your personal photo blog with markdown, hashtags and pagination with Gatsby & Contentful
You need a free Contentful account to get started.
- 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.
- Install dotenv:
npm i dotenv
- 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}`,
})
- Create new content delivery tokens via
https://app.contentful.com/spaces/YOUR_SPACE_ID/api/keys
- Install our source plugin:
npm i gatsby-source-contentful
- Duplicate
.env.example
to.env.development
&.env.production
- Adjust these files .env files:
- Both should contain your
Space ID
.env.development
should use yourContent Preview API - access token
.env.development
uncomment the line that sets the host tohttps://cpa.contentful.com
.env.production
should use yourContent Delivery API - access token
- Both should contain your
- Enable the
gatsby-source-contentful
plugin in yourgatsby-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",
},
},
- Execute
npm start
and visithtt
- 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.
- 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 & 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 & 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")
+ }
+ }
+ }
+`
Render multiple variants of the post images with Contentfuls Image API and the freshly released gatsby-plugin-image
:
- Execute:
npm i gatsby-plugin-image
- Add
gatsby-plugin-image
to the plugins in yourgatsby-config.js
- 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
+ )
}
- 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>
- Move
./src/pages/index.html
to./src/templates/post-listing.html
. Move and rename theindex.module.css
as well. - Install the plugin:
npm i gatsby-awesome-pagination
- 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"),
})
}
- 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
- 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")
}
}
`
- 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,
+ },
+ })
+ })
}
- 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>
)
}
- Install Remark transformer plugin for markdonw transformation:
npm i gatsby-transformer-remark
- Add
gatsby-transformer-remark
to the plugins in yourgatsby-config.js
- 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 {
- 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")
- 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}`,
},
})
})
- 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>
- Install react-swipeable:
npm i react-swipeable
- 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
- 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")
}
}
}
`
- 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 },
+ })
+ })
}
- 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