Skip to main content

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