Building a documentation site from scratch using Airtable, gatsby.js, and markdown!

Walkthrough tutorial for a match made in heaven.

canvis.app — a mapping platform.
10 min readOct 15, 2018

Building and maintaining documentation pages, help sections, or knowledge bases can be a daunting task. We wanted the canvis.app knowledge base to fit a few criteria: fast and lightweight, search-optimized, affordable to host, portable, branded and well-designed, and most importantly: easy to organize and edit between non-technical team members.

I finally found something that checks all these boxes, was a breeze to build, and didn’t require a single SaaS subscription!

In this tutorial we will create a simple documentation website comprised of a table of contents, sections, and pages.

See the final code here:

https://github.com/jbolda/gatsby-source-airtable/tree/master/examples/markdown-docs

See a real airtable/gatsby/markdown/firebase website in production at:
https://about.canvis.app/en/guide

And also, more recently:
https://offsetra.com

A quick Airtable introduction.

If you don’t know Airtable, it’s like a spreadsheet that’s beefed up in some ways (relational links between records; unique fields like checkboxes, tags, or attachments) — yet simplified in others (beautiful sorting, searching, views, summaries and widgets). And that’s without mentioning the insane API!

Recently, we were so blown away by Airtable that we decided to migrate our CRM, docs, kanbans and to-dos from the numerous products they were spread across. I’m in the process of moving my cooking recipes too!

Airtable is especially handy if want to track metadata and collaborate: Add a status column and populate it with a sexy drop-down (draft|archived|published) and filters for each. Add a Form for data entry. Leave comments, tag team members, see revision history — all in the same place! A complete game changer.

How would Airtable work for managing docs?

Simple: a Pages table, each record being a page. A Sections table, each record being a section. Each section record contains (links to) many page records. Each page record is contained by (links to) one section record. Every entry gets a Path, Slug, Title, and a Body written in markdown.

Why Gatsby?

If you are like me, you love the React way of doing things. Components, functional one-way data flow, state-based styling, etc. Unfortunately, an SPA is not really the best approach for documentation, both for performance and SEO reasons. We need static HTML for each page. Gatsby gives us that, while still letting us flex our React muscles for the fancy stuff.

Gatsby also has fantastic plugins for Airtable and Markdown — and accessing our data with simple GraphQL queries is a joy!

Gatsby uses dark magic to summon our Airtable data at build time, and generates our site and its pages. When we want to publish some changes to our site, we just run gatsby build and deploy to our host. Thats it!

Let’s code.

For this tutorial you should already be comfortable with JavaScript/HTML, React/JSX components, and Node Package Manager.

Installing packages and plugins

  1. Install the Gatsby CLI
npm install --global gatsby-cli

2. Then use it to generate an empty starter project:

gatsby new airtable-markdown-docs

3. To connect to Airtable, we will need gatsby-source-airtable

npm install --save gatsby-source-airtable

4. To transform our markdown content to HTML, we use the very popular gatsby-transformer-remark

npm install --save gatsby-transformer-remark

5. One last module, this one being a dependency for our plugins, called gatsby-source-filesystem.

npm install --save gatsby-source-filesystem

Configuring and connecting to Airtable

Now we need to add and configure our gatsby plugins to our project.

We add them to the plugins array in gatsby-config.js. Change the site title white you are in there!

The result should look like this:

// gatsby-config.jsmodule.exports = {
siteMetadata: {
title: 'My Documentation Site',
},
plugins: [
'gatsby-transformer-remark',
{
resolve: `gatsby-source-airtable`,
options: {
apiKey: `YOUR_AIRTABLE_KEY`,
tables: [
{
baseId: `YOUR_AIRTABLE_BASE_ID`,
tableName: `YOUR_AIRTABLE_NAME`,
tableView: `YOUR_AIRTABLE_VIEW_NAME`,
mapping: { 'COLUMN NAME': `VALUE_FORMAT` },
tableLinks: [`ARRAY_OF_COLUMN_NAMES`],
},
{
baseId: `YOUR_AIRTABLE_BASE_ID`,
tableName: `YOUR_AIRTABLE_NAME`,
tableView: `YOUR_AIRTABLE_VIEW_NAME`,
mapping: { 'COLUMN NAME': `VALUE_FORMAT` },
tableLinks: [`ARRAY_OF_COLUMN_NAMES`],
},
],
},
},
],
}

We need to fill in those table objects— but first, we need to create our base in Airtable! Duplicate the simple documentation database I have created below. Note that column, page, and view titles are case sensitive.

YOU MIGHT HAVE THOUGHT IT WAS A SCREEN SHOT, IT’S NOT! IT’S EMBEDDED! WOW

When you have your base set up, in Airtable, click Help then API Documentation.

