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"
}
}
- Create a production database on Neon or Supabase
- Push code to GitHub
- Import repo on Vercel
- Set environment variables:
DATABASE_URL,JWT_SECRET - Deploy
Your full-stack blog is live.
Next project: E-commerce store — product catalog, shopping cart, and Stripe payments.
📱
Get Notes Free →Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises