Using Gatsby for a web app with dynamic content — A case study
TL;DR
- A basic overview of what a static site is and how it is different from a dynamic one.
- What Gatsby is and its various features.
- An action plan on what it takes to build a website with dynamic content using Gatsby.
- Caveats and current solutions for it.
- A step by step tutorial where we build out a product listing app with pagination and also programmatic page creation from an external data source. You can find the code to the whole app here.
- Final thoughts on using Gatsby for such an app.
Content
Why a static site?
What is this Gatsby you speak of?
What about a website with dynamic content?
The Action Plan
- Adding our external data into Gatsby’s internal data store. This way our react components can then query this data using GraphQL.
- Generate a static listing page with the external data.
- Dynamically create static pages based on our data. For example, for an e-commerce web app, we would require individual pages for each product that we list on our listing page.
- Make sure that our listing page is paginated.
Caveats
- You can have a cron job running that rebuilds your app every 30 minutes or at a time interval of your choosing or just triggers a rebuild each time your data updates.
- Gatsby is built to behave almost exactly like a normal React application, which means that you can make network APIs in your react components and update the data shown after the page loads. These are called Hybrid Components, you can read more about them here.
Hasura gives you instant GraphQL APIs and can be used to query your Gatsby data store. Here's a video on how to get started with Hasura, if you need it.
Executing the action plan
What are we going to build?
- Have a paginated listing page which gets its data from an external source
- For each item shown on the listing page, create another page which will show more details about the item
Getting started
$ npm install --global gatsby-cli
$ gatsby new my-listing-app
Adding the external data to the gatsby data store
$ npm install --save algoliasearch
const algoliasearch = require('algoliasearch');
algoliaIndex.search({
const algoliaClient = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_API_KEY);
const algoliaIndex = algoliaClient.initIndex(ALGOLIA_INDEX);
query: '', //empty query string returns all the data
hitsPerPage: 1000 // This is the maximum data that can be fetched
},
function searchDone(err, content) {
if (err) {
console.log(err);
} else {
console.log(content);
}
});
- Fetch the data during the
gatsby build
step. - Create new nodes for the fetched data.
const algoliasearch = require('algoliasearch');
const ALGOLIA_API_KEY = '<YOUR_ALGOLIA_API_KEY>';
const algoliaClient = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_API_KEY);
exports.sourceNodes = async ({ boundActionCreators }) => {
const ALGOLIA_INDEX = '<YOUR_ALGOLIA_INDEX_NAME>';
const ALGOLIA_APP_ID = '<YOUR_ALGOLIA_APP_ID';
const algoliaIndex = algoliaClient.initIndex(ALGOLIA_INDEX);
return new Promise((resolve, reject) => {
algoliaIndex.search({
query: '', //fetch all
hitsPerPage: 1000
},
function searchDone(err, content) {
if (err) {
console.log('\nError');
console.log(err);
reject();
} else {
console.log('\nSuccess');
console.log(content);
resolve();
}
}
);
});
};
const algoliasearch = require('algoliasearch');
.....
exports.sourceNodes = async ({ boundActionCreators }) => {
const crypto = require('crypto');
const { createNode } = boundActionCreators;
return new Promise((resolve, reject) => {
algoliaIndex.search({
query: '', //fetch all
hitsPerPage: 1000
},
function searchDone(err, content) {
if (err) {
console.log('\nError');
console.log(err);
reject();
} else {
content.hits.forEach(content => {
const productNode = {
id: content.objectID,
parent: null,
children: [],
internal: {
type: `Product`,
contentDigest: crypto
.createHash(`md5`)
.update(JSON.stringify(content))
.digest(`hex`),
},
name: content.name,
stock: content.stock,
price: content.price,
}
createNode(productNode);
});
resolve();
}
}
);
});
};
query {
allProduct {
edges {
node {
id
name
stock
price
}
}
}
}
Accessing the data in the react component using GraphQL queries
import React from 'react'
export default class ProductList extends React.Component {
render() {
}
export const query = graphql`
return (
<div>
<h1>Products</h1>
{
this.props.data.allProduct.edges.map((edge, index) => {
const product = edge.node
return (
<div key={product.id}>{product.name}</div>
)
})
}
</div>
);
}
query AllProducts {
allProduct {
edges {
node {
id
name
}
}
}
}
`
Creating dynamic pages for each product
...
exports.sourceNodes = async ({ boundActionCreators }) => {....}
exports.createPages = ({ graphql, boundActionCreators }) => {
const path = require(`path`);
... const { createPage } = boundActionCreators
return new Promise((resolve, reject) => {
graphql(`
{
allProduct {
edges {
node {
id
}
}
}
}
` ).then(result => {
result.data.allProduct.edges.forEach(({ node }) => {
createPage({
path: `product/${node.id}`,
component: path.resolve(`./src/templates/productDetail.js`),
context: {
productId: node.id
},
})
})
resolve()
})
}).catch(error => {
console.log(error)
reject()
})
};
path
: This is the path we want for our page. In this case, it is going to be/product/<productId>
.component
: This is the location of our react component which will be used to render the page. For the time being, just leave this as shown in the code snippet. We will create the component next. Note: we are using a gatsby provided library calledpath
for this. Ensure that you have imported it withconst path = require('path')
.context
: is the data you want this component to get by default. In this case, we are sending it the product id, so that it can use it to fetch the complete data for the particular product.
import React from 'react'
export default class ProductDetail extends React.Component {
render() {
}
export const query = graphql`
const product = this.props.data.product;
return (
<div>
<h1>{product.name}</h1>
<div>Price: {product.price}</div>
<div>Stock: {product.stock}</div>
</div>
);
}
query ProductDetails($productId: String!) {
product(id: { eq: $productId }) {
id
name
stock
price
}
}
`
import React from 'react'
export default class ProductList extends React.Component {
render() {
}
export const query = graphql`
import Link from 'gatsby-link'
return (
<div>
<h1>Products</h1>
{
this.props.data.allProduct.edges.map((edge, index) => {
const product = edge.node
return (
<Link key={product.id} to={`/product/${product.id}`}>
<div>{product.name}</div>
</Link>
)
})
}
</div>
);
}
query AllProducts {
allProduct {
edges {
node {
id
name
}
}
}
}
`
- Import the Gatsby Link component with
import Link from 'gatsby-link'
- Wrap
<div>{product.name}</div>
inside theLink
component.
Adding pagination to the product listing page
$ npm install --save gatsby-paginate
...
exports.sourceNodes = async ({ boundActionCreators }) => {...}
exports.createPages = ({ graphql, boundActionCreators }) => {
const createPaginatedPages = require("gatsby-paginate");
const { createPage } = boundActionCreators
return new Promise((resolve, reject) => {
graphql(` ... ` ).then(result => {
createPaginatedPages({
edges: result.data.allProduct.edges,
createPage: createPage,
pageTemplate: "src/templates/productList.js",
pageLength: 10,
pathPrefix: "productList",
});
result.data.allProduct.edges.forEach(({ node }) => {
createPage({
path: `product/${node.id}`,
component: path.resolve(`./src/templates/productDetail.js`),
context: {
productId: node.id
},
})
})
resolve()
})
}).catch(error => {
console.log(error)
reject()
})
};
edges
: is the list of products we have fetched from our GraphQL querycreatePage
: is thecreatePage
function we retrieve fromboundActionCreators
.pageTemplate
: is the path to our react component which will render this page. In this case it issrc/templates/productList.js
. We will get to this in a bit. For the time being, let this be.pageLength
: is the number of products we want to show per page. (defaults to 10)pathPrefix
: is the route we want this list to be rendered at. In this case,/productList/
import React from 'react'
const NavLink = props => {
export default class ProductList extends React.Component {
render() {
return (
import Link from 'gatsby-link'
if (!props.test) {
return (
<Link to={props.url}>
{props.text} > {props.url}
</Link>
)
} else {
return (
<span>
{props.text} > {props.url}
</span>
)
}}
const { group, index, first, last, pageCount } = this.props.pathContext
const previousUrl =
index - 1 <= 1 ? '/productList/' : `${(index - 1).toString()}`
const nextUrl = `${(index + 1).toString()}`
<div>
{
group.map(edge => {
const product = edge.node;
return (
<Link key={product.id} to={`/product/${product.id}`}>
<div>{product.name}</div>
</Link>
)
})
}
<div className="previousLink">
<NavLink test={first} url={previousUrl} text="Go to Previous Page" />
</div>
<div className="nextLink">
<NavLink test={last} url={nextUrl} text="Go to Next Page" />
</div>
</div>
)
}
}
Final Thoughts
- Faster build times by only rebuilding pages whose data has changed. Having this feature will make every rebuild for data changes the same as updating a cache.
- Enabling fetching data directly from external GraphQL sources with the help of Schema Stitching. This way you can query the data directly from your source with your own GraphQL query.
- Support for “large websites”. This means that sites with >1k pages will not perform well with Gatsby. Although, Gatsby does mention that there are people who have successfully built websites with more than 1k pages.
While you're here, why not try out Hasura? It's free to try and you can get started in 30 seconds or so.
Related reading