AiTechWorlds
AiTechWorlds
University learning companion
Web Development Bootcamp 2026 · Project: Full-Stack Blog Platform
A feature looks correct on a developer’s large monitor but fails on a slow connection and a small screen. Use this topic to inspect structure, state, loading/error behaviour, accessibility and the user journey—not just the happy path.
Use this course-level map to place “Project: Full-Stack Blog Platform” in the larger system. It complements—not replaces—a topic-specific worked diagram.
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.
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// 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// 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);
}// 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 });
}// 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();
}// 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>
);
}// 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>
);
}// package.json
{
"scripts": {
"build": "prisma generate && prisma migrate deploy && next build"
}
}DATABASE_URL, JWT_SECRETYour full stack blog is live.
Next project: E commerce store product catalog, shopping cart, and Stripe payments.
University learning companion
Web Development Bootcamp 2026 · Project: Full-Stack Blog Platform
Complete the steps without looking at the answer first; record evidence rather than only a final result.
These are primary or established learning references for this subject. Check the relevant chapter or specification for the exact topic and version.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises