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