How to Build a Blog with Next.js 15 and Supabase (Free Step-by-Step Tutorial)
Web Development

How to Build a Blog with Next.js 15 and Supabase (Free Step-by-Step Tutorial)

Build a fully functional, SEO-optimised blog using Next.js 15 App Router and Supabase in under 2 hours. Free hosting on Vercel included. No backend experience required.

MSP
Muhammad Shams Paracha
January 25, 20259 min read0 views
#Next.js#Supabase#tutorial#React#blog#web development#Vercel

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:

  1. Go to Settings → API and copy:

    • Project URL (starts with https://)
    • Anon public key (safe to expose in browser)
  2. Create .env.local in your project root:

NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
  1. 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

  1. Push your project to GitHub
  2. Go to vercel.com → New Project → Import your repo
  3. Add environment variables: NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY
  4. 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: ![Description](https://project.supabase.co/storage/v1/object/public/images/photo.jpg).

Was this helpful?

Share this article

MS

Written by

Muhammad Shams Paracha

Front-End Developer specialising in Next.js and React. Builder of production SaaS apps. Based in Islamabad, Pakistan.

Related Articles

Comments

Add a Comment

Your email will never be published.