├── .env.example ├── .eslintrc.json ├── .gitignore ├── @types └── schema.d.ts ├── README.md ├── access-screenshot.png ├── access-token-screenshot.png ├── components └── BlogCard.tsx ├── database-screenshot.png ├── next-env.d.ts ├── next.config.js ├── nextjs-notion-blog.jpg ├── package-lock.json ├── package.json ├── pages ├── _app.tsx ├── _document.tsx ├── api │ └── hello.ts ├── index.tsx └── post │ └── [slug].tsx ├── postcss.config.js ├── public ├── favicon.ico └── vercel.svg ├── services └── notion-service.ts ├── styles ├── Home.module.css └── globals.css ├── tailwind.config.js ├── tsconfig.json └── url-screenshot.png /.env.example: -------------------------------------------------------------------------------- 1 | NOTION_ACCESS_TOKEN= 2 | NOTION_BLOG_DATABASE_ID= 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | 39 | .idea/ 40 | -------------------------------------------------------------------------------- /@types/schema.d.ts: -------------------------------------------------------------------------------- 1 | export type Tag = { 2 | color: string 3 | id: string 4 | name: string 5 | } 6 | 7 | export type BlogPost = { 8 | id: string; 9 | slug: string; 10 | cover: string; 11 | title: string; 12 | tags: Tag[]; 13 | description: string; 14 | date: string 15 | } 16 | 17 | export type PostPage = { 18 | post: BlogPost, 19 | markdown: string 20 | } 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![thumbnail](nextjs-notion-blog.jpg) 2 | 3 | ## Click to watch YouTube tutorial 4 | 5 | 6 | ## Link to Notion Database 7 | To duplicate the Notion template, go here: 8 | [📝Test Blog](https://even-result-bd6.notion.site/7d731261948c470dac0b14d286f15a77?v=09f594bee7c64bc7852112ce284f8b5b) 9 | 10 | ## Sample Website 11 | Here's a sample version of the web available on Vercel: https://nextjs-notion-blog-chi.vercel.app 12 | 13 | 14 | ## Credits 15 | Follow[@Solomon](https://twitter.com/solocodes_me) on Twitter for more tutorials & developer content. 16 | -------------------------------------------------------------------------------- /access-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solomon04/nextjs-notion-blog/c6e7e29568436c4bb7106990c915e9f058b2e1ab/access-screenshot.png -------------------------------------------------------------------------------- /access-token-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solomon04/nextjs-notion-blog/c6e7e29568436c4bb7106990c915e9f058b2e1ab/access-token-screenshot.png -------------------------------------------------------------------------------- /components/BlogCard.tsx: -------------------------------------------------------------------------------- 1 | import {FunctionComponent} from "react"; 2 | import Link from "next/link"; 3 | import {BlogPost} from "../@types/schema"; 4 | import dayjs from 'dayjs' 5 | 6 | type BlogCardProps = { 7 | post: BlogPost 8 | } 9 | const localizedFormat = require('dayjs/plugin/localizedFormat'); 10 | dayjs.extend(localizedFormat) 11 | 12 | const BlogCard: FunctionComponent = ({post}) => { 13 | 14 | return ( 15 | 16 | 17 |
18 |
19 | 20 |
21 |
22 |
23 | 24 |

{dayjs(post.date).format('LL')}

25 |
26 | 27 |

{post.title}

28 |
29 | 30 | 31 |

{post.description}

