Follow AiTechWorlds on LinkedIn for professional AI content!Follow Now →
60 minLesson 39 of 40
Capstone Projects

Project: Full-Stack Blog Platform

Project: Full-Stack Blog with Next.js and PostgreSQL

This project brings together everything from the bootcamp: Next.js App Router, Tailwind CSS, PostgreSQL with Prisma, authentication, image uploads, and deployment. You'll build a complete multi-author blog platform where writers can publish posts and readers can discover content.

What You'll Build

  • Public blog — list of posts, individual post pages, category filtering, search
  • Author dashboard — create, edit, and delete your own posts
  • Authentication — email/password signup and login with JWT sessions
  • Image uploads — cover images stored in cloud storage
  • Admin panel — manage all users and posts
  • SEO — dynamic metadata, Open Graph images, sitemap

Project Setup

npx create-next-app@latest blog-platform --typescript --tailwind --app
cd blog-platform
npm install prisma @prisma/client bcryptjs jsonwebtoken
npm install -D @types/bcryptjs @types/jsonwebtoken
npm install zod sharp multer
npm install -D @types/multer
npx prisma init

Database Schema

// prisma/schema.prisma

model User {
    id           String   @id @default(cuid())
    email        String   @unique
    name         String
    password     String
    bio          String?
    avatarUrl    String?
    role         Role     @default(AUTHOR)
    createdAt    DateTime @default(now())
    
    posts        Post[]
    
    @@map("users")
}

enum Role {
    AUTHOR
    ADMIN
}

model Category {
    id    Int    @id @default(autoincrement())
    slug  String @unique
    name  String
    posts Post[]
    
    @@map("categories")
}

model Post {
    id          String     @id @default(cuid())
    slug        String     @unique
    title       String
    excerpt     String
    content     String     @db.Text
    coverImage  String?
    published   Boolean    @default(false)
    publishedAt DateTime?
    createdAt   DateTime   @default(now())
    updatedAt   DateTime   @updatedAt
    
    authorId    String
    author      User       @relation(fields: [authorId], references: [id], onDelete: Cascade)
    
    categoryId  Int?
    category    Category?  @relation(fields: [categoryId], references: [id])
    
    @@map("posts")
}
npx prisma migrate dev --name init

Authentication

// src/lib/auth.ts
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import { cookies } from "next/headers";
import { db } from "./db";

const JWT_SECRET = process.env.JWT_SECRET!;
const COOKIE_NAME = "session";

export async function hashPassword(password: string) {
    return bcrypt.hash(password, 12);
}

export async function verifyPassword(password: string, hash: string) {
    return bcrypt.compare(password, hash);
}

export function createToken(userId: string, role: string) {
    return jwt.sign({ userId, role }, JWT_SECRET, { expiresIn: "7d" });
}

export function verifyToken(token: string) {
    return jwt.verify(token, JWT_SECRET) as { userId: string; role: string };
}

export async function getSession() {
    const cookieStore = cookies();
    const token = cookieStore.get(COOKIE_NAME)?.value;
    if (!token) return null;
    
    try {
        const payload = verifyToken(token);
        const user = await db.user.findUnique({
            where: { id: payload.userId },
            select: { id: true, name: true, email: true, role: true, avatarUrl: true },
        });
        return user;
    } catch {
        return null;
    }
}

export function setSessionCookie(token: string) {
    cookies().set(COOKIE_NAME, token, {
        httpOnly: true,
        secure: process.env.NODE_ENV === "production",
        sameSite: "lax",
        maxAge: 60 * 60 * 24 * 7,   // 7 days
        path: "/",
    });
}

export function clearSessionCookie() {
    cookies().delete(COOKIE_NAME);
}

API Routes

Registration and Login

// src/app/api/auth/register/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { hashPassword, createToken, setSessionCookie } from "@/lib/auth";

const RegisterSchema = z.object({
    name: z.string().min(2).max(100),
    email: z.string().email(),
    password: z.string().min(8).max(100),
});