Scrolling down a bit, in the right hand column you can find your BaseId, and if you check the box in the top right, your account apiKey. Be careful not to push or publish the apiKey anywhere public! We are leaving it dangerously in gatsby-config.js for simplicity sake — make sure to change this later.

Fill in both tables objects in gatsby-config.js. In the end, they should look like this — I’ll explain why in a second.

options: {
apiKey: `SENSITIVE_API_KEY`,
tables: [
{
baseId: `YOUR_BASE_ID`,
tableName: `Sections`,
tableView: `All`,
mapping: { Body: `text/markdown` },
tableLinks: [`Pages`],
},
{
baseId: `YOUR_BASE_ID_AGAIN`,
tableName: `Pages`,
tableView: `All`,
mapping: { Body: `text/markdown` },
tableLinks: [`Sections`],
},
],
},

We have two objects in our tables array. One for our Pages table, one for our Sections table. Gatsby will search through our base for these tables and pull all the data in that view. The names are case sensitive!

tableView is useful for filtering out data — for example, adding a “published” column and creating a new view that filters Page records where published = true. For the mapping property, we pass a key/value that tells Gatsby that our Body has the mediaType text/markdown. The gatsby-transformer-remark plugin will look for markdown nodes and handle them accordingly. See the plugin for more info and configuration options.

Lastly, because Airtable’s most powerful feature is it’s relational links between records, we need to tell Gatsby how deep to go. We want Gatsby to pull not just the Section records, but all the linked Page’s data as well, and vice versa. This is what the tableLinks field is for.

Fire it up

If you’ve made it this far, run gatsby develop in your project folder, and go to http://localhost:8000/ in your browser. It’s alive! Whenever you update your Airtable, you will need to restart gatsby develop again.

Querying Airtable with GraphQL

Gatsby is built on GraphQL —it allows us to declare what data we want, right along side our components or pages. If you’ve never used it, no problem — though reading a brief tutorial might help.

Gatsby also has a built-in tool called graphiQL— after you run gatsby develop, head to http://localhost:8000/___graphql to try it out. It lets you explore the nodes and edges that Gatsby has created, write your queries in advance, and see what they return.

Before we build any components we need to make sure our data is loaded properly, and in what shape. Paste this code into the left hand column of graphiQL:

// testing in graphIQL
{
allAirtable {
edges {
node {
data {
Title
Path
}
}
}
}
}

GraphiQLshould return one object for each record in our tables, like this:

