- Learn
- Guided Projects
- Build a Blog
Intermediate60 min1 prerequisite
Create a full-featured blog with markdown support, categories, and a content management dashboard.
Build a Blog
Build a complete blog platform with posts, categories, markdown support, and an admin dashboard for content management.
Project Overview
What We're Building
Terminal
Blog Features:
├── Public blog pages
│ ├── Homepage with post list
│ ├── Individual post pages
│ ├── Category filtering
│ └── Search
├── Admin dashboard
│ ├── Create/edit posts
│ ├── Manage categories
│ ├── Draft/publish workflow
│ └── Media upload
└── Authentication
└── Admin-only access
Tech Stack
- Frontend: Next.js + Tailwind + shadcn/ui
- Backend: Supabase (database + auth + storage)
- Markdown: MDX or react-markdown
- Deployment: Vercel
Phase 1: Database Design
Create Tables
Terminal
-- Categories table
CREATE TABLE categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Posts table
CREATE TABLE posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
content TEXT NOT NULL,
excerpt TEXT,
featured_image TEXT,
category_id UUID REFERENCES categories(id),
author_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'published')),
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Enable RLS
ALTER TABLE categories ENABLE ROW LEVEL SECURITY;
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- Public can read published posts
CREATE POLICY "Public can read published posts"
ON posts FOR SELECT
USING (status = 'published');
-- Public can read categories
CREATE POLICY "Public can read categories"
ON categories FOR SELECT
USING (true);
-- Authenticated users can manage posts
CREATE POLICY "Authors can manage posts"
ON posts FOR ALL
TO authenticated
USING (auth.uid() = author_id)
WITH CHECK (auth.uid() = author_id);
-- Authenticated users can manage categories
CREATE POLICY "Authenticated can manage categories"
ON categories FOR ALL
TO authenticated
USING (true)
WITH CHECK (true);
Seed Categories
Terminal
INSERT INTO categories (name, slug, description) VALUES
('Technology', 'technology', 'Tech news and tutorials'),
('Design', 'design', 'UI/UX and design thinking'),
('Development', 'development', 'Coding and software'),
('AI', 'ai', 'Artificial intelligence topics');
Phase 2: Public Blog Pages
AI Prompt for Blog UI
Terminal
"Create a blog frontend with Next.js and Tailwind.
Pages needed:
1. Homepage showing latest posts in a grid
2. Post page showing full content with markdown
3. Category page showing filtered posts
4. About page
Each post card should show:
- Featured image
- Title
- Excerpt
- Category badge
- Date
Use a clean, modern design with good typography."
Homepage Structure
Terminal
// app/page.tsx
import { createClient } from '@/lib/supabase/server'
import { PostCard } from '@/components/PostCard'
export default async function HomePage() {
const supabase = await createClient()
const { data: posts } = await supabase
.from('posts')
.select(`
*,
category:categories(name, slug)
`)
.eq('status', 'published')
.order('published_at', { ascending: false })
.limit(12)
return (
<main className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8">Latest Posts</h1>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{posts?.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
</main>
)
}
Post Page with Markdown
Terminal
npm install react-markdown remark-gfm
Terminal
// app/blog/[slug]/page.tsx
import { createClient } from '@/lib/supabase/server'
import { notFound } from 'next/navigation'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
interface Props {
params: Promise<{ slug: string }>
}
export default async function PostPage({ params }: Props) {
const { slug } = await params
const supabase = await createClient()
const { data: post } = await supabase
.from('posts')
.select(`
*,
category:categories(name, slug)
`)
.eq('slug', slug)
.eq('status', 'published')
.single()
if (!post) notFound()
return (
<article className="container max-w-3xl mx-auto px-4 py-8">
{post.featured_image && (
<img
src={post.featured_image}
alt={post.title}
className="w-full h-64 object-cover rounded-lg mb-8"
/>
)}
<header className="mb-8">
<span className="text-sm text-primary">
{post.category?.name}
</span>
<h1 className="text-4xl font-bold mt-2">{post.title}</h1>
<time className="text-muted-foreground">
{new Date(post.published_at).toLocaleDateString()}
</time>
</header>
<div className="prose prose-lg max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{post.content}
</ReactMarkdown>
</div>
</article>
)
}
Phase 3: Admin Dashboard
AI Prompt for Admin
Terminal
"Create an admin dashboard at /admin for managing blog posts.
Include:
1. Posts list with edit/delete actions
2. Create new post form with:
- Title input
- Slug (auto-generated from title)
- Category dropdown
- Content textarea (markdown)
- Featured image upload
- Draft/Publish toggle
3. Edit post page
4. Categories management
Protect all /admin routes - require authentication."
Admin Layout
Terminal
// app/admin/layout.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { AdminSidebar } from '@/components/AdminSidebar'
export default async function AdminLayout({
children
}: {
children: React.ReactNode
}) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
return (
<div className="flex min-h-screen">
<AdminSidebar />
<main className="flex-1 p-8">
{children}
</main>
</div>
)
}
Post Editor Form
Terminal
// components/PostEditor.tsx
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useRouter } from 'next/navigation'
interface PostEditorProps {
post?: Post
categories: Category[]
}
export function PostEditor({ post, categories }: PostEditorProps) {
const [title, setTitle] = useState(post?.title || '')
const [slug, setSlug] = useState(post?.slug || '')
const [content, setContent] = useState(post?.content || '')
const [categoryId, setCategoryId] = useState(post?.category_id || '')
const [status, setStatus] = useState(post?.status || 'draft')
const [saving, setSaving] = useState(false)
const router = useRouter()
const supabase = createClient()
// Auto-generate slug from title
function handleTitleChange(value: string) {
setTitle(value)
if (!post) {
setSlug(value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''))
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setSaving(true)
const { data: { user } } = await supabase.auth.getUser()
const postData = {
title,
slug,
content,
category_id: categoryId || null,
status,
author_id: user?.id,
published_at: status === 'published' ? new Date().toISOString() : null,
updated_at: new Date().toISOString()
}
if (post) {
await supabase.from('posts').update(postData).eq('id', post.id)
} else {
await supabase.from('posts').insert(postData)
}
setSaving(false)
router.push('/admin/posts')
router.refresh()
}
return (
<form onSubmit={handleSubmit} className="space-y-6 max-w-2xl">
<div>
<label className="block font-medium mb-1">Title</label>
<input
value={title}
onChange={(e) => handleTitleChange(e.target.value)}
className="w-full px-4 py-2 border rounded"
required
/>
</div>
<div>
<label className="block font-medium mb-1">Slug</label>
<input
value={slug}
onChange={(e) => setSlug(e.target.value)}
className="w-full px-4 py-2 border rounded"
required
/>
</div>
<div>
<label className="block font-medium mb-1">Category</label>
<select
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
className="w-full px-4 py-2 border rounded"
>
<option value="">No category</option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
</div>
<div>
<label className="block font-medium mb-1">Content (Markdown)</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={15}
className="w-full px-4 py-2 border rounded font-mono"
required
/>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2">
<input
type="radio"
value="draft"
checked={status === 'draft'}
onChange={() => setStatus('draft')}
/>
Draft
</label>
<label className="flex items-center gap-2">
<input
type="radio"
value="published"
checked={status === 'published'}
onChange={() => setStatus('published')}
/>
Published
</label>
</div>
<button
type="submit"
disabled={saving}
className="px-6 py-2 bg-primary text-white rounded"
>
{saving ? 'Saving...' : post ? 'Update Post' : 'Create Post'}
</button>
</form>
)
}
Phase 4: Image Upload
Create Storage Bucket
Terminal
-- In Supabase SQL Editor
INSERT INTO storage.buckets (id, name, public)
VALUES ('blog-images', 'blog-images', true);
-- Allow authenticated users to upload
CREATE POLICY "Authenticated can upload images"
ON storage.objects FOR INSERT
TO authenticated
WITH CHECK (bucket_id = 'blog-images');
-- Anyone can view images
CREATE POLICY "Public can view images"
ON storage.objects FOR SELECT
USING (bucket_id = 'blog-images');
Image Upload Component
Terminal
// components/ImageUpload.tsx
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
interface ImageUploadProps {
onUpload: (url: string) => void
}
export function ImageUpload({ onUpload }: ImageUploadProps) {
const [uploading, setUploading] = useState(false)
const supabase = createClient()
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
const fileName = `${Date.now()}-${file.name}`
const { data, error } = await supabase.storage
.from('blog-images')
.upload(fileName, file)
if (error) {
alert('Upload failed')
setUploading(false)
return
}
const { data: urlData } = supabase.storage
.from('blog-images')
.getPublicUrl(fileName)
onUpload(urlData.publicUrl)
setUploading(false)
}
return (
<input
type="file"
accept="image/*"
onChange={handleUpload}
disabled={uploading}
/>
)
}
Phase 5: SEO & Metadata
Dynamic Metadata
Terminal
// app/blog/[slug]/page.tsx
import { Metadata } from 'next'
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const supabase = await createClient()
const { data: post } = await supabase
.from('posts')
.select('title, excerpt, featured_image')
.eq('slug', slug)
.single()
if (!post) return { title: 'Not Found' }
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: post.featured_image ? [post.featured_image] : []
}
}
}
Static Generation
Terminal
// Generate pages at build time
export async function generateStaticParams() {
const supabase = await createClient()
const { data: posts } = await supabase
.from('posts')
.select('slug')
.eq('status', 'published')
return posts?.map(post => ({ slug: post.slug })) || []
}
Phase 6: Deployment
Environment Variables
Terminal
# Vercel Environment Variables
NEXT_PUBLIC_SUPABASE_URL=xxx
NEXT_PUBLIC_SUPABASE_ANON_KEY=xxx
Deploy
Terminal
git add .
git commit -m "Complete blog platform"
git push
# Vercel auto-deploys
Verification Checklist
- Homepage shows published posts
- Individual post pages render markdown
- Category filtering works
- Admin login protected
- Can create new posts
- Can edit existing posts
- Can upload images
- Draft/publish workflow works
- SEO metadata correct
- Deployed and accessible
Extensions
Once working, try adding:
- Comments system
- Newsletter signup
- Related posts
- Reading time
- Social sharing
- RSS feed
- Search functionality
Summary
You built a complete blog platform with:
- Public-facing blog pages
- Markdown content rendering
- Admin dashboard for content management
- Image upload
- SEO optimization
- Production deployment
Next Steps
Build a data dashboard with charts and real-time updates.
Mark this lesson as complete to track your progress