export async function POST(request: NextRequest) {
    const body = await request.json();
    const result = RegisterSchema.safeParse(body);
    
    if (!result.success) {
        return NextResponse.json({ error: "Invalid input" }, { status: 400 });
    }
    
    const { name, email, password } = result.data;
    
    const existing = await db.user.findUnique({ where: { email } });
    if (existing) {
        return NextResponse.json({ error: "Email already registered" }, { status: 409 });
    }
    
    const user = await db.user.create({
        data: { name, email, password: await hashPassword(password) },
    });
    
    const token = createToken(user.id, user.role);
    setSessionCookie(token);
    
    return NextResponse.json({ id: user.id, name: user.name, email: user.email }, { status: 201 });
}
// src/app/api/auth/login/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { verifyPassword, createToken, setSessionCookie } from "@/lib/auth";

export async function POST(request: NextRequest) {
    const { email, password } = await request.json();
    
    const user = await db.user.findUnique({ where: { email } });
    
    if (!user || !(await verifyPassword(password, user.password))) {
        return NextResponse.json({ error: "Invalid email or password" }, { status: 401 });
    }
    
    const token = createToken(user.id, user.role);
    setSessionCookie(token);
    
    return NextResponse.json({ id: user.id, name: user.name, email: user.email });
}

Posts API

// src/app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getSession } from "@/lib/auth";
import { db } from "@/lib/db";
import { z } from "zod";

// GET /api/posts?page=1&category=react&search=hooks
export async function GET(request: NextRequest) {
    const url = new URL(request.url);
    const page = parseInt(url.searchParams.get("page") ?? "1");
    const category = url.searchParams.get("category");
    const search = url.searchParams.get("search");
    const limit = 12;
    
    const where: any = { published: true };
    if (category) where.category = { slug: category };
    if (search) where.OR = [
        { title: { contains: search, mode: "insensitive" } },
        { excerpt: { contains: search, mode: "insensitive" } },
    ];
    
    const [posts, total] = await Promise.all([
        db.post.findMany({
            where,
            skip: (page - 1) * limit,
            take: limit,
            orderBy: { publishedAt: "desc" },
            include: {
                author: { select: { name: true, avatarUrl: true } },
                category: { select: { name: true, slug: true } },
            },
        }),
        db.post.count({ where }),
    ]);
    
    return NextResponse.json({ posts, total, pages: Math.ceil(total / limit) });
}

// POST /api/posts — create a post (authenticated)
export async function POST(request: NextRequest) {
    const user = await getSession();
    if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    
    const body = await request.json();
    const post = await db.post.create({
        data: {
            ...body,
            authorId: user.id,
            slug: generateSlug(body.title),
        },
    });
    
    return NextResponse.json(post, { status: 201 });
}

function generateSlug(title: string) {
    return title
        .toLowerCase()
        .replace(/[^a-z0-9\s-]/g, "")
        .replace(/\s+/g, "-")
        .replace(/-+/g, "-")
        .trim();
}

Public Pages

// src/app/blog/page.tsx — list of published posts
import { db } from "@/lib/db";
import Link from "next/link";

export const metadata = {
    title: "Blog — AiTechWorlds",
    description: "Articles on web development, React, Python, and AI.",
};

export default async function BlogPage() {
    const posts = await db.post.findMany({
        where: { published: true },
        orderBy: { publishedAt: "desc" },
        take: 20,
        include: {
            author: { select: { name: true } },
            category: true,
        },
    });
    
    return (
        <main className="max-w-5xl mx-auto px-4 py-12">
            <h1 className="text-4xl font-bold text-gray-900 mb-8">Blog</h1>
            <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
                {posts.map(post => (
                    <article key={post.id} className="bg-white rounded-xl border border-gray-200 overflow-hidden hover:shadow-md transition-shadow">
                        {post.coverImage && (
                            <img src={post.coverImage} alt={post.title} className="w-full h-44 object-cover"/>
                        )}
                        <div className="p-5">
                            {post.category && (
                                <span className="text-xs font-semibold text-blue-600 uppercase tracking-wide">
                                    {post.category.name}
                                </span>
                            )}
                            <h2 className="mt-1 text-lg font-semibold text-gray-900 leading-snug">
                                <Link href={`/blog/${post.slug}`} className="hover:text-blue-600 transition-colors">
                                    {post.title}
                                </Link>
                            </h2>
                            <p className="mt-2 text-gray-500 text-sm line-clamp-2">{post.excerpt}</p>
                            <div className="mt-3 flex items-center gap-2 text-xs text-gray-400">
                                <span>{post.author.name}</span>
                                <span>·</span>
                                <time>{new Date(post.publishedAt!).toLocaleDateString()}</time>
                            </div>
                        </div>
                    </article>
                ))}
            </div>
        </main>
    );
}
// src/app/blog/[slug]/page.tsx — individual post
import { db } from "@/lib/db";
import { notFound } from "next/navigation";

