- 发布于
Next.js全栈开发实战:App Router与Server Components深度应用
- 作者

- 姓名
- 全能波
- GitHub
- @weicracker
Next.js全栈开发实战:App Router与Server Components深度应用
Next.js 13+引入的App Router和Server Components为全栈开发带来了革命性的变化。本文将深入探讨这些新特性的实战应用。
App Router基础
文件系统路由
// app/layout.tsx - 根布局
import { Inter } from 'next/font/google'
import { Metadata } from 'next'
import './globals.css'
import { Providers } from './providers'
import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: {
default: 'My Next.js App',
template: '%s | My Next.js App'
},
description: 'A modern full-stack application built with Next.js',
keywords: ['Next.js', 'React', 'TypeScript', 'Full-stack'],
authors: [{ name: 'Your Name' }],
creator: 'Your Name',
openGraph: {
type: 'website',
locale: 'en_US',
url: 'https://myapp.com',
title: 'My Next.js App',
description: 'A modern full-stack application',
siteName: 'My Next.js App',
images: [
{
url: '/og-image.jpg',
width: 1200,
height: 630,
alt: 'My Next.js App'
}
]
},
twitter: {
card: 'summary_large_image',
title: 'My Next.js App',
description: 'A modern full-stack application',
creator: '@yourusername',
images: ['/og-image.jpg']
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1
}
}
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<Providers>
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">
{children}
</main>
<Footer />
</div>
</Providers>
</body>
</html>
)
}
// app/page.tsx - 首页
import { Suspense } from 'react'
import { Hero } from '@/components/Hero'
import { FeaturedPosts } from '@/components/FeaturedPosts'
import { Newsletter } from '@/components/Newsletter'
import { LoadingSpinner } from '@/components/LoadingSpinner'
export default function HomePage() {
return (
<div className="space-y-12">
<Hero />
<section className="container mx-auto px-4">
<h2 className="text-3xl font-bold text-center mb-8">
Featured Posts
</h2>
<Suspense fallback={<LoadingSpinner />}>
<FeaturedPosts />
</Suspense>
</section>
<Newsletter />
</div>
)
}
// app/blog/page.tsx - 博客列表页
import { Suspense } from 'react'
import { Metadata } from 'next'
import { BlogList } from '@/components/BlogList'
import { BlogFilters } from '@/components/BlogFilters'
import { Pagination } from '@/components/Pagination'
import { LoadingSpinner } from '@/components/LoadingSpinner'
export const metadata: Metadata = {
title: 'Blog',
description: 'Read our latest blog posts about web development and technology'
}
interface BlogPageProps {
searchParams: {
page?: string
category?: string
search?: string
sort?: string
}
}
export default function BlogPage({ searchParams }: BlogPageProps) {
const page = Number(searchParams.page) || 1
const category = searchParams.category || ''
const search = searchParams.search || ''
const sort = searchParams.sort || 'newest'
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-4xl font-bold mb-4">Blog</h1>
<p className="text-gray-600 text-lg">
Discover insights, tutorials, and thoughts on web development
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
<aside className="lg:col-span-1">
<BlogFilters />
</aside>
<main className="lg:col-span-3">
<Suspense
key={`${page}-${category}-${search}-${sort}`}
fallback={<LoadingSpinner />}
>
<BlogList
page={page}
category={category}
search={search}
sort={sort}
/>
</Suspense>
<div className="mt-8">
<Suspense fallback={<div>Loading pagination...</div>}>
<Pagination
currentPage={page}
category={category}
search={search}
sort={sort}
/>
</Suspense>
</div>
</main>
</div>
</div>
)
}
// app/blog/[slug]/page.tsx - 博客详情页
import { Suspense } from 'react'
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { BlogPost } from '@/components/BlogPost'
import { RelatedPosts } from '@/components/RelatedPosts'
import { Comments } from '@/components/Comments'
import { ShareButtons } from '@/components/ShareButtons'
import { getBlogPost, getBlogPosts } from '@/lib/blog'
interface BlogPostPageProps {
params: {
slug: string
}
}
// 生成静态参数
export async function generateStaticParams() {
const posts = await getBlogPosts()
return posts.map((post) => ({
slug: post.slug,
}))
}
// 生成元数据
export async function generateMetadata({ params }: BlogPostPageProps): Promise<Metadata> {
const post = await getBlogPost(params.slug)
if (!post) {
return {
title: 'Post Not Found'
}
}
return {
title: post.title,
description: post.excerpt,
authors: [{ name: post.author.name }],
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
authors: [post.author.name],
images: [
{
url: post.coverImage,
width: 1200,
height: 630,
alt: post.title
}
]
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.coverImage]
}
}
}
export default async function BlogPostPage({ params }: BlogPostPageProps) {
const post = await getBlogPost(params.slug)
if (!post) {
notFound()
}
return (
<article className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<BlogPost post={post} />
<div className="mt-8 pt-8 border-t">
<ShareButtons
url={`/blog/${post.slug}`}
title={post.title}
/>
</div>
<div className="mt-12">
<Suspense fallback={<div>Loading related posts...</div>}>
<RelatedPosts
currentPostId={post.id}
category={post.category}
/>
</Suspense>
</div>
<div className="mt-12">
<Suspense fallback={<div>Loading comments...</div>}>
<Comments postId={post.id} />
</Suspense>
</div>
</div>
</article>
)
}
// app/blog/[slug]/not-found.tsx - 404页面
import Link from 'next/link'
export default function NotFound() {
return (
<div className="container mx-auto px-4 py-16 text-center">
<h1 className="text-4xl font-bold mb-4">Post Not Found</h1>
<p className="text-gray-600 mb-8">
The blog post you're looking for doesn't exist.
</p>
<Link
href="/blog"
className="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors"
>
Back to Blog
</Link>
</div>
)
}
路由组和布局
// app/(dashboard)/layout.tsx - 仪表板布局
import { Sidebar } from '@/components/dashboard/Sidebar'
import { DashboardHeader } from '@/components/dashboard/Header'
import { AuthGuard } from '@/components/AuthGuard'
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<AuthGuard>
<div className="flex h-screen bg-gray-100">
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">
<DashboardHeader />
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 p-6">
{children}
</main>
</div>
</div>
</AuthGuard>
)
}
// app/(dashboard)/dashboard/page.tsx - 仪表板首页
import { Suspense } from 'react'
import { StatsCards } from '@/components/dashboard/StatsCards'
import { RecentActivity } from '@/components/dashboard/RecentActivity'
import { Charts } from '@/components/dashboard/Charts'
import { LoadingCard } from '@/components/dashboard/LoadingCard'
export default function DashboardPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-gray-900">Dashboard</h1>
<p className="mt-1 text-sm text-gray-600">
Welcome back! Here's what's happening with your account.
</p>
</div>
<Suspense fallback={<LoadingCard />}>
<StatsCards />
</Suspense>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Suspense fallback={<LoadingCard />}>
<Charts />
</Suspense>
<Suspense fallback={<LoadingCard />}>
<RecentActivity />
</Suspense>
</div>
</div>
)
}
// app/(dashboard)/dashboard/posts/page.tsx - 文章管理
import { Suspense } from 'react'
import Link from 'next/link'
import { PostsTable } from '@/components/dashboard/PostsTable'
import { PostsFilters } from '@/components/dashboard/PostsFilters'
import { Button } from '@/components/ui/Button'
import { PlusIcon } from '@heroicons/react/24/outline'
interface PostsPageProps {
searchParams: {
page?: string
status?: string
search?: string
}
}
export default function PostsPage({ searchParams }: PostsPageProps) {
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-semibold text-gray-900">Posts</h1>
<p className="mt-1 text-sm text-gray-600">
Manage your blog posts and articles
</p>
</div>
<Link href="/dashboard/posts/new">
<Button className="flex items-center gap-2">
<PlusIcon className="h-4 w-4" />
New Post
</Button>
</Link>
</div>
<PostsFilters />
<Suspense fallback={<div>Loading posts...</div>}>
<PostsTable searchParams={searchParams} />
</Suspense>
</div>
)
}
// app/(dashboard)/dashboard/posts/[id]/edit/page.tsx - 编辑文章
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { PostEditor } from '@/components/dashboard/PostEditor'
import { getPost } from '@/lib/posts'
interface EditPostPageProps {
params: {
id: string
}
}
export default async function EditPostPage({ params }: EditPostPageProps) {
const post = await getPost(params.id)
if (!post) {
notFound()
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-gray-900">Edit Post</h1>
<p className="mt-1 text-sm text-gray-600">
Make changes to your blog post
</p>
</div>
<Suspense fallback={<div>Loading editor...</div>}>
<PostEditor post={post} />
</Suspense>
</div>
)
}
Server Components与数据获取
Server Components实现
// components/FeaturedPosts.tsx - Server Component
import { getBlogPosts } from '@/lib/blog'
import { PostCard } from './PostCard'
export async function FeaturedPosts() {
// 在服务器端获取数据
const posts = await getBlogPosts({
featured: true,
limit: 6
})
if (posts.length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-600">No featured posts available.</p>
</div>
)
}
return (
<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>
)
}
// components/BlogList.tsx - Server Component with search params
import { getBlogPosts, getBlogPostsCount } from '@/lib/blog'
import { PostCard } from './PostCard'
import { EmptyState } from './EmptyState'
interface BlogListProps {
page: number
category: string
search: string
sort: string
}
export async function BlogList({ page, category, search, sort }: BlogListProps) {
const limit = 12
const offset = (page - 1) * limit
// 并行获取数据
const [posts, totalCount] = await Promise.all([
getBlogPosts({
limit,
offset,
category,
search,
sort,
published: true
}),
getBlogPostsCount({
category,
search,
published: true
})
])
if (posts.length === 0) {
return (
<EmptyState
title="No posts found"
description="Try adjusting your search criteria or browse all posts."
/>
)
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<p className="text-sm text-gray-600">
Showing {offset + 1}-{Math.min(offset + limit, totalCount)} of {totalCount} posts
</p>
</div>
<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>
</div>
)
}
// components/dashboard/StatsCards.tsx - Server Component with data fetching
import { getStats } from '@/lib/dashboard'
import { StatsCard } from './StatsCard'
import {
UserGroupIcon,
DocumentTextIcon,
EyeIcon,
HeartIcon
} from '@heroicons/react/24/outline'
export async function StatsCards() {
const stats = await getStats()
const cards = [
{
title: 'Total Users',
value: stats.totalUsers,
change: stats.userGrowth,
icon: UserGroupIcon,
color: 'blue'
},
{
title: 'Total Posts',
value: stats.totalPosts,
change: stats.postGrowth,
icon: DocumentTextIcon,
color: 'green'
},
{
title: 'Page Views',
value: stats.totalViews,
change: stats.viewGrowth,
icon: EyeIcon,
color: 'purple'
},
{
title: 'Total Likes',
value: stats.totalLikes,
change: stats.likeGrowth,
icon: HeartIcon,
color: 'pink'
}
]
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{cards.map((card) => (
<StatsCard key={card.title} {...card} />
))}
</div>
)
}
// lib/blog.ts - 数据获取函数
import { cache } from 'react'
import { prisma } from './prisma'
// 使用React cache缓存函数结果
export const getBlogPosts = cache(async (options: {
limit?: number
offset?: number
category?: string
search?: string
sort?: string
featured?: boolean
published?: boolean
} = {}) => {
const {
limit = 10,
offset = 0,
category,
search,
sort = 'newest',
featured,
published
} = options
const where: any = {}
if (published !== undefined) {
where.published = published
}
if (featured !== undefined) {
where.featured = featured
}
if (category) {
where.category = {
slug: category
}
}
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ excerpt: { contains: search, mode: 'insensitive' } },
{ content: { contains: search, mode: 'insensitive' } }
]
}
const orderBy: any = {}
switch (sort) {
case 'oldest':
orderBy.publishedAt = 'asc'
break
case 'popular':
orderBy.views = 'desc'
break
case 'title':
orderBy.title = 'asc'
break
default:
orderBy.publishedAt = 'desc'
}
const posts = await prisma.post.findMany({
where,
orderBy,
take: limit,
skip: offset,
include: {
author: {
select: {
id: true,
name: true,
email: true,
avatar: true
}
},
category: {
select: {
id: true,
name: true,
slug: true
}
},
tags: {
select: {
id: true,
name: true,
slug: true
}
},
_count: {
select: {
comments: true,
likes: true
}
}
}
})
return posts
})
export const getBlogPost = cache(async (slug: string) => {
const post = await prisma.post.findUnique({
where: { slug },
include: {
author: {
select: {
id: true,
name: true,
email: true,
avatar: true,
bio: true
}
},
category: {
select: {
id: true,
name: true,
slug: true
}
},
tags: {
select: {
id: true,
name: true,
slug: true
}
},
comments: {
where: { approved: true },
include: {
author: {
select: {
id: true,
name: true,
avatar: true
}
}
},
orderBy: { createdAt: 'desc' }
},
_count: {
select: {
comments: true,
likes: true
}
}
}
})
if (post) {
// 增加浏览量
await prisma.post.update({
where: { id: post.id },
data: { views: { increment: 1 } }
})
}
return post
})
export const getBlogPostsCount = cache(async (options: {
category?: string
search?: string
published?: boolean
} = {}) => {
const { category, search, published } = options
const where: any = {}
if (published !== undefined) {
where.published = published
}
if (category) {
where.category = {
slug: category
}
}
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ excerpt: { contains: search, mode: 'insensitive' } },
{ content: { contains: search, mode: 'insensitive' } }
]
}
return prisma.post.count({ where })
})
总结
Next.js全栈开发的核心要点:
- App Router:文件系统路由、布局嵌套、路由组织
- Server Components:服务端渲染、数据获取、性能优化
- 数据流:缓存策略、重新验证、状态管理
- API设计:RESTful API、Server Actions、错误处理
- 最佳实践:TypeScript集成、SEO优化、性能监控
Next.js 13+的新特性为全栈开发提供了更强大的能力,通过合理使用这些特性,可以构建出高性能、可扩展的现代Web应用。