32 |
33 | 34 | 35 | { 36 | post.tags.map(tag => ( 37 | 38 | #{tag.name} 39 | 40 | )) 41 | } 42 | 43 |
44 |
45 |
46 |
47 | 48 | ); 49 | }; 50 | 51 | export default BlogCard; 52 | -------------------------------------------------------------------------------- /database-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solomon04/nextjs-notion-blog/c6e7e29568436c4bb7106990c915e9f058b2e1ab/database-screenshot.png -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | module.exports = { 3 | reactStrictMode: true, 4 | } 5 | -------------------------------------------------------------------------------- /nextjs-notion-blog.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solomon04/nextjs-notion-blog/c6e7e29568436c4bb7106990c915e9f058b2e1ab/nextjs-notion-blog.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notion-blog", 3 | "private": true, 4 | "scripts": { 5 | "dev": "next dev", 6 | "build": "next build", 7 | "start": "next start", 8 | "lint": "next lint" 9 | }, 10 | "dependencies": { 11 | "@notionhq/client": "^0.4.12", 12 | "dayjs": "^1.10.7", 13 | "next": "12.0.8", 14 | "notion-to-md": "^2.1.0", 15 | "react": "17.0.2", 16 | "react-dom": "17.0.2", 17 | "react-markdown": "^8.0.0" 18 | }, 19 | "devDependencies": { 20 | "@tailwindcss/typography": "^0.5.0", 21 | "@types/node": "17.0.10", 22 | "@types/react": "17.0.38", 23 | "autoprefixer": "^10.4.2", 24 | "eslint": "8.7.0", 25 | "eslint-config-next": "12.0.8", 26 | "postcss": "^8.4.5", 27 | "tailwindcss": "^3.0.15", 28 | "typescript": "4.5.5" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css' 2 | import type { AppProps } from 'next/app' 3 | 4 | function MyApp({ Component, pageProps }: AppProps) { 5 | return 6 | } 7 | 8 | export default MyApp 9 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, {Html, Head, Main, NextScript, DocumentContext} from 'next/document' 2 | 3 | class MyDocument extends Document { 4 | static async getInitialProps(ctx: DocumentContext) { 5 | const initialProps = await Document.getInitialProps(ctx) 6 | return {...initialProps} 7 | } 8 | 9 | render() { 10 | return ( 11 | 12 | 13 | 14 | 15 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | ) 25 | } 26 | } 27 | 28 | export default MyDocument 29 | -------------------------------------------------------------------------------- /pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | 4 | type Data = { 5 | name: string 6 | } 7 | 8 | export default function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | res.status(200).json({ name: 'John Doe' }) 13 | } 14 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import {GetStaticProps, InferGetStaticPropsType} from "next"; 2 | import Head from "next/head"; 3 | import {BlogPost} from "../@types/schema"; 4 | import BlogCard from "../components/BlogCard"; 5 | import NotionService from "../services/notion-service"; 6 | 7 | export const getStaticProps: GetStaticProps = async (context) => { 8 | const notionService = new NotionService(); 9 | const posts = await notionService.getPublishedBlogPosts() 10 | 11 | return { 12 | props: { 13 | posts 14 | }, 15 | } 16 | } 17 | 18 | const Home = ({posts}: InferGetStaticPropsType) => { 19 | const title = 'Test Blog'; 20 | const description = 'Welcome to my Notion Blog.' 21 | 22 | return ( 23 | <> 24 | 25 | {title} 26 | 27 | 28 | 29 | 30 | 31 |
32 |
33 |
34 |
35 |

Notion + NextJS Sample Blog

36 |
37 |
38 | {posts.map((post: BlogPost) => ( 39 | 40 | ))} 41 |
42 |
43 |
44 |
45 | 46 | ) 47 | }; 48 | 49 | export default Home; 50 | -------------------------------------------------------------------------------- /pages/post/[slug].tsx: -------------------------------------------------------------------------------- 1 | import {GetStaticProps, InferGetStaticPropsType} from "next"; 2 | import ReactMarkdown from "react-markdown"; 3 | import Head from "next/head"; 4 | import NotionService from "../../services/notion-service"; 5 | 6 | const Post = ({markdown, post}: InferGetStaticPropsType) => { 7 | return ( 8 | <> 9 | 10 | {post.title} 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 |
20 |
21 | {markdown} 22 |
23 |
24 |
25 |
26 | 27 | 28 | 29 | ) 30 | } 31 | 32 | 33 | export const getStaticProps: GetStaticProps = async (context) => { 34 | const notionService = new NotionService() 35 | 36 | // @ts-ignore 37 | const p = await notionService.getSingleBlogPost(context.params?.slug) 38 | 39 | if (!p) { 40 | throw '' 41 | } 42 | 43 | return { 44 | props: { 45 | markdown: p.markdown, 46 | post: p.post 47 | }, 48 | } 49 | } 50 | 51 | export async function getStaticPaths() { 52 | const notionService = new NotionService() 53 | 54 | const posts = await notionService.getPublishedBlogPosts() 55 | 56 | // Because we are generating static paths, you will have to redeploy your site whenever 57 | // you make a change in Notion. 58 | const paths = posts.map(post => { 59 | return `/post/${post.slug}` 60 | }) 61 | 62 | return { 63 | paths, 64 | fallback: false, 65 | } 66 | } 67 | 68 | export default Post; 69 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solomon04/nextjs-notion-blog/c6e7e29568436c4bb7106990c915e9f058b2e1ab/public/favicon.ico -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /services/notion-service.ts: -------------------------------------------------------------------------------- 1 | import {Client} from "@notionhq/client"; 2 | import {BlogPost, PostPage} from "../@types/schema"; 3 | import {NotionToMarkdown} from "notion-to-md"; 4 | 5 | export default class NotionService { 6 | client: Client 7 | n2m: NotionToMarkdown; 8 | 9 | constructor() { 10 | this.client = new Client({ auth: process.env.NOTION_ACCESS_TOKEN }); 11 | this.n2m = new NotionToMarkdown({ notionClient: this.client }); 12 | } 13 | 14 | async getPublishedBlogPosts(): Promise { 15 | const database = process.env.NOTION_BLOG_DATABASE_ID ?? ''; 16 | // list blog posts 17 | const response = await this.client.databases.query({ 18 | database_id: database, 19 | filter: { 20 | property: 'Published', 21 | checkbox: { 22 | equals: true 23 | } 24 | }, 25 | sorts: [ 26 | { 27 | property: 'Updated', 28 | direction: 'descending' 29 | } 30 | ] 31 | }); 32 | 33 | return response.results.map(res => { 34 | return NotionService.pageToPostTransformer(res); 35 | }) 36 | } 37 | 38 | async getSingleBlogPost(slug: string): Promise { 39 | let post, markdown 40 | 41 | const database = process.env.NOTION_BLOG_DATABASE_ID ?? ''; 42 | // list of blog posts 43 | const response = await this.client.databases.query({ 44 | database_id: database, 45 | filter: { 46 | property: 'Slug', 47 | formula: { 48 | text: { 49 | equals: slug // slug 50 | } 51 | }, 52 | // add option for tags in the future 53 | }, 54 | sorts: [ 55 | { 56 | property: 'Updated', 57 | direction: 'descending' 58 | } 59 | ] 60 | }); 61 | 62 | if (!response.results[0]) { 63 | throw 'No results available' 64 | } 65 | 66 | // grab page from notion 67 | const page = response.results[0]; 68 | 69 | const mdBlocks = await this.n2m.pageToMarkdown(page.id) 70 | markdown = this.n2m.toMarkdownString(mdBlocks); 71 | post = NotionService.pageToPostTransformer(page); 72 | 73 | return { 74 | post, 75 | markdown 76 | } 77 | } 78 | 79 | private static pageToPostTransformer(page: any): BlogPost { 80 | let cover = page.cover; 81 | console.log(cover) 82 | switch (cover.type) { 83 | case 'file': 84 | cover = page.cover.file 85 | break; 86 | case 'external': 87 | cover = page.cover.external.url; 88 | break; 89 | default: 90 | // Add default cover image if you want... 91 | cover = '' 92 | } 93 | 94 | return { 95 | id: page.id, 96 | cover: cover, 97 | title: page.properties.Name.title[0].plain_text, 98 | tags: page.properties.Tags.multi_select, 99 | description: page.properties.Description.rich_text[0].plain_text, 100 | date: page.properties.Updated.last_edited_time, 101 | slug: page.properties.Slug.formula.string 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 0 2rem; 3 | } 4 | 5 | .main { 6 | min-height: 100vh; 7 | padding: 4rem 0; 8 | flex: 1; 9 | display: flex; 10 | flex-direction: column; 11 | justify-content: center; 12 | align-items: center; 13 | } 14 | 15 | .footer { 16 | display: flex; 17 | flex: 1; 18 | padding: 2rem 0; 19 | border-top: 1px solid #eaeaea; 20 | justify-content: center; 21 | align-items: center; 22 | } 23 | 24 | .footer a { 25 | display: flex; 26 | justify-content: center; 27 | align-items: center; 28 | flex-grow: 1; 29 | } 30 | 31 | .title a { 32 | color: #0070f3; 33 | text-decoration: none; 34 | } 35 | 36 | .title a:hover, 37 | .title a:focus, 38 | .title a:active { 39 | text-decoration: underline; 40 | } 41 | 42 | .title { 43 | margin: 0; 44 | line-height: 1.15; 45 | font-size: 4rem; 46 | } 47 | 48 | .title, 49 | .description { 50 | text-align: center; 51 | } 52 | 53 | .description { 54 | margin: 4rem 0; 55 | line-height: 1.5; 56 | font-size: 1.5rem; 57 | } 58 | 59 | .code { 60 | background: #fafafa; 61 | border-radius: 5px; 62 | padding: 0.75rem; 63 | font-size: 1.1rem; 64 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, 65 | Bitstream Vera Sans Mono, Courier New, monospace; 66 | } 67 | 68 | .grid { 69 | display: flex; 70 | align-items: center; 71 | justify-content: center; 72 | flex-wrap: wrap; 73 | max-width: 800px; 74 | } 75 | 76 | .card { 77 | margin: 1rem; 78 | padding: 1.5rem; 79 | text-align: left; 80 | color: inherit; 81 | text-decoration: none; 82 | border: 1px solid #eaeaea; 83 | border-radius: 10px; 84 | transition: color 0.15s ease, border-color 0.15s ease; 85 | max-width: 300px; 86 | } 87 | 88 | .card:hover, 89 | .card:focus, 90 | .card:active { 91 | color: #0070f3; 92 | border-color: #0070f3; 93 | } 94 | 95 | .card h2 { 96 | margin: 0 0 1rem 0; 97 | font-size: 1.5rem; 98 | } 99 | 100 | .card p { 101 | margin: 0; 102 | font-size: 1.25rem; 103 | line-height: 1.5; 104 | } 105 | 106 | .logo { 107 | height: 1em; 108 | margin-left: 0.5rem; 109 | } 110 | 111 | @media (max-width: 600px) { 112 | .grid { 113 | width: 100%; 114 | flex-direction: column; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | "./pages/**/*.{js,ts,jsx,tsx}", 4 | "./components/**/*.{js,ts,jsx,tsx}", 5 | ], 6 | theme: { 7 | extend: {}, 8 | fontFamily: { 9 | sans: ["'Montserrat'"], 10 | mono: ["'Inconsolata'"] 11 | } 12 | }, 13 | plugins: [ 14 | require('@tailwindcss/typography') 15 | ], 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /url-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solomon04/nextjs-notion-blog/c6e7e29568436c4bb7106990c915e9f058b2e1ab/url-screenshot.png --------------------------------------------------------------------------------