├── .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 | 
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 |
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 |
--------------------------------------------------------------------------------