export async function generateStaticParams() {
    const posts = await db.post.findMany({
        where: { published: true },
        select: { slug: true },
    });
    return posts.map(p => ({ slug: p.slug }));
}

export async function generateMetadata({ params }: { params: { slug: string } }) {
    const post = await db.post.findUnique({
        where: { slug: params.slug },
        select: { title: true, excerpt: true, coverImage: true },
    });
    
    return {
        title: post?.title,
        description: post?.excerpt,
        openGraph: { images: post?.coverImage ? [post.coverImage] : [] },
    };
}

export default async function BlogPostPage({ params }: { params: { slug: string } }) {
    const post = await db.post.findUnique({
        where: { slug: params.slug, published: true },
        include: { author: true, category: true },
    });
    
    if (!post) notFound();
    
    return (
        <article className="max-w-3xl mx-auto px-4 py-12">
            {post.category && (
                <p className="text-blue-600 font-semibold text-sm uppercase tracking-wide mb-3">
                    {post.category.name}
                </p>
            )}
            <h1 className="text-4xl font-bold text-gray-900 leading-tight">{post.title}</h1>
            <div className="flex items-center gap-3 mt-4 text-gray-500 text-sm">
                <span>By {post.author.name}</span>
                <span>·</span>
                <time>{new Date(post.publishedAt!).toLocaleDateString("en-US", { dateStyle: "long" })}</time>
            </div>
            {post.coverImage && (
                <img src={post.coverImage} alt={post.title} className="w-full rounded-xl mt-6 mb-8"/>
            )}
            <div 
                className="prose prose-lg max-w-none"
                dangerouslySetInnerHTML={{ __html: post.content }}
            />
        </article>
    );
}

Dashboard (Protected)

// src/app/dashboard/page.tsx
import { getSession } from "@/lib/auth";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import Link from "next/link";

export default async function DashboardPage() {
    const user = await getSession();
    if (!user) redirect("/login");
    
    const posts = await db.post.findMany({
        where: { authorId: user.id },
        orderBy: { updatedAt: "desc" },
    });
    
    return (
        <div className="max-w-5xl mx-auto px-4 py-12">
            <div className="flex items-center justify-between mb-8">
                <h1 className="text-3xl font-bold">My Posts</h1>
                <Link href="/dashboard/new" className="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-lg transition-colors">
                    New Post
                </Link>
            </div>
            
            <div className="divide-y border rounded-xl overflow-hidden">
                {posts.map(post => (
                    <div key={post.id} className="flex items-center justify-between p-4 bg-white hover:bg-gray-50">
                        <div>
                            <p className="font-medium text-gray-900">{post.title}</p>
                            <p className="text-sm text-gray-500 mt-0.5">
                                {post.published ? "Published" : "Draft"} · {new Date(post.updatedAt).toLocaleDateString()}
                            </p>
                        </div>
                        <div className="flex gap-2">
                            <Link href={`/dashboard/edit/${post.id}`} className="text-sm text-blue-600 hover:underline">Edit</Link>
                        </div>
                    </div>
                ))}
                {posts.length === 0 && (
                    <div className="text-center py-12 text-gray-500">
                        No posts yet. <Link href="/dashboard/new" className="text-blue-600 hover:underline">Write your first one.</Link>
                    </div>
                )}
            </div>
        </div>
    );
}

Deployment

// package.json
{
    "scripts": {
        "build": "prisma generate && prisma migrate deploy && next build"
    }
}
  1. Create a production database on Neon or Supabase
  2. Push code to GitHub
  3. Import repo on Vercel
  4. Set environment variables: DATABASE_URL, JWT_SECRET
  5. Deploy

Your full-stack blog is live.

Next project: E-commerce store — product catalog, shopping cart, and Stripe payments.

📱

Get this course's notes on Telegram!

Free cheat sheets, summaries & practice exercises

Get Notes Free →
!