/// result
...
"data": {
"allAirtable": {
"edges": [
{
"node": {
"data": {
"Title": "Reasons why Gatsby is great",
"Path": "great-gatsby"
}
}
},
{
"node": {
"data": {
"Title": "Reasons why Airtable is great",
"Path": "great-airtable"
}
}
},
...

Creating a Table of Contents

Now we know our Airtable data has successfully been loaded in Gatsby. How do we access this data in our components? Using Page Queries!

Any file in src/pages can run a page query. You simply define and export a query using the built-in graphql object, and Gatsby will inject the result into your component as a prop.

We want a list of Sections, and a hyperlink to each.

At the top of src/pages/index.js

import { Link, graphql } from ‘gatsby’;

At the bottom of the same file:

// query airtable for the Title and Path of each record,
// filtering for only records in the Sections table.
export const query = graphql`
{
allAirtable(filter: { table: { eq: "Sections" } }) {
edges {
node {
data {
Title
Path
}
}
}
}
}
`

Now rearrange the IndexPage component — destructure the data prop, map through each object in the edges array. If this is confusing, just remember you can always use graphiQL to understand the shape and structure of the data you are receiving.

import Layout from '../components/layout'const IndexPage = ({ data }) => (
<Layout>
<h1>Table of Contents</h1>
{data.allAirtable.edges.map((edge, i) => (
<Link to={edge.node.data.Path} key={i}>
<h3>{edge.node.data.Title}</h3>
</Link>
))}

</Layout>
)
export default IndexPage

The browser should hot-reload and show you our super fancy table of contents. However, our hyperlinks are broken because the doc pages don’t exist yet!

Creating pages from data

Here we will use the Gatsby createPages API to generate a unique HTML page for each record in our Airtable.

This is accomplished by writing a page-creation function that runs at the very beginning of our build process (i.e. gatsby develop / gatsby build). It will take a list of paths and a designated template component.

We will write the function first, and the templates later. Open up gatsby-node.js and paste in the following:

const path = require(`path`)exports.createPages = ({ graphql, actions }) => {
// createPage is a built in action,
// available to all gatsby-node exports
const { createPage } = actions
return new Promise(async resolve => {
// we need the table name (e.g. "Sections")
// as well as the unique path for each Page/Section.
const result = await graphql(`
{
allAirtable {
edges {
node {
table
data {
Path
}
}
}
}
}
`)
// For each path, create page and choose a template.
// values in context Object are available in that page's query
result.data.allAirtable.edges.forEach(({ node }) => {
const isPage = node.table === 'Pages'
createPage({
path: node.data.Path,
component: isPage
? path.resolve(`./src/templates/page-template.js`)
: path.resolve(`./src/templates/section-template.js`),
context: {
Path: node.data.Path,
},
})
})
resolve()
})
}

Now the templates. Create a folder called src/templates and create section-template.js and page-template.js:

// section-template.jsimport React from 'react'
import { Link, graphql } from 'gatsby'
import Layout from '../components/layout'
export default ({ data }) => (
<Layout>
<h3>{data.airtable.data.Title}</h3>
<main
dangerouslySetInnerHTML={{
__html: data.airtable.data.Body.childMarkdownRemark.html,
}}
/>
{data.airtable.data.Pages.map((page, i) => (
<Link to={page.data.Path} key={i}>
<h4>{page.data.Title}</h4>
</Link>
))}
</Layout>
)
export const query = graphql`
query GetSection($Path: String!) {
airtable(table: { eq: "Sections" }, data: { Path: { eq: $Path } }) {
data {
Title
Body {
childMarkdownRemark {
html
}
}
Pages {
data {
Title
Path
}
}
}
}
}
`

And for the pages:

// page-template.jsimport React from 'react'
import { Link, graphql } from 'gatsby'
import Layout from '../components/layout'
export default ({ data }) => (
<Layout>
<Link to={data.airtable.data.Section[0].data.Path}>
<h6>> {data.airtable.data.Section[0].data.Title}</h6>
</Link>
<h3>{data.airtable.data.Title}</h3>
<main
dangerouslySetInnerHTML={{
__html: data.airtable.data.Body.childMarkdownRemark.html,
}}
/>
</Layout>
)
export const query = graphql`
query GetPage($Path: String!) {
airtable(table: { eq: "Pages" }, data: { Path: { eq: $Path } }) {
data {
Title
Body {
childMarkdownRemark {
html
}
}
Section {
data {
Title
Path
}
}
}
}
}
`

Take note of our graphQL queries — we are using variables which are automatically passed from the context object in our createPages function to grab the right record. In our Airtable, we just make sure that Path is always unique!

Finished!

Obviously, this is not a production ready design. Replicating a standard docs / knowledge base layout is just as easy — See the UI used by Redux (GitBook), or Gatsby docs for inspiration.

A Few Caveats

Table of Contents

Gatsby-transformer-remark let’s you query for a tableOfContents, which it generates itself. This field has a query parameter, tableOfContents(pathToSlugField: “…”), which it uses to locate a path string somewhere in your data layer. Then, it takes this path and uses it to generate the URLs within your ToC. I quickly realized that pathToSlugField can’t find anything higher up in our graph query (where we already have defined a Path), so instead I use Airtable to inject the path into a Frontmatter section in my markdown:

// a separate Markdown field, generated with an airtable formula
// takes the Path field and the Content/Body field and merges them // into a new string
---
path: en/documentation/myPage
---
... markdown content

With this set up, all I do is query for tableOfContents(pathToSlugField: "frontmatter.path”). Works like a charm!

Images

I am currently trying to figure out how to bring in Airtable images as Attachments, and have them be both predictable to reference in our markdown, and automatically processed by plugins like gatsby-remark-images. Currently I am just copying the airtable URL and using this in my markdown — Stay tuned for a better solution!

Build Triggers

Airtable does not offer any webhooks yet, so you will need to be creative in how you handle pushing your updates live. Some people have had success with Zapier.

Attaching markdown files

At the moment, I receive an inexplicable Error when trying to reference markdown files as attachments, although in theory they should be recognized and parsed by our plugins. This approach would allow us to do our writing in a convenient markdown editors, then save and attach to airtable records.

Stay Tuned

When the new canvis.app landing page is finished, I will be posting a much more complex example with full i18n, multiple pages, and a knowledge base — all powered by Airtable!

We are using React, Gatsby, Airtable, Mapbox and other neat tech to build tools for public participation, citizen science, and crowd-sourced maps.

We plan on posting many more tutorials like this one, in addition to some great content on topics we care about. Including our efforts to crowd-mapping sexual harassment reports and using our platform, canvis.app, and helping an ambitious startup locate rural and off-grid clinics who need help with sterilization.

Sound interesting? We are looking for front-end and full-stack devs to join us!

jobs@canvis.app

--

--

canvis.app — a mapping platform.

Create a shareable collaborative mapping project. For crowd-sourcing, public participation, research and more: https://canvis.app