Building a blog doesn't have to cost you anything. In this comprehensive guide, we'll show you how to create a professional blog website using Notion as your backend and deploy it for free using modern JAMstack tools.
Why Use Notion as a Blog Backend?
Notion has become one of the most popular productivity tools, and for good reason:
- Beautiful writing experience - Write in a distraction-free environment with rich formatting
- No database costs - Notion handles all your content storage for free
- Real-time collaboration - Work with team members on blog posts
- Media management - Easily add images, videos, and embeds
- Version history - Track changes and revert when needed
What You'll Build
By the end of this tutorial, you'll have:
- A Notion database to manage your blog posts
- A Next.js website that fetches content from Notion
- Free hosting on Vercel or Netlify
- A custom domain (optional)
Prerequisites
Before we start, make sure you have:
- A Notion account (free tier works)
- Node.js installed (v16 or higher)
- Basic knowledge of JavaScript/React
- A GitHub account
- A Vercel or Netlify account (free tier)
Step 1: Set Up Your Notion Database
Create a Blog Database
- Open Notion and create a new page
- Add a Database - Full page block
- Set up the following properties:
| Property | Type | Description |
|---|---|---|
| Title | Title | Blog post title |
| Slug | Text | URL-friendly identifier |
| Published | Checkbox | Whether post is live |
| Date | Date | Publication date |
| Excerpt | Text | Short description |
| Cover | Files | Featured image |
| Tags | Multi-select | Categories/tags |
Get Your Notion API Key
- Go to Notion Integrations
- Click "+ New integration"
- Name it (e.g., "My Blog")
- Select your workspace
- Copy the Internal Integration Token
Share Database with Integration
- Open your blog database in Notion
- Click Share in the top right
- Click "Invite" and search for your integration name
- Give it access
Get Your Database ID
The database ID is in the URL when you open your database:
https://notion.so/myworkspace/a8aec43384f447ed84390e8e42c2e089?v=...
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
This is your database ID
Step 2: Create Your Next.js Project
Initialize the Project
npx create-next-app@latest my-notion-blog
cd my-notion-blog
Choose the following options:
- TypeScript: Yes
- ESLint: Yes
- Tailwind CSS: Yes
- App Router: Yes
Install Notion Client
npm install @notionhq/client
Set Up Environment Variables
Create a .env.local file:
NOTION_API_KEY=your_integration_token_here
NOTION_DATABASE_ID=your_database_id_here
Step 3: Build the Notion Integration
Create the Notion Client
Create lib/notion.ts:
import { Client } from '@notionhq/client';
const notion = new Client({
auth: process.env.NOTION_API_KEY,
});
export const getDatabase = async () => {
const response = await notion.databases.query({
database_id: process.env.NOTION_DATABASE_ID!,
filter: {
property: 'Published',
checkbox: {
equals: true,
},
},
sorts: [
{
property: 'Date',
direction: 'descending',
},
],
});
return response.results;
};
export const getPage = async (pageId: string) => {
const response = await notion.pages.retrieve({ page_id: pageId });
return response;
};
export const getBlocks = async (blockId: string) => {
const blocks = [];
let cursor;
while (true) {
const { results, next_cursor } = await notion.blocks.children.list({
block_id: blockId,
start_cursor: cursor,
});
blocks.push(...results);
if (!next_cursor) break;
cursor = next_cursor;
}
return blocks;
};
Create the Blog List Page
Update app/page.tsx:
import Link from 'next/link';
import { getDatabase } from '@/lib/notion';
export const revalidate = 60; // Revalidate every 60 seconds
export default async function Home() {
const posts = await getDatabase();
return (
<main className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8">My Blog</h1>
<div className="grid gap-6">
{posts.map((post: any) => {
const title = post.properties.Title?.title[0]?.plain_text;
const slug = post.properties.Slug?.rich_text[0]?.plain_text;
const excerpt = post.properties.Excerpt?.rich_text[0]?.plain_text;
const date = post.properties.Date?.date?.start;
return (
<Link
key={post.id}
href={`/blog/${slug}`}
className="block p-6 border rounded-lg hover:shadow-lg transition"
>
<h2 className="text-2xl font-semibold mb-2">{title}</h2>
{excerpt && <p className="text-gray-600 mb-2">{excerpt}</p>}
{date && <time className="text-sm text-gray-500">{date}</time>}
</Link>
);
})}
</div>
</main>
);
}
Create the Blog Post Page
Create app/blog/[slug]/page.tsx:
import { getDatabase, getPage, getBlocks } from '@/lib/notion';
import { notFound } from 'next/navigation';
// Render different block types
function renderBlock(block: any) {
const { type, id } = block;
const value = block[type];
switch (type) {
case 'paragraph':
return (
<p key={id} className="mb-4">
{value.rich_text.map((text: any) => text.plain_text).join('')}
</p>
);
case 'heading_1':
return (
<h1 key={id} className="text-3xl font-bold mt-8 mb-4">
{value.rich_text.map((text: any) => text.plain_text).join('')}
</h1>
);
case 'heading_2':
return (
<h2 key={id} className="text-2xl font-bold mt-6 mb-3">
{value.rich_text.map((text: any) => text.plain_text).join('')}
</h2>
);
case 'heading_3':
return (
<h3 key={id} className="text-xl font-bold mt-4 mb-2">
{value.rich_text.map((text: any) => text.plain_text).join('')}
</h3>
);
case 'bulleted_list_item':
return (
<li key={id} className="ml-4">
{value.rich_text.map((text: any) => text.plain_text).join('')}
</li>
);
case 'numbered_list_item':
return (
<li key={id} className="ml-4 list-decimal">
{value.rich_text.map((text: any) => text.plain_text).join('')}
</li>
);
case 'code':
return (
<pre key={id} className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto my-4">
<code>{value.rich_text.map((text: any) => text.plain_text).join('')}</code>
</pre>
);
case 'image':
const src = value.type === 'external' ? value.external.url : value.file.url;
return (
<img key={id} src={src} alt="" className="rounded-lg my-4" />
);
default:
return null;
}
}
export default async function BlogPost({ params }: { params: { slug: string } }) {
const posts = await getDatabase();
const post = posts.find((p: any) =>
p.properties.Slug?.rich_text[0]?.plain_text === params.slug
);
if (!post) {
notFound();
}
const blocks = await getBlocks(post.id);
const title = post.properties.Title?.title[0]?.plain_text;
return (
<article className="max-w-3xl mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8">{title}</h1>
<div className="prose prose-lg max-w-none">
{blocks.map((block: any) => renderBlock(block))}
</div>
</article>
);
}
Step 4: Deploy for Free
Push to GitHub
git init
git add .
git commit -m "Initial commit"
git remote add origin https://github.com/yourusername/my-notion-blog.git
git push -u origin main
Deploy to Vercel
- Go to vercel.com
- Click "New Project"
- Import your GitHub repository
- Add your environment variables:
NOTION_API_KEYNOTION_DATABASE_ID
- Click Deploy
Your blog is now live at your-project.vercel.app!
Custom Domain (Optional)
- In Vercel, go to your project settings
- Click Domains
- Add your custom domain
- Update your DNS settings as instructed
Advanced Features
Adding Comments
Integrate a free commenting system like:
- Giscus - Uses GitHub Discussions
- Utterances - Uses GitHub Issues
SEO Optimization
Add metadata to your pages:
export async function generateMetadata({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
},
};
}
RSS Feed
Create an RSS feed endpoint for subscribers:
// app/rss.xml/route.ts
import { getDatabase } from '@/lib/notion';
export async function GET() {
const posts = await getDatabase();
const feed = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>My Blog</title>
<link>https://yourdomain.com</link>
<description>My awesome blog</description>
${posts.map((post: any) => `
<item>
<title>${post.properties.Title?.title[0]?.plain_text}</title>
<link>https://yourdomain.com/blog/${post.properties.Slug?.rich_text[0]?.plain_text}</link>
<pubDate>${post.properties.Date?.date?.start}</pubDate>
</item>
`).join('')}
</channel>
</rss>`;
return new Response(feed, {
headers: {
'Content-Type': 'application/xml',
},
});
}
Cost Breakdown
| Service | Cost |
|---|---|
| Notion | Free |
| Vercel Hosting | Free |
| GitHub | Free |
| Custom Domain | ~$10-15/year (optional) |
Total: $0/year (or ~$12/year with custom domain)
Conclusion
You've just built a fully functional blog with:
- Zero hosting costs
- A beautiful writing experience in Notion
- Automatic deployments when you push to GitHub
- Server-side rendering for SEO
- Incremental static regeneration for performance
This setup scales incredibly well. Notion handles your content, Vercel handles your traffic, and you focus on writing great content.
Next Steps
- Style your blog - Add custom CSS or use a component library
- Add analytics - Integrate Plausible or PostHog (both have free tiers)
- Set up a newsletter - Use Buttondown or Substack embeds
- Write your first post - The best time to start is now!
Have questions? Feel free to reach out to us at [email protected] or explore our blog platform for a complete hosted solution.
