notion-blogs
Turn a Notion database into a production-ready, SEO-friendly blog — with zero CMS to host and zero markdown to write.
@prkedia81/notion-blogs lets you write blog posts in Notion — the editor you already love — and pull them into any React, Next.js, or Node.js app as clean, structured data and ready-to-render HTML. Authors, tags, cover images, SEO descriptions, "similar posts", and rich content blocks all come parsed and typed out of the box.
import { NotionBlog } from '@prkedia81/notion-blogs';
const blog = new NotionBlog(); // reads NOTION_KEY + NOTION_BLOG_ID from env
const posts = await blog.getAllPosts(); // fully-typed BlogPost[]
const post = await blog.getBlogBySlug('hello-world'); // includes rendered HTMLTable of contents
- Why notion-blogs?
- Features
- Installation
- Notion setup (5 minutes)
- Quick start
- Configuration
- API reference
- Data types
- Rendering & styling the HTML
- Supported Notion blocks
- Framework recipes
- Error handling & rate limits
- FAQ
- Roadmap
- Contributing
- License
Why notion-blogs?
Most "Notion as a CMS" solutions hand you back the raw, deeply-nested Notion API response and leave the hard parts to you. This package does the heavy lifting:
| You get | Instead of |
|---|---|
A clean BlogPost object with title, slug, author[], tags[], coverImage[], seoDescription… |
Hundreds of lines of nested properties.title.title[0].plain_text access |
| Rendered, semantic HTML for the post body | A flat list of block objects you have to walk yourself |
| Resolved author and tag relations | Bare relation IDs you'd have to fetch and join manually |
| Built-in slug routing, "similar posts", and published/draft filtering | Custom query + filter plumbing |
| First-class TypeScript types for everything | any everywhere |
| Automatic pagination and rate-limit retries (429 backoff) | Manual cursor loops and retry logic |
Write in Notion. Ship a blog. That's it.
Features
- Author in Notion — your writers never touch code or markdown.
- Block-to-HTML parser — paragraphs, headings, images, callouts, columns, tables, nested lists, to-dos, video (incl. YouTube embeds), files, dividers and rich-text annotations (bold, italic, code, color, links).
- Relations resolved — authors and tags are joined into each post automatically.
- Routing-ready — fetch by numeric
IDor by URLslug. - "Similar posts" — surface related content by shared author/tag with one flag.
- Drafts vs. published — only
isPublishedposts are returned by listings. - Resilient — exponential-backoff retries on Notion rate limits and full pagination of long posts.
- TypeScript-first — ships its own
.d.ts; zero@typesneeded. - Framework-agnostic — works in Next.js (App & Pages Router), Remix, Astro, Express, or any Node runtime.
Installation
npm install @prkedia81/notion-blogs
# or
yarn add @prkedia81/notion-blogs
# or
pnpm add @prkedia81/notion-blogsReact is an optional peer dependency — you only need it if you use the bundled UI components. The core data/HTML APIs are pure Node and run anywhere.
# only if you use the React components
npm install react react-domNotion setup (5 minutes)
1. Create a Notion integration
- Go to notion.so/my-integrations → New integration.
- Give it a name (e.g.
My Blog) and pick the workspace. - Copy the Internal Integration Secret — this is your
NOTION_KEY.
2. Create your databases
You need one blog database (required) and, optionally, author and tag databases. Create the properties below — names are case-sensitive and must match exactly (note the space in AI summary).
Blog database (required)
| Property | Notion type | Required | Notes |
|---|---|---|---|
title |
Title | Post title. | |
isPublished |
Checkbox | Only true posts appear in listings. |
|
ID |
Unique ID | Auto-incrementing; used by getBlogById. |
|
slug |
Text | – | URL slug. Falls back to a slugified title if empty. |
author |
Relation → Author DB | – | One or more authors. |
tags |
Relation → Tag DB | – | One or more tags. |
publishedDate |
Date | – | Display/publish date. |
coverImage |
Files & media | – | Hero image(s). |
seoDescription |
Text | – | Meta description for <head>. |
AI summary |
Text | – | Optional short summary (note the space). |
featured |
Checkbox | – | Flag for highlighting a post. |
createdDate |
Created time | – | Auto-managed by Notion. |
lastEditedDate |
Last edited time | – | Auto-managed by Notion. |
The body of each Notion page becomes the post content and is parsed to HTML when you fetch a single post.
Author database (optional)
| Property | Notion type | Notes |
|---|---|---|
name |
Title | Author name. |
slug |
Formula → string | e.g. lower(replaceAll(prop("name"), " ", "-")). Used by getPostsByAuthor. |
authorBio |
Text | Short bio. |
authorImage |
Files & media | Avatar(s). |
ID |
Unique ID | |
createdDate |
Created time | |
lastEditedDate |
Last edited time |
Tag database (optional)
| Property | Notion type | Notes |
|---|---|---|
name |
Title | Tag name. |
slug |
Formula → string | Used by getPostsByTag. |
ID |
Unique ID | |
createdDate |
Created time | |
lastEditedDate |
Last edited time |
If you don't configure an author or tag database, those relations simply resolve to empty arrays — the blog still works.
3. Share the databases with your integration
Open each database in Notion → ••• menu → Connections → add the integration you created. Without this step the API returns nothing.
4. Grab the database IDs
The database ID is the 32-character hash in the database URL:
https://www.notion.so/workspace/8a4b1c2d3e4f5061728394a5b6c7d8e9?v=...
└──────────── database ID ────────────┘
Quick start
The fastest path is environment variables. Create a .env:
NOTION_KEY=secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
NOTION_BLOG_ID=8a4b1c2d3e4f5061728394a5b6c7d8e9
NOTION_TAG_ID=... # optional
NOTION_AUTHOR_ID=... # optionalThen:
import { NotionBlog } from '@prkedia81/notion-blogs';
const blog = new NotionBlog();
// List every published post (no body content, ideal for index pages)
const posts = await blog.getAllPosts();
// Fetch one post by slug — includes rendered HTML in `post.content`
const post = await blog.getBlogBySlug('my-first-post');
console.log(post?.title);
console.log(post?.content); // -> "<h1>…</h1><p>…</p>"Prefer explicit config over env vars? Pass it to the constructor:
const blog = new NotionBlog({
notionKey: process.env.NOTION_KEY,
blogDatabaseId: process.env.NOTION_BLOG_ID,
authorDatabaseId: process.env.NOTION_AUTHOR_ID, // optional
tagDatabaseId: process.env.NOTION_TAG_ID, // optional
});Configuration
Configuration is resolved from the constructor first, then falls back to environment variables. Credentials are cached on a singleton client, so initialize once and reuse.
| Constructor option | Environment variable | Required | Description |
|---|---|---|---|
notionKey |
NOTION_KEY |
Notion integration secret. | |
blogDatabaseId |
NOTION_BLOG_ID |
Blog database ID. | |
authorDatabaseId |
NOTION_AUTHOR_ID |
– | Author database ID. |
tagDatabaseId |
NOTION_TAG_ID |
– | Tag database ID. |
Setting
NODE_ENV=developmentenables verbose[notion-blogs]:debug logging.
API reference
class NotionBlog
The recommended entry point. Construct it once and call its async methods.
const blog = new NotionBlog(config?);| Method | Returns | Description |
|---|---|---|
getAllPosts() |
Promise<BlogPost[]> |
All published posts with authors and tags resolved. Body content is not included (fast — perfect for index/listing pages). |
getBlogById(id, includeSimilarBlogs?) |
Promise<BlogPost | null> |
Fetch a single post by its numeric ID. Includes rendered HTML in content. Returns null if not found. |
getBlogBySlug(slug, includeSimilarBlogs?) |
Promise<BlogPost | null> |
Fetch a single post by its slug. Includes rendered HTML in content. Returns null if not found. |
getAllAuthors() |
Promise<Authors[]> |
Every author. Returns [] if no author DB is configured. |
getPostsByAuthor(authorSlug) |
Promise<{ author: Authors | null; filteredBlogs: BlogPost[] }> |
The author plus all their published posts. |
getAllTags() |
Promise<Tags[]> |
Every tag. Returns [] if no tag DB is configured. |
getPostsByTag(tagSlug) |
Promise<BlogPost[]> |
All published posts carrying that tag. |
Pass includeSimilarBlogs = true to populate post.similarPosts with up to 5 related posts (matched by shared author or tag, falling back to other recent posts).
const post = await blog.getBlogBySlug('scaling-notion', true);
post?.similarPosts?.forEach((p) => console.log(p.title));Standalone functions
If you'd rather manage the Notion Client yourself (e.g. for DI or testing), every operation is exported as a standalone function that takes a Client as its first argument:
import {
getClient,
getAllPosts,
getBlogById,
getBlogBySlug,
getPageById,
getAllAuthors,
getPostsByAuthor,
getAllTags,
getPostsByTag,
} from '@prkedia81/notion-blogs';
const client = getClient(); // singleton @notionhq/client, configured from env
const posts = await getAllPosts(client);parseBlocksToHTML(blocks)
Convert an array of Notion block objects into a semantic HTML string. Useful if you fetch blocks yourself but still want this package's renderer.
import { parseBlocksToHTML } from '@prkedia81/notion-blogs';
const html = parseBlocksToHTML(blocks); // -> string of HTMLThere's also a formatDate(isoString) helper that returns a friendly date (e.g. "June 25, 2026").
import { formatDate } from '@prkedia81/notion-blogs';
formatDate(post.publishedDate); // "June 25, 2026"Data types
Everything is fully typed. The core shapes:
interface BlogPost {
ID: number;
title: string;
slug: string;
content?: string; // rendered HTML (single-post fetches only)
author: Authors[];
tags: Tags[];
coverImage: string[];
seoDescription: string;
'AI summary': string;
featured: boolean;
isPublished: boolean;
publishedDate: string; // ISO date
createdDate: string; // ISO date
lastEditedDate: string; // ISO date
similarPosts?: BlogPost[]; // when includeSimilarBlogs = true
}
interface Authors {
uuid: string;
id: number;
name: string;
slug: string;
image: string[];
bio: string;
createdDate: string;
lastEditedDate: string;
}
interface Tags {
uuid: string;
id: number;
name: string;
slug: string;
createdDate: string;
lastEditedDate: string;
}The lower-level Block, RichText, and per-block-type interfaces are exported too, for anyone building a custom renderer.
Rendering & styling the HTML
getBlogById / getBlogBySlug return ready-to-render HTML in content. Drop it in with dangerouslySetInnerHTML (or your framework's equivalent):
export default function Post({ post }: { post: BlogPost }) {
return (
<article className="notion-content">
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content ?? '' }} />
</article>
);
}The generated HTML uses stable, predictable class names so you can style it however you like. Key hooks:
| Class | Applied to |
|---|---|
.notion-heading |
<h1>/<h2>/<h3> |
.notion-image |
image <figure> |
.notion-callout |
callout box |
.notion-column-list / .notion-column |
multi-column layouts |
.notion-table / .notion-table-container |
tables |
.notion-video / .notion-video-container |
video & YouTube embeds |
.notion-file |
file attachments |
.notion-todo-item (.checked) |
to-do items |
.notion-hr |
dividers |
.color-<name> |
Notion text/background colors |
Style these classes in your own CSS, or scope a .prose / Tailwind Typography wrapper around the rendered content for instant, attractive defaults.
Supported Notion blocks
| Supported | Notes |
|---|---|
| Paragraph | with bold, italic, strikethrough, underline, inline code, color, links |
| Heading 1 / 2 / 3 | |
| Bulleted list | nested children supported |
| Numbered list | nested children supported |
| To-do | rendered with a disabled checkbox reflecting checked state |
| Image | external & uploaded, with captions, lazy-loaded |
| Callout | emoji icon + color |
| Columns | multi-column layouts (column_list / column) |
| Table | header rows, multi-column |
| Video | direct files and YouTube → responsive iframe embed |
| File | download link with filename |
| Divider |
Unsupported block types are skipped gracefully rather than throwing.
Framework recipes
Next.js (App Router)
// app/blog/[slug]/page.tsx
import { NotionBlog } from '@prkedia81/notion-blogs';
const blog = new NotionBlog();
export async function generateStaticParams() {
const posts = await blog.getAllPosts();
return posts.map((p) => ({ slug: p.slug }));
}
export async function generateMetadata({ params }: { params: { slug: string } }) {
const post = await blog.getBlogBySlug(params.slug);
return { title: post?.title, description: post?.seoDescription };
}
export default async function BlogPostPage({ params }: { params: { slug: string } }) {
const post = await blog.getBlogBySlug(params.slug, true);
if (!post) return <p>Not found</p>;
return (
<article>
<h1>{post.title}</h1>
{post.coverImage[0] && <img src={post.coverImage[0]} alt={post.title} />}
<div dangerouslySetInnerHTML={{ __html: post.content ?? '' }} />
</article>
);
}Next.js (Pages Router)
// pages/blog/[slug].tsx
import type { GetStaticPaths, GetStaticProps } from 'next';
import { NotionBlog, type BlogPost } from '@prkedia81/notion-blogs';
const blog = new NotionBlog();
export const getStaticPaths: GetStaticPaths = async () => {
const posts = await blog.getAllPosts();
return { paths: posts.map((p) => ({ params: { slug: p.slug } })), fallback: 'blocking' };
};
export const getStaticProps: GetStaticProps = async ({ params }) => {
const post = await blog.getBlogBySlug(params!.slug as string);
if (!post) return { notFound: true };
return { props: { post }, revalidate: 60 };
};
export default function Post({ post }: { post: BlogPost }) {
return <div dangerouslySetInnerHTML={{ __html: post.content ?? '' }} />;
}Express / Node API
import express from 'express';
import { NotionBlog } from '@prkedia81/notion-blogs';
const app = express();
const blog = new NotionBlog();
app.get('/api/posts', async (_req, res) => {
res.json(await blog.getAllPosts());
});
app.get('/api/posts/:slug', async (req, res) => {
const post = await blog.getBlogBySlug(req.params.slug);
post ? res.json(post) : res.status(404).json({ error: 'Not found' });
});
app.listen(3000);Error handling & rate limits
- Rate limits (HTTP 429): requests are automatically retried with exponential backoff (3 attempts, doubling delay) — you don't need to handle 429s yourself.
- Pagination: long posts are fully paginated; you always get the complete body, no matter the length.
- Missing posts:
getBlogById/getBlogBySlugreturnnull(and log a warning) rather than throwing, so guarding withif (!post)is enough. - Missing config: constructing without a key (and no
NOTION_KEY) throws a clear, actionable error.
const post = await blog.getBlogBySlug('does-not-exist');
if (!post) {
// render your 404
}FAQ
Do I need the author and tag databases?
No. They're optional. Without them, author and tags resolve to empty arrays.
Why is content empty on getAllPosts()?
By design — listing pages don't need full bodies, so listings skip the (expensive) block fetch. Use getBlogBySlug / getBlogById for the rendered HTML.
Are drafts exposed?
No. Only posts with isPublished = true are returned by the listing methods.
Is it server-side only?
Treat it as server-side. Your NOTION_KEY must never reach the browser — call these methods in server components, route handlers, getStaticProps, or your backend.
Does it work with TypeScript?
Yes — types ship with the package, no @types/... needed.
Roadmap
- Richer, themeable React components (cards, lists, full post renderer)
- Pluggable custom block renderers
- Code blocks with syntax highlighting
- Audio & toggle block support
- Built-in flex/grid layout presets
- Incremental cache / revalidation helpers
Have a request? Open an issue.
Contributing
Contributions, issues, and feature requests are welcome!
git clone https://github.com/prkedia81/notion-blog-package.git
cd notion-blog-package
npm install
npm run build # compile TS + build CSS
npm testPlease run npm run lint and npm run format before opening a PR.
License
ISC Prannay Kedia
Built for people who'd rather write in Notion than wrestle a CMS.