Building a blog with Next.js and Supabase is one of the best full-stack projects you can build as a developer in 2025. You'll learn server-side rendering, database management, dynamic routing, and production deployment — all in one cohesive project.
By the end of this tutorial, you will have:
- A live blog at your own domain (hosted free on Vercel)
- A Supabase PostgreSQL database storing your posts
- SEO-optimised pages with dynamic metadata
- Incremental Static Regeneration for fast loading
Prerequisites: Basic knowledge of JavaScript and React. No backend experience needed.
Total cost: $0.
What Is Next.js and Why Use It for a Blog?
Next.js is a React framework built by Vercel that adds server-side rendering, static site generation, file-based routing, and image optimisation out of the box. For a blog, this matters because:
- SEO: Server-rendered pages give Google the full HTML immediately, not JavaScript that needs to execute
- Speed: Static pages load in milliseconds from a global CDN
- Developer experience: File-based routing, TypeScript support, and automatic code splitting are built in
Next.js powers major blogs including the official React, Supabase, and Vercel docs — it is battle-tested for content-heavy websites.
Step 1: Create Your Next.js Project
Open your terminal and run:
npx create-next-app@latest my-blog --typescript --tailwind --app --src-dir=false
cd my-blog
When prompted, accept the defaults. This creates a Next.js 15 project with TypeScript, Tailwind CSS, and the App Router.
Start the development server:
npm run dev
Visit http://localhost:3000 — your app is running.
Step 2: Set Up Supabase
Go to supabase.com and create a free account and a new project. Choose a region close to your target audience (Singapore is closest for Pakistani users serving Asia).
Once your project is ready:
-
Go to Settings → API and copy:
- Project URL (starts with
https://) - Anon public key (safe to expose in browser)
- Project URL (starts with
-
Create
.env.localin your project root:
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
- Install the Supabase JS client:
npm install @supabase/supabase-js
Step 3: Create the Posts Table
In your Supabase dashboard, open the SQL Editor and run:
CREATE TABLE posts (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
excerpt TEXT,
content_md TEXT,
cover_image TEXT,
category TEXT DEFAULT 'General',
tags TEXT[] DEFAULT '{}',
author_name TEXT DEFAULT 'Author',
published_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
views INT DEFAULT 0,
is_published BOOLEAN DEFAULT false
);
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Public reads published posts"
ON posts FOR SELECT USING (is_published = true);
This creates the table and enables Row Level Security so only published posts are visible to the public.
Step 4: Create the Supabase Client
Create lib/supabase.ts:
import { createClient } from '@supabase/supabase-js'
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
export function getSupabase() {
return createClient(url, key)
}
Step 5: Build the Blog Listing Page
Create app/blog/page.tsx:
import { getSupabase } from '@/lib/supabase'
import Link from 'next/link'
export const revalidate = 3600 // Rebuild every hour (ISR)
export default async function BlogPage() {
const sb = getSupabase()
const { data: posts } = await sb
.from('posts')
.select('id, title, slug, excerpt, published_at, category, read_time')
.eq('is_published', true)
.order('published_at', { ascending: false })
return (
<main className="max-w-4xl mx-auto px-4 py-12">
<h1 className="text-4xl font-bold mb-8">Blog</h1>
<div className="grid gap-6">
{(posts ?? []).map(post => (
<article key={post.id} className="border border-slate-200 rounded-xl p-6 hover:shadow-md transition-shadow">
<span className="text-xs font-semibold text-blue-600 uppercase">{post.category}</span>
<h2 className="text-xl font-bold mt-1 mb-2">
<Link href={`/blog/${post.slug}`} className="hover:text-blue-600 transition-colors">
{post.title}
</Link>
</h2>
<p className="text-slate-600 text-sm leading-relaxed">{post.excerpt}</p>
<div className="flex items-center gap-4 mt-3 text-xs text-slate-400">
<span>{new Date(post.published_at).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}</span>
<span>{post.read_time} min read</span>
</div>
</article>
))}
</div>
</main>
)
}
Step 6: Build the Individual Post Page
Install a Markdown package:
npm install remark remark-gfm remark-html
Create app/blog/[slug]/page.tsx:
import { getSupabase } from '@/lib/supabase'
import { remark } from 'remark'
import remarkGfm from 'remark-gfm'
import remarkHtml from 'remark-html'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
export const revalidate = 3600
async function getPost(slug: string) {
const { data } = await getSupabase()
.from('posts')
.select('*')
.eq('slug', slug)
.eq('is_published', true)
.single()
return data
}
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
const post = await getPost(params.slug)
if (!post) return { title: 'Not Found' }
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: `/blog/${post.slug}` },
}
}
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug)
if (!post) notFound()
const html = await remark()
.use(remarkGfm)
.use(remarkHtml)
.process(post.content_md)
return (
<main className="max-w-3xl mx-auto px-4 py-12">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<p className="text-slate-500 text-sm mb-8">
{new Date(post.published_at).toLocaleDateString()} · {post.read_time} min read
</p>
<div
className="prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: html.toString() }}
/>
</main>
)
}
Step 7: Add Your First Post
In Supabase, go to Table Editor → posts and insert a row manually:
| Field | Value |
|---|---|
| title | My First Blog Post |
| slug | my-first-blog-post |
| excerpt | Welcome to my new blog. |
| content_md | # Hello World\n\nThis is my first post. |
| is_published | true |
| read_time | 1 |
Visit http://localhost:3000/blog/my-first-blog-post — your post is live.
Step 8: Generate a Dynamic Sitemap
Create app/sitemap.ts:
import { MetadataRoute } from 'next'
import { getSupabase } from '@/lib/supabase'
const SITE_URL = 'https://your-domain.com'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const { data: posts } = await getSupabase()
.from('posts')
.select('slug, updated_at')
.eq('is_published', true)
const postPages = (posts ?? []).map(p => ({
url: `${SITE_URL}/blog/${p.slug}`,
lastModified: new Date(p.updated_at),
changeFrequency: 'weekly' as const,
priority: 0.8,
}))
return [
{ url: SITE_URL, lastModified: new Date(), priority: 1.0 },
{ url: `${SITE_URL}/blog`, lastModified: new Date(), priority: 0.9 },
...postPages,
]
}
Step 9: Deploy to Vercel
- Push your project to GitHub
- Go to vercel.com → New Project → Import your repo
- Add environment variables:
NEXT_PUBLIC_SUPABASE_URLandNEXT_PUBLIC_SUPABASE_ANON_KEY - Click Deploy
Your blog is live in under 3 minutes. Vercel's free tier includes custom domains, SSL, and a global CDN.
FAQ
Do I need TypeScript to build this blog?
No. Remove the .tsx extensions and use .jsx instead, and delete all type annotations. However, TypeScript is strongly recommended for real projects because it catches bugs at compile time rather than in production.
Is Supabase free to use? Supabase's free tier supports up to 500 MB storage, 2 GB bandwidth, 50,000 monthly active users, and unlimited API requests. This is more than sufficient for a personal blog until it reaches significant traffic.
What is ISR (Incremental Static Regeneration) and why does it matter?
ISR lets Next.js pre-render pages as static HTML (instant loading) while automatically rebuilding them in the background at set intervals. For a blog, your pages load at CDN speed but stay up to date. The export const revalidate = 3600 in the page component tells Next.js to rebuild it every hour.
Can I add an admin panel to this blog? Yes. Use Supabase Auth to add authentication and create admin-only routes. You can manage posts through a custom admin panel or directly through the Supabase Table Editor.
How do I add images to Supabase?
Use Supabase Storage. Go to Storage → New Bucket → upload images. Reference the public URL in your markdown like: .