├── .babelrc ├── .gitignore ├── README.md ├── cache.js ├── components ├── Layout.js └── Post.js ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _document.js ├── index.js └── single.js └── server.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "next/babel" 4 | ], 5 | "plugins": [ 6 | ["styled-components", { 7 | "ssr": true, 8 | "displayName": true, 9 | "preprocess": false 10 | }] 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | .next 4 | .env 5 | dump.rdb 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## ⚠️ NOTE 2 | 3 | **This repository and companion tutorial have been retired**. No further updates will be made. See the [note on the tutorial](https://davidyeiser.com/tutorials/how-to-create-blog-airtable-api-next-js) for more information. 4 | 5 | --- 6 | 7 | ## Tutorial 8 | 9 | This is the companion repository for the tutorial: 10 | 11 | **[How to Create a Blog with the Airtable API & Next.js](https://davidyeiser.com/tutorials/how-to-create-blog-airtable-api-next-js)**. 12 | 13 | ## Setup 14 | 15 | You’ll need an Airtable account with an API key. The sample code is matched to a table structured like so: 16 | 17 | ![Screenshot of Table in Airtable](https://davidyeiser.com/images/general/tutorial-ex-airtable-blog-setup.png) 18 | 19 | ## Install 20 | 21 | 1. Clone this repository and run: 22 | 23 | ```` 24 | npm install 25 | ```` 26 | 27 | 2. Then create a `.env` file and add your Airtable API key and Base ID to it like so: 28 | 29 | ```` 30 | AIRTABLE_API_KEY=keyXXXXXX 31 | AIRTABLE_BASE_ID=appXXXXXX 32 | ```` 33 | 34 | ## Run 35 | 36 | 1. First start the Redis server with: 37 | 38 | ```` 39 | redis-server 40 | ```` 41 | 42 | 2. Then Start Next with: 43 | 44 | ```` 45 | npm run dev 46 | ```` 47 | 48 | Now go to `http://localhost:3000` and you should see your Airtable data. If you run into trouble, have any questions, etc. feel free to file an issue. 49 | -------------------------------------------------------------------------------- /cache.js: -------------------------------------------------------------------------------- 1 | const redis = require('redis') 2 | const client = redis.createClient() 3 | 4 | // Log any errors 5 | client.on('error', function(error) { 6 | console.log('Error:') 7 | console.log(error) 8 | }) 9 | 10 | module.exports = client 11 | -------------------------------------------------------------------------------- /components/Layout.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Head from 'next/head' 3 | import styled from 'styled-components' 4 | 5 | const Site = styled.div` 6 | max-width: 600px; 7 | margin: 100px auto; 8 | background-color: #fff; 9 | ` 10 | 11 | export default class Layout extends React.Component { 12 | render () { 13 | const { children } = this.props 14 | const title = 'My site' 15 | 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | {title} 23 | 24 | 25 | {children} 26 | 27 | ) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /components/Post.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import dateFormat from 'dateformat' 3 | import Markdown from 'react-markdown' 4 | 5 | class Post extends React.Component { 6 | render() { 7 | const { 8 | title, 9 | content, 10 | publish_date, 11 | slug, 12 | id 13 | } = this.props 14 | 15 | const permalink = !!id ? '/post/' + id + '/' + slug : false 16 | 17 | return ( 18 |
19 | {!!permalink ? 20 | 21 | 22 | {title &&

{title}

} 23 | {publish_date && 24 | 25 | } 26 |
27 | : 28 |
29 | {title &&

{title}

} 30 | {publish_date && 31 | 32 | } 33 |
34 | } 35 | 36 | {content && } 37 |
38 | ) 39 | } 40 | } 41 | 42 | export default Post 43 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const { parsed: localEnv } = require('dotenv').config() 2 | const webpack = require('webpack') 3 | 4 | module.exports = { 5 | webpack: (config, { buildId, dev, isServer, defaultLoaders }) => { 6 | config.plugins.push( 7 | new webpack.EnvironmentPlugin(localEnv) 8 | ) 9 | 10 | return config 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "airtable-nextjs-blog", 3 | "version": "0.1.0", 4 | "description": "How to create a Blog with Airtable & Next.js", 5 | "scripts": { 6 | "dev": "NODE_ENV=development node server.js", 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "next build", 9 | "start": "NODE_ENV=production node server.js" 10 | }, 11 | "engines": { 12 | "node": "8.9.4", 13 | "npm": "5.6.0" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/davidyeiser/airtable-nextjs-blog.git" 18 | }, 19 | "keywords": [], 20 | "author": "David Yeiser (https://davidyeiser.com)", 21 | "license": "MIT", 22 | "dependencies": { 23 | "airtable": "0.8.1", 24 | "babel-plugin-styled-components": "1.10.6", 25 | "dateformat": "3.0.3", 26 | "dotenv": "8.2.0", 27 | "express": "4.17.1", 28 | "isomorphic-unfetch": "3.0.0", 29 | "markdown-it": "10.0.0", 30 | "next": "9.1.7", 31 | "react": "16.12.0", 32 | "react-dom": "16.12.0", 33 | "react-markdown": "4.3.1", 34 | "redis": "2.8.0", 35 | "shortid": "2.2.15", 36 | "styled-components": "5.0.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /pages/_document.js: -------------------------------------------------------------------------------- 1 | /* 2 | From Next.js styled-components example, see: 3 | https://github.com/zeit/next.js/blob/canary/examples/with-styled-components/pages/_document.js 4 | */ 5 | 6 | import Document, { Head, Main, NextScript } from 'next/document' 7 | import { ServerStyleSheet } from 'styled-components' 8 | 9 | export default class MyDocument extends Document { 10 | static getInitialProps ({ renderPage }) { 11 | const sheet = new ServerStyleSheet() 12 | const page = renderPage(App => props => sheet.collectStyles()) 13 | const styleTags = sheet.getStyleElement() 14 | return { ...page, styleTags } 15 | } 16 | 17 | render () { 18 | return ( 19 | 20 | 21 | {this.props.styleTags} 22 | 23 | 24 |
25 | 26 | 27 | 28 | ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import fetch from 'isomorphic-unfetch' 2 | import Link from 'next/link' 3 | import shortid from 'shortid' 4 | 5 | import Layout from '../components/Layout' 6 | import Post from '../components/Post' 7 | 8 | class Home extends React.Component { 9 | constructor() { 10 | super() 11 | 12 | this.state = { 13 | airtablePosts: [] 14 | } 15 | } 16 | 17 | componentDidMount() { 18 | const { props } = this 19 | 20 | const transferPosts = new Promise((resolve) => { 21 | const collectPosts = [] 22 | 23 | Object.keys(props).map((item) => { 24 | // Filter out other props like 'url', etc. 25 | if (typeof props[item].id !== 'undefined') { 26 | collectPosts.push(props[item]) 27 | } 28 | }) 29 | 30 | resolve(collectPosts) 31 | }) 32 | 33 | Promise.resolve(transferPosts).then(data => { 34 | this.setState({ airtablePosts: data }) 35 | }) 36 | } 37 | 38 | render() { 39 | const { airtablePosts } = this.state 40 | 41 | if (!Array.isArray(airtablePosts) || !airtablePosts.length) { 42 | // Still loading Airtable data 43 | return ( 44 | 45 |

Loading…

46 |
47 | ) 48 | } 49 | else { 50 | // Loaded 51 | return ( 52 | 53 | {airtablePosts.map((post) => 54 | 62 | )} 63 | 64 | ) 65 | } 66 | } 67 | } 68 | 69 | Home.getInitialProps = async (context) => { 70 | const basePath = (process.env.NODE_ENV === 'development') ? 'http://localhost:3000' : 'https://yourdomain.com' 71 | 72 | const res = await fetch(`${basePath}/api/get/posts`) 73 | const airtablePosts = await res.json() 74 | 75 | return airtablePosts ? airtablePosts.data : {} 76 | } 77 | 78 | export default Home 79 | -------------------------------------------------------------------------------- /pages/single.js: -------------------------------------------------------------------------------- 1 | import fetch from 'isomorphic-unfetch' 2 | 3 | import Layout from '../components/Layout' 4 | import Post from '../components/Post' 5 | 6 | class Single extends React.Component { 7 | render() { 8 | const { 9 | title, 10 | content, 11 | publish_date 12 | } = this.props 13 | 14 | return ( 15 | 16 | 21 | 22 | ) 23 | } 24 | } 25 | 26 | Single.getInitialProps = async (context) => { 27 | const basePath = (process.env.NODE_ENV === 'development') ? 'http://localhost:3000' : 'https://yourdomain.com' 28 | const { id } = context.query 29 | 30 | const res = await fetch(`${basePath}/api/post/${id}`) 31 | const airtablePost = await res.json() 32 | 33 | return airtablePost ? airtablePost.data : {} 34 | } 35 | 36 | export default Single 37 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | // Access .env variables 2 | if (process.env.NODE_ENV !== 'production') { 3 | require('dotenv').config() 4 | } 5 | 6 | const express = require('express') 7 | const next = require('next') 8 | const cache = require('./cache') 9 | 10 | const Airtable = require('airtable') 11 | Airtable.configure({ apiKey: process.env.AIRTABLE_API_KEY }) 12 | 13 | const dev = process.env.NODE_ENV !== 'production' 14 | const port = process.env.PORT || 3000 15 | const app = next({ dev }) 16 | const handle = app.getRequestHandler() 17 | 18 | // Markdown support for JSON feed 19 | const MarkdownIt = require('markdown-it') 20 | const md = new MarkdownIt() 21 | 22 | const serialize = data => JSON.stringify({ data }) 23 | 24 | /* Main Airtable Query */ 25 | const getAirtablePosts = (baseId) => { 26 | const base = new Airtable.base(baseId) 27 | 28 | return new Promise((resolve, reject) => { 29 | cache.get('airtablePosts', function(error, data) { 30 | if (error) throw error 31 | 32 | if (!!data) { 33 | // Stored value, grab from cache 34 | resolve(JSON.parse(data)) 35 | } 36 | else { 37 | // No stored value, retrieve from Airtable 38 | const storeAirtablePosts = [] 39 | 40 | // Query 41 | const apiQuery = { 42 | pageSize: 50, 43 | sort: [{field: 'Publish Date', direction: 'desc'}] 44 | } 45 | 46 | // Go get it! 47 | base('Posts').select(apiQuery).eachPage((records, fetchNextPage) => { 48 | // This function (`page`) will get called for each page of records. 49 | 50 | // The properties here would correspond to your records 51 | records.forEach(function(record) { 52 | const post = { 53 | title: record.get('Title'), 54 | content: record.get('Content'), 55 | publish_date: record.get('Publish Date'), 56 | slug: record.get('Slug'), 57 | id: record.id 58 | } 59 | 60 | storeAirtablePosts.push(post) 61 | }) 62 | 63 | fetchNextPage() 64 | }, function done(error) { 65 | if (error) reject({ error }) 66 | 67 | // Store results in Redis, expires in 30 sec 68 | cache.setex('airtablePosts', 30, JSON.stringify(storeAirtablePosts)) 69 | 70 | // Finish 71 | resolve(storeAirtablePosts) 72 | }) 73 | } 74 | }) 75 | }) 76 | } 77 | 78 | /* Get Individual Airtable Record */ 79 | const getAirtablePost = (recordId, baseId) => { 80 | const base = new Airtable.base(baseId) 81 | const cacheRef = '_cachedAirtableBook_'+recordId 82 | 83 | return new Promise((resolve, reject) => { 84 | cache.get(cacheRef, function(error, data) { 85 | if (error) throw error 86 | 87 | if (!!data) { 88 | // Stored value, grab from cache 89 | resolve(JSON.parse(data)) 90 | } 91 | else { 92 | base('Posts').find(recordId, function(err, record) { 93 | if (err) { 94 | console.error(err) 95 | reject({ err }) 96 | } 97 | 98 | const airtablePost = { 99 | title: record.get('Title'), 100 | content: record.get('Content'), 101 | publish_date: record.get('Publish Date') 102 | } 103 | 104 | // Store results in Redis, expires in 30 sec 105 | cache.setex(cacheRef, 30, JSON.stringify(airtablePost)); 106 | 107 | resolve(airtablePost) 108 | }) 109 | } 110 | }) 111 | }) 112 | } 113 | 114 | app.prepare() 115 | .then(() => { 116 | const server = express() 117 | 118 | // Internal API call to get Airtable data 119 | server.get('/api/get/posts', (req, res) => { 120 | Promise.resolve(getAirtablePosts(process.env.AIRTABLE_BASE_ID)).then(data => { 121 | res.writeHead(200, {'Content-Type': 'application/json'}) 122 | return res.end(serialize(data)) 123 | }).catch((error) => { 124 | console.log(error) 125 | // Send empty JSON otherwise page load hangs indefinitely 126 | res.writeHead(200, {'Content-Type': 'application/json'}) 127 | return res.end(serialize({})) 128 | }) 129 | }) 130 | 131 | // Internal API call to get individual Airtable post 132 | server.get('/api/post/:id', (req, res) => { 133 | Promise.resolve(getAirtablePost(req.params.id, process.env.AIRTABLE_BASE_ID)).then(data => { 134 | res.writeHead(200, {'Content-Type': 'application/json'}) 135 | return res.end(serialize(data)) 136 | }).catch((error) => { 137 | console.log(error) 138 | // Send empty JSON otherwise page load hangs indefinitely 139 | res.writeHead(200, {'Content-Type': 'application/json'}) 140 | return res.end(serialize({})) 141 | }) 142 | }) 143 | 144 | // JSON Feed 145 | server.get('/feed/json', (req, res) => { 146 | Promise.resolve(getAirtablePosts(process.env.AIRTABLE_BASE_ID)).then(data => { 147 | const jsonFeed = { 148 | "version": "https://jsonfeed.org/version/1", 149 | "home_page_url": "https://yourdomain.com/", 150 | "feed_url": "https://yourdomain.com/feed/json", 151 | "title": "YOUR SITE TITLE", 152 | "description": "YOUR SITE DESCRIPTION", 153 | "items": [ 154 | ] 155 | } 156 | 157 | // Go through each item in returned array and add it to our JSON Feed object 158 | data.map((item) => { 159 | jsonFeed.items.push({ 160 | "id": `https://yourdomain.com/post/${item.id}/${item.slug}`, 161 | "url": `https://yourdomain.com/post/${item.id}/${item.slug}`, 162 | "title": item.title, 163 | "content_html": !!item.content ? md.render(item.content) : '', 164 | "date_published": item.publish_date, 165 | "author": { 166 | "name": "YOUR NAME" 167 | } 168 | }) 169 | }) 170 | 171 | res.writeHead(200, {'Content-Type': 'application/json'}) 172 | return res.end(JSON.stringify(jsonFeed, null, 2)) 173 | }).catch((error) => { 174 | console.log(error) 175 | // Send empty JSON otherwise page load hangs indefinitely 176 | res.writeHead(200, {'Content-Type': 'application/json'}) 177 | return res.end(serialize({})) 178 | }) 179 | }) 180 | 181 | server.get('/post/:id/:slug', (req, res) => { 182 | const actualPage = '/single' 183 | 184 | const queryParams = { 185 | id: req.params.id, 186 | slug: req.params.slug 187 | } 188 | 189 | app.render(req, res, actualPage, queryParams) 190 | }) 191 | 192 | server.get('*', (req, res) => { 193 | return handle(req, res) 194 | }) 195 | 196 | server.listen(port, (err) => { 197 | if (err) throw err 198 | console.log('> Ready on http://localhost:3000') 199 | }) 200 | }) 201 | .catch((ex) => { 202 | console.error(ex.stack) 203 | process.exit(1) 204 | }) 205 | --------------------------------------------------------------------------------