Chapter 12: Complete Project Walkthrough
What is This Complete Project?
This chapter provides a comprehensive walkthrough of building a real-world Next.js application from scratch. We'll create a modern blog platform with user authentication, content management, and advanced features that demonstrate all the concepts covered in this tutorial.
Project Features:
- User Authentication: Login, registration, and profile management
- Blog System: Create, edit, and manage blog posts
- Content Management: Rich text editor and media uploads
- SEO Optimization: Meta tags, structured data, and sitemaps
- Performance: Image optimization, caching, and Core Web Vitals
- Responsive Design: Mobile-first approach with modern UI
- API Integration: RESTful APIs and database operations
Why Build a Complete Project?
Learning Benefits:
- Practical Application: Apply all learned concepts in a real project
- Best Practices: See how to structure and organize code
- Problem Solving: Handle real-world challenges and edge cases
- Portfolio Piece: Create a project to showcase your skills
- Confidence Building: Gain experience with production-ready code
How to Build the Complete Project
1. Project Setup and Configuration
Initialize the Project:
# Create new Next.js project
npx create-next-app@latest blog-platform --typescript --tailwind --eslint --app
# Navigate to project directory
cd blog-platform
# Install additional dependencies
npm install @prisma/client prisma next-auth @next-auth/prisma-adapter
npm install @headlessui/react @heroicons/react
npm install react-hook-form @hookform/resolvers zod
npm install @tiptap/react @tiptap/starter-kit @tiptap/extension-image
npm install date-fns clsx
npm install -D @types/node
Project Structure:
blog-platform/
├── app/ # App Router (Next.js 13+)
│ ├── (auth)/ # Route groups
│ │ ├── login/
│ │ └── register/
│ ├── (dashboard)/ # Protected routes
│ │ ├── dashboard/
│ │ ├── posts/
│ │ └── profile/
│ ├── api/ # API routes
│ │ ├── auth/
│ │ ├── posts/
│ │ └── upload/
│ ├── blog/ # Public blog routes
│ │ ├── [slug]/
│ │ └── page.tsx
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
├── components/ # Reusable components
│ ├── ui/ # Base UI components
│ ├── forms/ # Form components
│ ├── layout/ # Layout components
│ └── blog/ # Blog-specific components
├── lib/ # Utility functions
│ ├── auth.ts
│ ├── db.ts
│ ├── utils.ts
│ └── validations.ts
├── prisma/ # Database schema
│ ├── schema.prisma
│ └── migrations/
├── public/ # Static assets
├── types/ # TypeScript types
└── middleware.ts # Next.js middleware
2. Database Setup with Prisma
Prisma Schema:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
bio String?
role Role @default(USER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
posts Post[]
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
model Post {
id String @id @default(cuid())
title String
slug String @unique
content String @db.Text
excerpt String?
published Boolean @default(false)
featured Boolean @default(false)
featuredImage String?
tags String[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
publishedAt DateTime?
authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
@@index([slug])
@@index([published])
@@index([publishedAt])
}
enum Role {
USER
ADMIN
}
Database Configuration:
// lib/db.ts
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
3. Authentication Setup
NextAuth Configuration:
// lib/auth.ts
import { NextAuthOptions } from 'next-auth'
import { PrismaAdapter } from '@next-auth/prisma-adapter'
import CredentialsProvider from 'next-auth/providers/credentials'
import { prisma } from './db'
import bcrypt from 'bcryptjs'
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
CredentialsProvider({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' }
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null
}
const user = await prisma.user.findUnique({
where: {
email: credentials.email
}
})
if (!user) {
return null
}
const isPasswordValid = await bcrypt.compare(
credentials.password,
user.password
)
if (!isPasswordValid) {
return null
}
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
}
}
})
],
session: {
strategy: 'jwt'
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.role = user.role
}
return token
},
async session({ session, token }) {
if (token) {
session.user.id = token.sub
session.user.role = token.role
}
return session
}
},
pages: {
signIn: '/login',
signUp: '/register'
}
}
Middleware for Route Protection:
// middleware.ts
import { withAuth } from 'next-auth/middleware'
export default withAuth(
function middleware(req) {
// Additional middleware logic
},
{
callbacks: {
authorized: ({ token, req }) => {
// Protect dashboard routes
if (req.nextUrl.pathname.startsWith('/dashboard')) {
return !!token
}
return true
}
}
}
)
export const config = {
matcher: ['/dashboard/:path*']
}
4. API Routes Implementation
Posts API:
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { postSchema } from '@/lib/validations'
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '10')
const search = searchParams.get('search') || ''
const tag = searchParams.get('tag') || ''
const where = {
published: true,
...(search && {
OR: [
{ title: { contains: search, mode: 'insensitive' as const } },
{ content: { contains: search, mode: 'insensitive' as const } }
]
}),
...(tag && { tags: { has: tag } })
}
const [posts, total] = await Promise.all([
prisma.post.findMany({
where,
include: {
author: {
select: {
id: true,
name: true,
image: true
}
}
},
orderBy: { publishedAt: 'desc' },
skip: (page - 1) * limit,
take: limit
}),
prisma.post.count({ where })
])
return NextResponse.json({
posts,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit)
}
})
} catch (error) {
return NextResponse.json(
{ error: 'Failed to fetch posts' },
{ status: 500 }
)
}
}
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validatedData = postSchema.parse(body)
const post = await prisma.post.create({
data: {
...validatedData,
authorId: session.user.id,
slug: generateSlug(validatedData.title)
},
include: {
author: {
select: {
id: true,
name: true,
image: true
}
}
}
})
return NextResponse.json(post, { status: 201 })
} catch (error) {
return NextResponse.json(
{ error: 'Failed to create post' },
{ status: 500 }
)
}
}
Individual Post API:
// app/api/posts/[slug]/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
export async function GET(
request: NextRequest,
{ params }: { params: { slug: string } }
) {
try {
const post = await prisma.post.findUnique({
where: {
slug: params.slug,
published: true
},
include: {
author: {
select: {
id: true,
name: true,
image: true,
bio: true
}
}
}
})
if (!post) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 })
}
return NextResponse.json(post)
} catch (error) {
return NextResponse.json(
{ error: 'Failed to fetch post' },
{ status: 500 }
)
}
}
5. Frontend Components
Layout Component:
// components/layout/Header.tsx
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { useSession, signOut } from 'next-auth/react'
import { Menu, Transition } from '@headlessui/react'
import { Fragment } from 'react'
export default function Header() {
const { data: session } = useSession()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
return (
<header className="bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center">
<Link href="/" className="text-2xl font-bold text-gray-900">
BlogPlatform
</Link>
</div>
<nav className="hidden md:flex space-x-8">
<Link href="/blog" className="text-gray-700 hover:text-gray-900">
Blog
</Link>
<Link href="/about" className="text-gray-700 hover:text-gray-900">
About
</Link>
<Link href="/contact" className="text-gray-700 hover:text-gray-900">
Contact
</Link>
</nav>
<div className="flex items-center space-x-4">
{session ? (
<Menu as="div" className="relative">
<Menu.Button className="flex items-center space-x-2 text-gray-700 hover:text-gray-900">
<img
className="h-8 w-8 rounded-full"
src={session.user.image || '/default-avatar.png'}
alt={session.user.name || 'User'}
/>
<span>{session.user.name}</span>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50">
<Menu.Item>
{({ active }) => (
<Link
href="/dashboard"
className={`${
active ? 'bg-gray-100' : ''
} block px-4 py-2 text-sm text-gray-700`}
>
Dashboard
</Link>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<Link
href="/profile"
className={`${
active ? 'bg-gray-100' : ''
} block px-4 py-2 text-sm text-gray-700`}
>
Profile
</Link>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<button
onClick={() => signOut()}
className={`${
active ? 'bg-gray-100' : ''
} block w-full text-left px-4 py-2 text-sm text-gray-700`}
>
Sign out
</button>
)}
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
) : (
<div className="flex space-x-4">
<Link
href="/login"
className="text-gray-700 hover:text-gray-900"
>
Sign in
</Link>
<Link
href="/register"
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"
>
Sign up
</Link>
</div>
)}
</div>
</div>
</div>
</header>
)
}
Blog Post Component:
// components/blog/PostCard.tsx
import Link from 'next/link'
import Image from 'next/image'
import { format } from 'date-fns'
interface PostCardProps {
post: {
id: string
title: string
slug: string
excerpt: string
featuredImage?: string
publishedAt: string
author: {
name: string
image?: string
}
tags: string[]
}
}
export default function PostCard({ post }: PostCardProps) {
return (
<article className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
{post.featuredImage && (
<div className="relative h-48 w-full">
<Image
src={post.featuredImage}
alt={post.title}
fill
className="object-cover"
/>
</div>
)}
<div className="p-6">
<div className="flex items-center space-x-2 text-sm text-gray-500 mb-2">
<span>{format(new Date(post.publishedAt), 'MMM d, yyyy')}</span>
<span>•</span>
<span>By {post.author.name}</span>
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
<Link href={`/blog/${post.slug}`} className="hover:text-blue-600">
{post.title}
</Link>
</h2>
<p className="text-gray-600 mb-4 line-clamp-3">
{post.excerpt}
</p>
<div className="flex flex-wrap gap-2 mb-4">
{post.tags.map((tag) => (
<Link
key={tag}
href={`/blog?tag=${tag}`}
className="px-2 py-1 bg-gray-100 text-gray-700 text-xs rounded-full hover:bg-gray-200"
>
{tag}
</Link>
))}
</div>
<Link
href={`/blog/${post.slug}`}
className="text-blue-600 hover:text-blue-700 font-medium"
>
Read more →
</Link>
</div>
</article>
)
}
Rich Text Editor:
// components/forms/RichTextEditor.tsx
'use client'
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import { useState } from 'react'
interface RichTextEditorProps {
content: string
onChange: (content: string) => void
placeholder?: string
}
export default function RichTextEditor({
content,
onChange,
placeholder = 'Start writing...'
}: RichTextEditorProps) {
const [isUploading, setIsUploading] = useState(false)
const editor = useEditor({
extensions: [
StarterKit,
Image.configure({
HTMLAttributes: {
class: 'max-w-full h-auto rounded-lg',
},
}),
],
content,
onUpdate: ({ editor }) => {
onChange(editor.getHTML())
},
editorProps: {
attributes: {
class: 'prose prose-sm sm:prose lg:prose-lg xl:prose-2xl mx-auto focus:outline-none min-h-[200px] p-4',
},
},
})
const addImage = async () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
setIsUploading(true)
try {
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
const { url } = await response.json()
if (editor) {
editor.chain().focus().setImage({ src: url }).run()
}
} catch (error) {
console.error('Error uploading image:', error)
} finally {
setIsUploading(false)
}
}
input.click()
}
if (!editor) {
return null
}
return (
<div className="border border-gray-300 rounded-lg">
<div className="border-b border-gray-300 p-2 flex space-x-2">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={`px-3 py-1 rounded ${
editor.isActive('bold') ? 'bg-gray-200' : 'hover:bg-gray-100'
}`}
>
Bold
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={`px-3 py-1 rounded ${
editor.isActive('italic') ? 'bg-gray-200' : 'hover:bg-gray-100'
}`}
>
Italic
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={`px-3 py-1 rounded ${
editor.isActive('heading', { level: 1 }) ? 'bg-gray-200' : 'hover:bg-gray-100'
}`}
>
H1
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={`px-3 py-1 rounded ${
editor.isActive('heading', { level: 2 }) ? 'bg-gray-200' : 'hover:bg-gray-100'
}`}
>
H2
</button>
<button
onClick={addImage}
disabled={isUploading}
className="px-3 py-1 rounded hover:bg-gray-100 disabled:opacity-50"
>
{isUploading ? 'Uploading...' : 'Image'}
</button>
</div>
<EditorContent editor={editor} />
</div>
)
}
6. SEO Implementation
SEO Component:
// components/SEO.tsx
import Head from 'next/head'
interface SEOProps {
title: string
description: string
image?: string
url?: string
type?: string
publishedTime?: string
modifiedTime?: string
author?: string
tags?: string[]
structuredData?: object
}
export default function SEO({
title,
description,
image = '/og-image.jpg',
url,
type = 'website',
publishedTime,
modifiedTime,
author,
tags = [],
structuredData
}: SEOProps) {
const siteName = 'BlogPlatform'
const siteUrl = 'https://blogplatform.com'
const fullTitle = `${title} | ${siteName}`
const fullUrl = url ? `${siteUrl}${url}` : siteUrl
const fullImage = image.startsWith('http') ? image : `${siteUrl}${image}`
return (
<Head>
<title>{fullTitle}</title>
<meta name="description" content={description} />
<meta name="keywords" content={tags.join(', ')} />
{/* Open Graph */}
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:image" content={fullImage} />
<meta property="og:url" content={fullUrl} />
<meta property="og:type" content={type} />
<meta property="og:site_name" content={siteName} />
{/* Twitter Card */}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={fullImage} />
{/* Canonical URL */}
<link rel="canonical" href={fullUrl} />
{/* Article specific */}
{type === 'article' && (
<>
{publishedTime && <meta property="article:published_time" content={publishedTime} />}
{modifiedTime && <meta property="article:modified_time" content={modifiedTime} />}
{author && <meta property="article:author" content={author} />}
{tags.map(tag => (
<meta key={tag} property="article:tag" content={tag} />
))}
</>
)}
{/* Structured Data */}
{structuredData && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
)}
</Head>
)
}
Sitemap Generation:
// app/sitemap.xml/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/db'
export async function GET() {
const baseUrl = 'https://blogplatform.com'
const [posts, categories] = await Promise.all([
prisma.post.findMany({
where: { published: true },
select: {
slug: true,
updatedAt: true
}
}),
prisma.post.findMany({
where: { published: true },
select: { tags: true }
})
])
const uniqueTags = [...new Set(categories.flatMap(c => c.tags))]
const staticPages = [
{ url: '', priority: '1.0', changefreq: 'daily' },
{ url: '/about', priority: '0.8', changefreq: 'monthly' },
{ url: '/contact', priority: '0.7', changefreq: 'monthly' },
{ url: '/blog', priority: '0.9', changefreq: 'weekly' },
]
const dynamicPages = [
...posts.map(post => ({
url: `/blog/${post.slug}`,
lastmod: post.updatedAt.toISOString(),
priority: '0.6',
changefreq: 'weekly'
})),
...uniqueTags.map(tag => ({
url: `/blog?tag=${tag}`,
priority: '0.5',
changefreq: 'monthly'
}))
]
const allPages = [...staticPages, ...dynamicPages]
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${allPages
.map(page => {
return `
<url>
<loc>${baseUrl}${page.url}</loc>
${page.lastmod ? `<lastmod>${page.lastmod}</lastmod>` : ''}
<changefreq>${page.changefreq}</changefreq>
<priority>${page.priority}</priority>
</url>
`;
})
.join('')}
</urlset>
`;
return new NextResponse(sitemap, {
headers: {
'Content-Type': 'text/xml',
},
})
}
7. Deployment Configuration
Vercel Configuration:
// vercel.json
{
"framework": "nextjs",
"buildCommand": "npm run build",
"outputDirectory": ".next",
"installCommand": "npm install",
"devCommand": "npm run dev",
"functions": {
"app/api/**/*.ts": {
"runtime": "nodejs18.x"
}
},
"env": {
"DATABASE_URL": "@database-url",
"NEXTAUTH_SECRET": "@nextauth-secret",
"NEXTAUTH_URL": "@nextauth-url"
},
"headers": [
{
"source": "/api/(.*)",
"headers": [
{
"key": "Access-Control-Allow-Origin",
"value": "*"
},
{
"key": "Access-Control-Allow-Methods",
"value": "GET, POST, PUT, DELETE, OPTIONS"
}
]
}
]
}
Environment Variables:
# .env.local
DATABASE_URL="postgresql://username:password@localhost:5432/blogplatform"
NEXTAUTH_SECRET="your-secret-key"
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_GOOGLE_CLIENT_ID="your-google-client-id"
NEXTAUTH_GOOGLE_CLIENT_SECRET="your-google-client-secret"
Best Practices Implemented
1. Code Organization
- Modular Structure: Clear separation of concerns
- Type Safety: Full TypeScript implementation
- Reusable Components: DRY principle followed
- Custom Hooks: Logic separation and reusability
2. Performance Optimization
- Image Optimization: Next.js Image component
- Code Splitting: Dynamic imports and lazy loading
- Caching: Proper cache headers and strategies
- Bundle Optimization: Webpack configuration
3. Security
- Authentication: Secure session management
- Input Validation: Zod schema validation
- CSRF Protection: NextAuth built-in protection
- SQL Injection Prevention: Prisma ORM
4. SEO
- Meta Tags: Comprehensive meta tag implementation
- Structured Data: JSON-LD for rich snippets
- Sitemap: Dynamic sitemap generation
- Performance: Core Web Vitals optimization
Summary
This complete project demonstrates:
- Full-Stack Development: Frontend and backend integration
- Modern Architecture: App Router, TypeScript, and best practices
- User Experience: Responsive design and intuitive interface
- Performance: Optimized loading and Core Web Vitals
- SEO: Search engine optimization and discoverability
- Security: Authentication and data protection
- Scalability: Database design and API architecture
Key Takeaways:
- Plan your project structure before coding
- Use TypeScript for better development experience
- Implement proper error handling and validation
- Optimize for performance and SEO
- Follow security best practices
- Test thoroughly before deployment
Congratulations! You've completed the comprehensive Next.js tutorial from beginner to expert. You now have the knowledge and skills to build production-ready Next.js applications.
Author: syscook.dev
Last Updated: December 2024