Next.js has revolutionized React development by providing a comprehensive framework that handles everything from routing to server-side rendering. With the introduction of the App Router in Next.js 13 and its maturation in Next.js 14, developers now have access to powerful features like Server Components, Server Actions, and improved data fetching patterns. This guide will take you through building a complete full-stack application using the latest Next.js features.
Understanding the App Router Architecture
The App Router represents a paradigm shift from the traditional Pages Router. Built on React's latest features, it introduces concepts like Server Components, which run on the server and can directly access databases and APIs without exposing sensitive data to the client.
Key Concepts:
- Server Components: React components that render on the server
- Client Components: Traditional React components that run in the browser
- Server Actions: Functions that run on the server and can be called from client components
- Streaming: Progressive rendering of UI components
- Nested Layouts: Reusable UI that persists across routes
Project Structure and Setup
Let's start by creating a modern blog application with authentication, database integration, and real-time features.
# Create a new Next.js application
npx create-next-app@latest my-blog-app --typescript --tailwind --eslint --app
cd my-blog-app
# Install additional dependencies
npm install @prisma/client prisma @auth/prisma-adapter next-auth
npm install @types/bcryptjs bcryptjs
npm install zod react-hook-form @hookform/resolvers
Database Schema with Prisma
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
password String?
role Role @default(USER)
posts Post[]
comments Comment[]
accounts Account[]
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("users")
}
model Post {
id String @id @default(cuid())
title String
slug String @unique
content String
excerpt String?
published Boolean @default(false)
featured Boolean @default(false)
viewCount Int @default(0)
authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
comments Comment[]
tags Tag[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("posts")
}
model Comment {
id String @id @default(cuid())
content String
postId String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
parentId String?
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id])
replies Comment[] @relation("CommentReplies")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("comments")
}
model Tag {
id String @id @default(cuid())
name String @unique
posts Post[]
@@map("tags")
}
enum Role {
USER
ADMIN
}
Server Components and Data Fetching
Server Components allow us to fetch data directly on the server, improving performance and SEO:
// app/blog/page.tsx
import { Metadata } from 'next'
import { Suspense } from 'react'
import { getPosts } from '@/lib/posts'
import PostCard from '@/components/PostCard'
import PostCardSkeleton from '@/components/PostCardSkeleton'
export const metadata: Metadata = {
title: 'Blog Posts | My Blog',
description: 'Latest blog posts and articles',
}
interface SearchParams {
page?: string
tag?: string
search?: string
}
interface BlogPageProps {
searchParams: SearchParams
}
export default async function BlogPage({ searchParams }: BlogPageProps) {
const page = parseInt(searchParams.page || '1')
const tag = searchParams.tag
const search = searchParams.search
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-4">
Latest Blog Posts
</h1>
<p className="text-gray-600 dark:text-gray-400">
Discover insights, tutorials, and thoughts on web development
</p>
</div>
<Suspense fallback={<PostGridSkeleton />}>
<PostGrid page={page} tag={tag} search={search} />
</Suspense>
</div>
)
}
async function PostGrid({ page, tag, search }: SearchParams & { page: number }) {
const { posts, totalPages, hasNextPage } = await getPosts({
page,
limit: 12,
tag,
search,
published: true
})
if (posts.length === 0) {
return (
<div className="text-center py-12">
<h3 className="text-xl text-gray-600 dark:text-gray-400">
No posts found
</h3>
</div>
)
}
return (
<div className="space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
{totalPages > 1 && (
<Pagination
currentPage={page}
totalPages={totalPages}
hasNextPage={hasNextPage}
/>
)}
</div>
)
}
function PostGridSkeleton() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<PostCardSkeleton key={i} />
))}
</div>
)
}
Server Actions for Data Mutations
Server Actions provide a seamless way to handle form submissions and data mutations:
// app/actions/posts.ts
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { generateSlug } from '@/lib/utils'
const CreatePostSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
content: z.string().min(10, 'Content must be at least 10 characters'),
excerpt: z.string().max(500).optional(),
tags: z.array(z.string()).optional(),
published: z.boolean().default(false),
featured: z.boolean().default(false)
})
export type CreatePostState = {
errors?: {
title?: string[]
content?: string[]
excerpt?: string[]
general?: string[]
}
message?: string
}
export async function createPost(
prevState: CreatePostState,
formData: FormData
): Promise<CreatePostState> {
// Get current user session
const session = await auth()
if (!session?.user?.id) {
return {
errors: { general: ['You must be logged in to create a post'] }
}
}
// Validate form data
const validatedFields = CreatePostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
excerpt: formData.get('excerpt'),
tags: formData.get('tags')?.toString().split(',').filter(Boolean) || [],
published: formData.get('published') === 'on',
featured: formData.get('featured') === 'on'
})
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors
}
}
const { title, content, excerpt, tags, published, featured } = validatedFields.data
try {
// Generate unique slug
const baseSlug = generateSlug(title)
let slug = baseSlug
let counter = 1
while (await prisma.post.findUnique({ where: { slug } })) {
slug = `${baseSlug}-${counter}`
counter++
}
// Create post with tags
const post = await prisma.post.create({
data: {
title,
slug,
content,
excerpt,
published,
featured,
authorId: session.user.id,
tags: {
connectOrCreate: tags.map(tag => ({
where: { name: tag.toLowerCase().trim() },
create: { name: tag.toLowerCase().trim() }
}))
}
},
include: {
tags: true,
author: {
select: {
name: true,
email: true,
image: true
}
}
}
})
// Revalidate relevant pages
revalidatePath('/blog')
revalidatePath('/admin/posts')
revalidateTag('posts')
redirect(`/blog/${post.slug}`)
} catch (error) {
console.error('Failed to create post:', error)
return {
errors: { general: ['Failed to create post. Please try again.'] }
}
}
}
export async function updatePostViews(postId: string) {
try {
await prisma.post.update({
where: { id: postId },
data: {
viewCount: {
increment: 1
}
}
})
revalidateTag(`post-${postId}`)
} catch (error) {
console.error('Failed to update post views:', error)
}
}
export async function deletePost(postId: string): Promise<{ success: boolean; error?: string }> {
const session = await auth()
if (!session?.user?.id) {
return { success: false, error: 'Unauthorized' }
}
try {
const post = await prisma.post.findUnique({
where: { id: postId },
select: { authorId: true, slug: true }
})
if (!post) {
return { success: false, error: 'Post not found' }
}
if (post.authorId !== session.user.id && session.user.role !== 'ADMIN') {
return { success: false, error: 'Unauthorized' }
}
await prisma.post.delete({
where: { id: postId }
})
revalidatePath('/blog')
revalidatePath('/admin/posts')
revalidateTag('posts')
return { success: true }
} catch (error) {
console.error('Failed to delete post:', error)
return { success: false, error: 'Failed to delete post' }
}
}
Client Components for Interactivity
Client Components handle user interactions and maintain client-side state:
// components/PostEditor.tsx
'use client'
import { useFormState } from 'react-dom'
import { useRouter } from 'next/navigation'
import { useState, useTransition } from 'react'
import { createPost, CreatePostState } from '@/app/actions/posts'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { TagInput } from '@/components/TagInput'
import { MarkdownPreview } from '@/components/MarkdownPreview'
const initialState: CreatePostState = {
errors: {},
message: ''
}
interface PostEditorProps {
initialData?: {
title: string
content: string
excerpt?: string
tags: string[]
published: boolean
featured: boolean
}
}
export default function PostEditor({ initialData }: PostEditorProps) {
const [state, formAction] = useFormState(createPost, initialState)
const [isPending, startTransition] = useTransition()
const [showPreview, setShowPreview] = useState(false)
const [content, setContent] = useState(initialData?.content || '')
const router = useRouter()
const handleSubmit = (formData: FormData) => {
startTransition(() => {
formAction(formData)
})
}
return (
<div className="max-w-4xl mx-auto p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">
{initialData ? 'Edit Post' : 'Create New Post'}
</h1>
<div className="flex items-center space-x-4">
<Label htmlFor="preview-toggle">Preview</Label>
<Switch
id="preview-toggle"
checked={showPreview}
onCheckedChange={setShowPreview}
/>
</div>
</div>
{state.errors?.general && (
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded mb-4">
{state.errors.general.join(', ')}
</div>
)}
<form action={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-6">
<div>
<Label htmlFor="title">Title *</Label>
<Input
id="title"
name="title"
defaultValue={initialData?.title}
placeholder="Enter post title..."
className={state.errors?.title ? 'border-red-500' : ''}
/>
{state.errors?.title && (
<p className="text-red-500 text-sm mt-1">{state.errors.title[0]}</p>
)}
</div>
<div>
<Label htmlFor="excerpt">Excerpt</Label>
<Textarea
id="excerpt"
name="excerpt"
defaultValue={initialData?.excerpt}
placeholder="Brief description of your post..."
rows={3}
className={state.errors?.excerpt ? 'border-red-500' : ''}
/>
{state.errors?.excerpt && (
<p className="text-red-500 text-sm mt-1">{state.errors.excerpt[0]}</p>
)}
</div>
<div>
<Label>Tags</Label>
<TagInput
name="tags"
defaultValue={initialData?.tags || []}
placeholder="Add tags..."
/>
</div>
<div className="flex space-x-6">
<div className="flex items-center space-x-2">
<Switch
id="published"
name="published"
defaultChecked={initialData?.published}
/>
<Label htmlFor="published">Published</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
id="featured"
name="featured"
defaultChecked={initialData?.featured}
/>
<Label htmlFor="featured">Featured</Label>
</div>
</div>
</div>
<div className="space-y-6">
<div>
<Label htmlFor="content">Content *</Label>
{showPreview ? (
<div className="border rounded-lg p-4 min-h-[400px] bg-gray-50">
<MarkdownPreview content={content} />
</div>
) : (
<Textarea
id="content"
name="content"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Write your post content in Markdown..."
rows={20}
className={`font-mono ${state.errors?.content ? 'border-red-500' : ''}`}
/>
)}
{state.errors?.content && (
<p className="text-red-500 text-sm mt-1">{state.errors.content[0]}</p>
)}
</div>
</div>
</div>
<div className="flex justify-end space-x-4">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
>
Cancel
</Button>
<Button
type="submit"
disabled={isPending}
>
{isPending ? 'Saving...' : (initialData ? 'Update Post' : 'Create Post')}
</Button>
</div>
</form>
</div>
)
}
Authentication with NextAuth.js
// lib/auth.ts
import { NextAuthOptions } from 'next-auth'
import { PrismaAdapter } from '@auth/prisma-adapter'
import CredentialsProvider from 'next-auth/providers/credentials'
import GoogleProvider from 'next-auth/providers/google'
import { compare } from 'bcryptjs'
import { prisma } from './prisma'
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
session: {
strategy: 'jwt'
},
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!
}),
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 || !user.password) {
return null
}
const isPasswordValid = await compare(credentials.password, user.password)
if (!isPasswordValid) {
return null
}
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
image: user.image
}
}
})
],
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 as string
}
return session
}
},
pages: {
signIn: '/auth/signin',
signUp: '/auth/signup'
}
}
Advanced Features and Optimizations
1. Image Optimization and Upload
// components/ImageUpload.tsx
'use client'
import { useState, useCallback } from 'react'
import Image from 'next/image'
import { useDropzone } from 'react-dropzone'
import { uploadImage } from '@/app/actions/upload'
interface ImageUploadProps {
onImageUploaded: (url: string) => void
currentImage?: string
}
export default function ImageUpload({ onImageUploaded, currentImage }: ImageUploadProps) {
const [isUploading, setIsUploading] = useState(false)
const [preview, setPreview] = useState<string>(currentImage || '')
const onDrop = useCallback(async (acceptedFiles: File[]) => {
const file = acceptedFiles[0]
if (!file) return
setIsUploading(true)
try {
const formData = new FormData()
formData.append('file', file)
const result = await uploadImage(formData)
if (result.success && result.url) {
setPreview(result.url)
onImageUploaded(result.url)
}
} catch (error) {
console.error('Upload failed:', error)
} finally {
setIsUploading(false)
}
}, [onImageUploaded])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'image/*': ['.jpeg', '.jpg', '.png', '.webp']
},
maxSize: 5 * 1024 * 1024, // 5MB
multiple: false
})
return (
<div className="space-y-4">
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors
${isDragActive ? 'border-blue-400 bg-blue-50' : 'border-gray-300 hover:border-gray-400'}
${isUploading ? 'opacity-50 cursor-not-allowed' : ''}
`}
>
<input {...getInputProps()} disabled={isUploading} />
{preview ? (
<div className="space-y-4">
<div className="relative w-full h-48">
<Image
src={preview}
alt="Preview"
fill
className="object-cover rounded"
/>
</div>
<p className="text-sm text-gray-600">
Click or drag to replace image
</p>
</div>
) : (
<div className="space-y-2">
<div className="text-gray-400">
<svg className="mx-auto h-12 w-12" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<div>
<p className="text-lg">
{isDragActive ? 'Drop image here' : 'Drag & drop an image here'}
</p>
<p className="text-sm text-gray-500">
or click to select (max 5MB)
</p>
</div>
</div>
)}
{isUploading && (
<div className="absolute inset-0 flex items-center justify-center bg-white bg-opacity-75 rounded-lg">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-2 text-sm text-gray-600">Uploading...</p>
</div>
</div>
)}
</div>
</div>
)
}
2. Real-time Comments with Server Actions
// components/CommentSection.tsx
'use client'
import { useState, useTransition } from 'react'
import { useSession } from 'next-auth/react'
import { addComment, deleteComment } from '@/app/actions/comments'
import { Comment, User } from '@prisma/client'
type CommentWithAuthor = Comment & {
author: Pick<User, 'name' | 'image'>
replies: CommentWithAuthor[]
}
interface CommentSectionProps {
postId: string
initialComments: CommentWithAuthor[]
}
export default function CommentSection({ postId, initialComments }: CommentSectionProps) {
const { data: session } = useSession()
const [comments, setComments] = useState(initialComments)
const [newComment, setNewComment] = useState('')
const [replyTo, setReplyTo] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!newComment.trim() || !session?.user) return
startTransition(async () => {
const result = await addComment({
content: newComment,
postId,
parentId: replyTo
})
if (result.success && result.comment) {
// Optimistically update UI
if (replyTo) {
setComments(prev => prev.map(comment =>
comment.id === replyTo
? { ...comment, replies: [...comment.replies, result.comment!] }
: comment
))
} else {
setComments(prev => [result.comment!, ...prev])
}
setNewComment('')
setReplyTo(null)
}
})
}
return (
<div className="mt-12 space-y-6">
<h3 className="text-2xl font-semibold">Comments ({comments.length})</h3>
{session?.user ? (
<form onSubmit={handleSubmit} className="space-y-4">
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder={replyTo ? "Write a reply..." : "Write a comment..."}
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
rows={4}
/>
<div className="flex justify-between">
{replyTo && (
<button
type="button"
onClick={() => setReplyTo(null)}
className="text-gray-500 hover:text-gray-700"
>
Cancel Reply
</button>
)}
<button
type="submit"
disabled={isPending || !newComment.trim()}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{isPending ? 'Posting...' : (replyTo ? 'Reply' : 'Comment')}
</button>
</div>
</form>
) : (
<p className="text-gray-600">
<a href="/auth/signin" className="text-blue-600 hover:underline">
Sign in
</a>{' '}
to join the conversation
</p>
)}
<div className="space-y-6">
{comments.map((comment) => (
<CommentItem
key={comment.id}
comment={comment}
onReply={setReplyTo}
currentUserId={session?.user?.id}
/>
))}
</div>
</div>
)
}
3. SEO and Metadata Optimization
// app/blog/[slug]/page.tsx
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPostBySlug } from '@/lib/posts'
import { updatePostViews } from '@/app/actions/posts'
import PostContent from '@/components/PostContent'
import CommentSection from '@/components/CommentSection'
interface PostPageProps {
params: {
slug: string
}
}
export async function generateMetadata({ params }: PostPageProps): Promise<Metadata> {
const post = await getPostBySlug(params.slug)
if (!post) {
return {
title: 'Post Not Found'
}
}
return {
title: post.title,
description: post.excerpt || `Read ${post.title} by ${post.author.name}`,
authors: [{ name: post.author.name! }],
openGraph: {
title: post.title,
description: post.excerpt || `Read ${post.title} by ${post.author.name}`,
type: 'article',
publishedTime: post.createdAt.toISOString(),
modifiedTime: post.updatedAt.toISOString(),
authors: [post.author.name!],
tags: post.tags.map(tag => tag.name)
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt || `Read ${post.title} by ${post.author.name}`,
},
keywords: post.tags.map(tag => tag.name).join(', ')
}
}
export default async function PostPage({ params }: PostPageProps) {
const post = await getPostBySlug(params.slug, {
includeComments: true,
includeUnpublished: false
})
if (!post) {
notFound()
}
// Update view count (fire and forget)
updatePostViews(post.id)
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.excerpt,
author: {
'@type': 'Person',
name: post.author.name
},
datePublished: post.createdAt.toISOString(),
dateModified: post.updatedAt.toISOString(),
keywords: post.tags.map(tag => tag.name).join(', ')
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article className="max-w-4xl mx-auto px-4 py-8">
<PostContent post={post} />
<CommentSection
postId={post.id}
initialComments={post.comments}
/>
</article>
</>
)
}
Performance Optimizations and Best Practices
1. Streaming and Loading UI
typescript
// app/blog/loading.tsx
export default function Loading()
Top comments (0)