
Next.js、Notionを使ってブログを構築して行きたいと思います。
上から順番にやっていけば30分程度で手軽に構築できます。
興味はあるけど自分で開発するのが億劫だった方、どうぞ。
完成形のイメージ
見た目はこんな感じです。
限りなくシンプルにしました。
記事一覧ページ

記事詳細ページ

Notion側のセッティング
コードを書く前に、Notionでデータベースやインテグレーションを作成していきます。
面倒ですが、こうゆうのは慣れですね。
データベース作成
記事を登録していくデータベースを作成します。
Next.jsでプロパティ名、種類を指定しますので、大文字小文字など間違いないように登録してください。
プロパティ名 | 種類 | 説明 |
---|---|---|
Name | タイトル | 記事タイトル |
Tags | マルチセレクト | タグを設定します |
Published | チェックボックス | チェックしたものを公開します |
Date | 作成日時 | – |
Slug | テキスト | 記事のURLになります |
Description | テキスト | 簡易的な説明文(記事一覧ページに表示します) |
Thumb | ファイル&メディア | 記事のサムネイル画像 |


データベースが作成できたら、3記事ほど登録してくださいね。
インテグレーション作成
APIで記事を取得するために作成します。
- インテグレーションへのリンク(https://www.notion.so/my-integrations)
- 上記のリンクから「新しいインテグレーション作成」
- 名前は「NotionBlog」とかなんでもOKです。
- ロゴは登録したい方だけどうぞ
- シークレットキーをメモして下さい。こんなやつ→
secret_※※※※※※※※※※※※※※※※※※※※※
データベースとインテグレーションを接続します
先程作成したデータベースと、インテグレーションを接続します。
- データベース右上の「…」三点リーダクリック
- +コネクトの追加
- 作成したインテグレーション(NotionBlog)を選択

接続できたらこんな感じです。

データベース名と、シークレットキーを大切に保管します
大切に保管してください。特にシークレットキーは第三者に見られないように。
データベース名やシークレットトークンについてはこちらの記事を参照してください。
簡易にAPI接続して、データベース名とシークレットキーが正しいか確認する
ターミナルを開いて下記を入力してください。
接続できればデータが取得できます。
エラーなどなく、なにかしら取得できていればOKです。
curl 'https://api.notion.com/v1/databases/データベースID' \ -H 'Notion-Version: 2022-06-28' \ -H 'Authorization: Bearer '"シークレットキー"''
上手く取得できなかった方こちらどうぞ。もう少し詳しく解説しています。
Next.js環境構築
Next.jsのアプリを立ち上げていきます。
作業フォルダ作成
デスクトップなどにフォルダを作成してくだい。
フォルダ名は「notionblog
」などなんでもOKです。
フォルダを作成したらVSCodeで開いて下さい。
Next.jsアプリ立ち上げ
VSCodeを開いたら⌘+j
でVSCode内でターミナルを開いて、下記のコマンドを入力していきます。
notion-blog
という名前で作成
npx create-next-app notion-blog
色々聞かれますが、下記の設定にしてください。
✔ Would you like to use TypeScript with this project? … No / Yes ✔ Would you like to use ESLint with this project? … No / Yes ✔ Would you like to use `src/` directory with this project? … No / Yes ✔ Would you like to use experimental `app/` directory with this project? … No / Yes ✔ What import alias would you like configured? … @/*
cd
でnotion-blogに移動します。
cd notion-blog
notion-blogのディレクトリに移動したら、npm run dev
でローカルサーバーでNext.jsを立ち上げます。
ローカルサーバーhttp://localhost:3000にアクセスします。
npm run dev > notion-blog@0.1.0 dev > next dev - ready started server on 0.0.0.0:3000, url: http://localhost:3000
ローカルホスト3000にアクセスして、下記のページが表示されれば完了です。
見た目が違っても気にしなくてOKです。Next.jsのバージョンによって画面が変わります。


ローカルサーバーを終了させるコマンドはcontrol + c
です。
次はライブラリなどをインストールしていきますが、ローカルサーバーが立ち上がったままだとインストールできないので、一度control+c
で終了させて下さい。
いろいろインストールしていきます。
デザイン考えたり、CSS書くのが面倒なのでUIフレームワークのAnt DesignやTailwind-CSSを入れています。
また、日付変換用のDay.js、コードブロックをエディタのように表示してくれるreact-syntax-highlighterなどなど。
Ant Design
Ant Designのコンポーネントをメインに使っていきます。 styleの記述が不要なのはもちろんですが、ページャーやローディングアイコンなども手軽に実装できます。
npm install antd
Tailwind-css
メインはAnt Designを使用しますが、背景色や文字サイズ、余白やレイアウトなどTailwind-cssを使用します。
npm install -D tailwindcss postcss autoprefixer npx tailwindcss init -p
tailwind.config.js
に下記を追記
/** @type {import('tailwindcss').Config} */ module.exports = { content: [ "./app/**/*.{js,ts,jsx,tsx}", "./pages/**/*.{js,ts,jsx,tsx}", "./components/**/*.{js,ts,jsx,tsx}", // Or if using `src` directory: "./src/**/*.{js,ts,jsx,tsx}", ], theme: { extend: {}, }, plugins: [], }
global.css
に下記を追記
@tailwind base; @tailwind components; @tailwind utilities;
公式のインストール方法と同様です。
VSCodeの拡張機能もインストールしておくと便利
TailwindCSSのクラス名を予測して表示してくれます。

Scss
あまり使わないですが、cssを記述する際は効率よくScssで記述したいと思います。
npm install sass --save-dev
.css
を、.scss
に変更すれば、SCSS フォーマットでスタイル記述できます。
- 置換前:
styles/global**.css**
- 置換後:
styles/global**.scss**
.css
ファイルをインポートしている部分を、.scss
に置換。
import type { AppProps } from 'next/app' import '../styles/global.scss' export default function MyApp({ Component, pageProps }: AppProps) { return <Component {...pageProps} /> }
Day.js
2023-08-01T02:0:00.000Z
のような日付を2023年8月1日
に変換するコンポーネントを作成します。
npm i -S dayjs
ルートディレクトリにcomponents
フォルダを作成し、その中にconvertdate.js
というファイルを作成します。
notion-blog └ components └ convertdate.js
components/convertdate.js
import dayjs from 'dayjs'; export default function Date({ convertDate }) { const publishedAt = dayjs(convertDate).format('YYYY年MM月DD日'); return ( <time dateTime={convertDate}>{publishedAt}</time> ) }
notion-sdk-js
API接続が簡単にできます。
npm install @notionhq/client
notion-to-md
Notionのブロックをマークダウンに変換してくれます。
バージョンを指定してインストールします。
npm install notion-to-md@2.6.0
react-markdown
Notionのブロックをnotion-to-mdを使ってマークダウンに変換し、マークダウンをHTMに変換してブラウザでレンダリングするのに、React Markdownパッケージを使います。
npm install react-markdown
react-syntax-highlighter
コードブロックをいい感じに表示してくれます。
npm install react-syntax-highlighter --save
swr
npm install swr
axios
npm install axios

インストールお疲れ様でした。
大変でしたが、ここからは楽になります。
スタイルの記述やページャーの実装、コードブロックの表示などに時間を使わなくて良くなります。
API接続|API接続してNotionから情報を取得します
.env.local
ファイルを作成します。
ルートディレクトリに.env.local
ファイルを作成します。
API接続時に使用する機密情報を変数にして格納します。
notion-blog └ .env.local
メモしていたデータベース名と、シークレットキーを貼り付けます。
NOTION_TOKEN=secret_**** DATABASE_ID=****
.envファイルの機密情報は他人に見せたり、Gitにアップロードしない
notion.js
ファイルを作成します
API接続して、データベースから情報を取得します。
ルートディレクトリにlib
フォルダを作成し、そのなかにnotion.js
ファイルを作成します。 フォルダ名やファイル名はなんでもOKです。lib
はLibraryの略です。
notion-blog └ lib └ notion.js
公式のnotion-sdk-jsを使用しています。Publishedにチェックマークを入れたものだけ取得しています。
詳しい内容は下記を参照下さい。
lib/notion.js
const { Client } = require("@notionhq/client") const notion = new Client({ auth: process.env.NOTION_TOKEN, }) export const getAllPublished = async () => { const posts = await notion.databases.query({ database_id: process.env.DATABASE_ID, filter: { property: "Published", checkbox: { equals: true, }, }, sorts: [ { property: "Date", direction: "descending", }, ], }); const allPosts = posts.results; return allPosts.map((post) => { return getPageMetaData(post); }); }; const getPageMetaData = (post) => { const getTags = (tags) => { const allTags = tags.map((tag) => { return tag.name; }); return allTags; }; return { id: post.id, last_edited_time: post.last_edited_time, title: post.properties.Name.title[0].plain_text, tags: getTags(post.properties.Tags.multi_select), description: post.properties.Description.rich_text[0]?.plain_text || false, date: post.properties.Date.created_time, slug: post.properties.Slug.rich_text[0].plain_text, thumbnail: post.properties.Thumb && post.properties.Thumb.files.length > 0 ? post.properties.Thumb.files[0].file.url : null, }; } const { NotionToMarkdown } = require("notion-to-md"); const n2m = new NotionToMarkdown({ notionClient: notion }); export const getSingleBlogPostBySlug = async (slug) => { const response = await notion.databases.query({ database_id: process.env.DATABASE_ID, filter: { property: "Slug", formula: { string: { equals: slug, }, }, }, }); const page = response.results[0]; const metadata = getPageMetaData(page); const mdblocks = await n2m.pageToMarkdown(page.id); const mdString = n2m.toMarkdownString(mdblocks); return { metadata, markdown: mdString, }; }
記事一覧ページ
page / index.js
を編集します。
import { useState } from "react"; import Head from "next/head"; import { getAllPublished } from "lib/notion"; import { Image, List, Button } from "antd"; import { RedoOutlined, FieldTimeOutlined, LoadingOutlined } from '@ant-design/icons'; import useSWR from "swr"; import axios from "axios"; import Date from "components/convertdate"; const fetcher = async (url) => { if (!url) return null; const response = await axios.get(url); if (response.data.type === "external") { return response.data.url; } else if (response.data.type === "file") { return response.data.file.url; } return null; }; export const getStaticProps = async () => { const data = await getAllPublished(); return { props: { posts: data, }, revalidate: 60, }; }; export default function Home({ posts }) { const [loading, setLoading] = useState(false); if (!posts) return <h1>No posts</h1>; const { data: imageUrl, error } = useSWR(posts?.thumbnail, fetcher); return ( <div> <Head> <title>Notion API & Next.js</title> <meta name="description" content="Generated by create next app" /> <link rel="icon" href="/favicon.ico" /> </Head> <main> <div className="lg:container mx-auto px-2"> <h1 className="mb-3">Notion API & Next.js</h1> <List itemLayout="vertical" size="large" loading={loading} pagination={{ onChange: (page) => { console.log(page + "ページ目です"); }, pageSize: 3, }} dataSource={posts} renderItem={(post, index) => ( <List.Item key={index}> <div className="flex gap-x-5"> <div className="w-[150px]"> <Image preview={true} width="100%" height="100%" alt={post.title} src={post.thumbnail} fallback="/noimg.jpg" style={{ objectFit: "cover" }} placeholder={ <LoadingOutlined spin /> } /> </div> <div className="flex-1"> <div className="space-y-[0.5rem]"> <h2 className="text-xl"> <p>{post.title}</p> </h2> <div> <p className="flex items-center space-x-1"><FieldTimeOutlined /><Date convertDate={post.date}></Date></p> <p className="flex items-center space-x-1"><RedoOutlined /><Date convertDate={post.last_edited_time}></Date></p> </div> {post.description && ( <p className="whitespace-break-spaces bg-white rounded w-fit p-3">{post.description}</p> )} <Button href={`/posts/${post.slug}`} className="inline-block">More</Button> </div> </div> </div> </List.Item> )} /> </div> </main> </div> ); }
記事詳細ページ
pages
ディレクトリにposts
フォルダを作成し、その中に[slug].js
というファイルを作成します。
notion-blog └ pages └ posts └ [slug].js
pages/[slug].js
import ReactMarkdown from "react-markdown"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { vscDarkPlus } from "react-syntax-highlighter/dist/cjs/styles/prism"; import { getAllPublished, getSingleBlogPostBySlug } from "/lib/notion"; import Date from "conpornents/convertdate"; import { Button, Image } from "antd"; import { RedoOutlined, FieldTimeOutlined, LoadingOutlined } from '@ant-design/icons'; const CodeBlock = ({ language, codestring }) => { return ( <SyntaxHighlighter language={language} style={vscDarkPlus} PreTag="div"> {codestring} </SyntaxHighlighter> ); }; const Post = ({ post }) => { return ( <div className="news-detail px-3"> <article className="space-y-10"> <section className="px-10 pb-10"> <h2 className="mb-2">{post.metadata.title}</h2> {post.metadata.thumbnail && ( <div className="w-[200px]"> <Image preview={true} width="100%" height="100%" alt={post.metadata.title} src={post.metadata.thumbnail}> fallback="/noimg.jpg" style={{ objectFit: "cover" }} placeholder={ <LoadingOutlined spin /> } </Image> </div> )} <div className="space-y-3 mb-10"> {post.metadata.tags?.length > 0 && ( <p className="flex space-x-1"> {post.metadata.tags.map((tag) => ( <span className="tag" key={tag}> {tag} </span> ))} </p> )} <div className="flex space-x-3 justify-end"> <p className="flex items-center space-x-1"><FieldTimeOutlined /><Date convertDate={post.metadata.date}></Date></p> <p className="flex items-center space-x-1"><RedoOutlined /><Date convertDate={post.last_edited_time}></Date></p> </div> </div> <div className="owl"> <ReactMarkdown components={{ code({ node, inline, className, children, ...props }) { const match = /language-(\w+)/.exec(className || ""); return !inline && match ? ( <CodeBlock codestring={String(children).replace(/\n$/, "")} language={match[1]} /> ) : ( <code className={className} {...props}> {children} </code> ); }, }} > {post.markdown} </ReactMarkdown> </div> </section> <div className="text-center"> <Button href="/" size="large" className="inline-block">Back</Button> </div> </article> </div> ); }; export const getStaticProps = async ({ params }) => { const post = await getSingleBlogPostBySlug(params.slug); return { props: { post, }, revalidate: 60, }; }; export const getStaticPaths = async () => { const posts = await getAllPublished(); const paths = posts.map(({ slug }) => ({ params: { slug } })); return { paths, fallback: "blocking", }; }; export default Post;
ダミー画像の設置
サムネイル画像がない場合に表示する画像を作成します。
好きな画像を下記の場所に保存します。
ファイル名はnoimg.jpg
にします。
notion-blog └ public └noimg.jpg
スタイルシート
基本的にはAnt-desingとTailwind-CSSで記述しましたが一部SCSSも記述しています。 global.scss
を下記に変更します。
@tailwind base; @tailwind components; @tailwind utilities; body { padding: 80px 0; background: #ddd; } h1 { font-size: 30px; font-weight: bold; } h2 { font-size: 26px; font-weight: bold; } h3 { font-size: 21px; font-weight: bold; line-height: 1.5; letter-spacing: 0.1em; } ol, ul { list-style: auto; padding: revert; padding-left: 1em; } .news-detail { max-width: 900px; margin-right: auto; margin-left: auto; .owl { > * + * { margin-top: 1.25rem; } pre { margin-top: 35px; } } h1, h2, h3 { margin-top: 2.5rem; } > hr { margin-top: 5px; } section { background: #fff; overflow: hidden; border-radius: 20px; } p { white-space: pre-wrap; } blockquote { border-radius: 3px; border: 1px solid rgba(135, 131, 120, 0.15); background-color: transparent; padding: 16px 16px 16px 12px; background: #fff; } p > code { font-family: "SFMono-Regular", Menlo, Consolas, "PT Mono", "Liberation Mono", Courier, monospace; line-height: normal; background: rgba(135, 131, 120, 0.15); color: #eb5757; border-radius: 3px; font-size: 85%; padding: 0.2em 0.4em; } .tag { background: rgba(135, 131, 120, 0.25); border-radius: 4px; color: inherit; padding: 0px 10px; } } .ant-image-placeholder { width: 100%; height: 100%; background: #fff; display: flex; flex-direction: column; justify-content: center; align-items: center; .anticon { font-size: 20px; color: blue; } } .anticon-left, .anticon-right { vertical-align: middle; } // コードブロックのスタイル $languageMap: ( "abap": "ABAP", "agda": "Agda", "arduino": "Arduino", "assembly": "Assembly", "bash": "Bash", "basic": "BASIC", "bnf": "BNF", "c": "C", "clojure": "Clojure", "coffeescript": "CoffeeScript", "coq": "Coq", "css": "CSS", "dart": "Dart", "dhall": "Dhall", "diff": "Diff", "docker": "Docker", "ebnf": "EBNF", "elixir": "Elixir", "elm": "Elm", "erlang": "Erlang", "f": "F", "flow": "Flow", "fortran": "Fortran", "gherkin": "Gherkin", "glsl": "GLSL", "go": "Go", "graphql": "GraphQL", "groovy": "Groovy", "haskell": "Haskell", "html": "HTML", "idris": "Idris", "java": "Java", "javascript": "JavaScript", "json": "JSON", "julia": "Julia", "kotlin": "Kotlin", "latex": "LaTeX", "less": "Less", "lisp": "Lisp", "livescript": "LiveScript", "llvm": "LLVM IR", "lua": "Lua", "makefile": "Makefile", "markdown": "Markdown", "markup": "Markup", "matlab": "MATLAB", "mathematica": "Mathematica", "mermaid": "Mermaid", "nix": "Nix", "objective-c": "Objective-C", "ocaml": "OCaml", "pascal": "Pascal", "perl": "Perl", "php": "PHP", "text": "Plain Text", "powershell": "PowerShell", "prolog": "Prolog", "protobuf": "Protobuf", "purescript": "PureScript", "python": "Python", "r": "R", "racket": "Racket", "reason": "Reason", "ruby": "Ruby", "rust": "Rust", "sass": "Sass", "scala": "Scala", "scheme": "Scheme", "scss": "Scss", "shell": "Shell", "solidity": "Solidity", "sql": "SQL", "swift": "Swift", "toml": "TOML", "typescript": "TypeScript", "vb": "VB.Net", "verilog": "Verilog", "vhdl": "VHDL", "visual": "Visual Basic", "webassembly": "WebAssembly", "xml": "XML", "yaml": "YAML", ); pre { position: relative; > div { border-radius: 10px 0 10px 10px; } code { &::before { content: ""; padding: 5px 20px; position: absolute; top: 1px; right: 0; transform: translateY(-100%); background: inherit; background: #1e1e1e; border-radius: 5px 5px 0 0; } } @each $language, $name in $languageMap { code.language-#{$language}::before { content: "#{$name}"; } } }
最後に

今回は以上になります。
最後までありがとうございました。