Lesson 22:商品展示 — 分类搜索、分页与 SEO 优化
🧩 本节信息卡(学习前先看)
- 阶段定位:Phase 3(实战篇)
- 推荐时长:90~150 分钟(首次学习)
- 先修要求:完成 L17–21,已有使用 Prisma 的商品列表与详情页
- 学习产出:实现 URL 筛选与分页,为商品详情添加元数据和安全的 JSON-LD
✅ 本节完成标准(自检清单)
- [ ] 我可以独立复现文中的核心代码片段
- [ ] 我能解释“为什么这样实现”,而不只是“照着写”
- [ ] 我记录了至少 1 个踩坑点和修复方法
🧭 本节统一学习流程
- 学习目标:先明确本节要解决的业务问题与核心 API。
- 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
- 原理深挖:理解为什么这样设计,以及常见误区。
- 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
- 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。
建议节奏:阅读 20% + 编码 60% + 复盘 20%。
🎯 本节目标:实现商品的分类展示、关键词搜索、服务端分页,并通过 Metadata API 和 JSON-LD 结构化数据补充搜索引擎可读取的商品信息。
📦 本节产出:用户可以按分类筛选、搜索关键词、翻页浏览的商品展示页面,并带有动态 meta 标签和结构化数据。
一、在 URL 中保存筛选条件
筛选条件如果只存在 useState 中,刷新页面就会丢失。需要分享、刷新和前进后退恢复的条件,可以保存在 URL 中;CSR 项目同样能这样做。 本课把筛选条件反映在 URL 里 (/products?category=book&q=react&page=2):
优势:
- 用户可以分享搜索结果链接
- 浏览器后退/前进正常工作
- 可为有价值的分类页设计索引策略;搜索结果和大量筛选组合不应无节制地被索引
二、实战:分类 + 搜索 + 分页
2.1 商品列表页
// src/app/products/page.tsx
import { prisma } from '@/lib/prisma'
import Link from 'next/link'
import SearchBar from './SearchBar'
import Pagination from './Pagination'
import type { Prisma } from '@prisma/client'
const PAGE_SIZE = 6
export default async function ProductsPage({
searchParams
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const params = await searchParams
const single = (value: string | string[] | undefined) => Array.isArray(value) ? value[0] : value
const rawCategory = single(params.category)
const category = ['book', 'electronics', 'clothing'].includes(rawCategory ?? '') ? rawCategory : undefined
const query = (single(params.q) ?? '').trim().slice(0, 100)
const rawPage = single(params.page) ?? '1'
const candidate = /^\d+$/.test(rawPage) ? Number(rawPage) : 1
const requestedPage = Number.isSafeInteger(candidate) && candidate > 0 ? candidate : 1
const where: Prisma.ProductWhereInput = {
...(category && { category }),
...(query && {
OR: [{ name: { contains: query } }, { description: { contains: query } }],
}),
}
// 同一事务中计数、限制页码并查询,避免负数、NaN、超大 skip。
const { products, total, totalPages, page } = await prisma.$transaction(async tx => {
const total = await tx.product.count({ where })
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const page = Math.min(requestedPage, totalPages)
const products = await tx.product.findMany({
where, skip: (page - 1) * PAGE_SIZE, take: PAGE_SIZE,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
})
return { products, total, totalPages, page }
})
return (
<div className="max-w-7xl mx-auto px-4 py-12">
<h1 className="text-3xl font-bold mb-8">
全部商品
<span className="text-lg font-normal text-gray-400 ml-2">({total} 件)</span>
</h1>
<SearchBar key={JSON.stringify([query, category])} defaultQuery={query} defaultCategory={category} />
{products.length === 0 ? (
<div className="text-center py-20 text-gray-400">
<p className="text-5xl mb-4">🔍</p>
<p>没有找到匹配的商品</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mt-8">
{products.map(product => (
<Link key={product.id} href={`/products/${product.id}`}
className="group bg-white rounded-2xl border hover:shadow-lg transition-shadow overflow-hidden"
>
<div className="h-48 bg-gray-100 flex items-center justify-center text-5xl">📦</div>
<div className="p-5">
<span className="text-xs bg-indigo-100 text-indigo-700 px-2 py-0.5 rounded-full">
{product.category}
</span>
<h2 className="font-semibold text-lg mt-2 group-hover:text-indigo-600 transition-colors">
{product.name}
</h2>
<p className="mt-3 text-2xl font-bold text-indigo-600">¥{(product.price / 100).toFixed(2)}</p>
</div>
</Link>
))}
</div>
)}
{totalPages > 1 && (
<Pagination
currentPage={page}
totalPages={totalPages}
category={category}
query={query}
/>
)}
</div>
)
}searchParams 在 Next.js 15 中要等待 Promise,并可能包含同名参数数组。上面统一取第一个值,限制搜索长度,并让超出范围的页码显示最后一页。createdAt 相同时再按 id 排序,避免同值排序造成翻页不稳定。contains 的大小写行为依赖数据库;SQLite 和 PostgreSQL 的规则不同,切库后需重新验证。
2.2 搜索栏组件
// src/app/products/SearchBar.tsx
'use client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
const CATEGORIES = [
{ value: '', label: '全部' },
{ value: 'book', label: '📚 图书' },
{ value: 'electronics', label: '💻 电子' },
{ value: 'clothing', label: '👕 服饰' },
]
export default function SearchBar({ defaultQuery, defaultCategory }: {
defaultQuery?: string; defaultCategory?: string
}) {
const router = useRouter()
const [query, setQuery] = useState(defaultQuery || '')
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
const params = new URLSearchParams()
if (query.trim()) params.set('q', query.trim())
if (defaultCategory) params.set('category', defaultCategory)
router.push(`/products?${params.toString()}`)
}
const handleCategoryClick = (cat: string) => {
const params = new URLSearchParams()
if (cat) params.set('category', cat)
if (query.trim()) params.set('q', query.trim())
router.push(`/products?${params.toString()}`)
}
return (
<div>
<form onSubmit={handleSearch} className="flex gap-2 mb-4">
<input value={query} onChange={e => setQuery(e.target.value)}
aria-label="搜索商品" maxLength={100} placeholder="搜索商品名称或描述..."
className="flex-1 border rounded-xl px-4 py-2.5 focus:ring-2 focus:ring-indigo-500 outline-none" />
<button type="submit" className="bg-indigo-600 text-white px-6 rounded-xl hover:bg-indigo-700">
搜索
</button>
</form>
<div className="flex gap-2 flex-wrap">
{CATEGORIES.map(cat => (
<button key={cat.value} onClick={() => handleCategoryClick(cat.value)}
aria-pressed={(defaultCategory || '') === cat.value}
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
(defaultCategory || '') === cat.value
? 'bg-indigo-600 text-white'
: 'bg-white border text-gray-600 hover:bg-gray-100'
}`}>
{cat.label}
</button>
))}
</div>
</div>
)
}列表给 SearchBar 的 key 由 URL 筛选条件组成。通过浏览器后退、前进或链接改变条件时,输入框会按新 URL 重建,避免 useState 只读取首次 props 后一直保留旧文本。
2.3 分页组件
// src/app/products/Pagination.tsx
import Link from 'next/link'
interface PaginationProps {
currentPage: number
totalPages: number
category?: string
query?: string
}
export default function Pagination({ currentPage, totalPages, category, query }: PaginationProps) {
const buildUrl = (page: number) => {
const params = new URLSearchParams()
params.set('page', String(page))
if (category) params.set('category', category)
if (query) params.set('q', query)
return `/products?${params.toString()}`
}
// 生成页码数组(最多显示 5 个)
const getPageNumbers = () => {
const pages: number[] = []
const start = Math.max(1, currentPage - 2)
const end = Math.min(totalPages, currentPage + 2)
for (let i = start; i <= end; i++) pages.push(i)
return pages
}
return (
<nav aria-label="商品分页" className="flex items-center justify-center gap-1 mt-12">
{/* 首页 */}
{currentPage > 2 && (
<Link href={buildUrl(1)}
className="px-3 py-2 rounded-lg text-sm text-gray-500 hover:bg-gray-100">
首页
</Link>
)}
{/* 上一页 */}
{currentPage > 1 && (
<Link href={buildUrl(currentPage - 1)}
className="px-3 py-2 rounded-lg text-sm text-gray-500 hover:bg-gray-100">
← 上一页
</Link>
)}
{/* 页码 */}
{getPageNumbers().map(p => (
<Link key={p} href={buildUrl(p)} aria-current={p === currentPage ? 'page' : undefined}
className={`w-10 h-10 flex items-center justify-center rounded-lg text-sm font-medium transition-colors ${
p === currentPage
? 'bg-indigo-600 text-white'
: 'text-gray-500 hover:bg-gray-100'
}`}>
{p}
</Link>
))}
{/* 下一页 */}
{currentPage < totalPages && (
<Link href={buildUrl(currentPage + 1)}
className="px-3 py-2 rounded-lg text-sm text-gray-500 hover:bg-gray-100">
下一页 →
</Link>
)}
{/* 末页 */}
{currentPage < totalPages - 1 && (
<Link href={buildUrl(totalPages)}
className="px-3 py-2 rounded-lg text-sm text-gray-500 hover:bg-gray-100">
末页
</Link>
)}
</nav>
)
}TIP
next/link 预取: 生产环境中,进入视口的链接可触发预取。默认行为会根据静态/动态路由与 loading 边界决定预取范围,不保证点击后即时加载;prefetch={true} 会请求更完整的预取,可能增加服务器查询。这里保留默认值,见 Next.js 15 Link 文档。
三、SEO 与 Metadata API
先在 .env.local 设置 APP_URL=http://localhost:3000,部署时替换为真实 HTTPS 站点地址。L17 根布局的 metadata 中添加 metadataBase: new URL(process.env.APP_URL ?? 'http://localhost:3000'),为相对 OG 图片 URL 提供站点基址。
3.1 静态 Metadata
// 合并到 src/app/products/page.tsx,保留前面的列表组件。
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: '全部商品 — ShopNext',
description: '浏览 ShopNext 的全部商品,包含图书、电子产品、服饰等分类。',
}对关键词搜索页还可按业务需要返回 robots: { index: false, follow: true };若改为 generateMetadata,同一文件不要再同时导出静态 metadata。
3.2 动态 Metadata(商品详情页)
// 合并到 src/app/products/[id]/page.tsx,保留已有页面组件。
import type { Metadata } from 'next'
import { prisma } from '@/lib/prisma'
export async function generateMetadata({
params
}: {
params: Promise<{ id: string }>
}): Promise<Metadata> {
const { id } = await params
const product = await prisma.product.findUnique({ where: { id } })
if (!product) return { title: '商品不存在' }
return {
title: `${product.name} — ShopNext`,
description: product.description || `购买 ${product.name},价格 ¥${(product.price / 100).toFixed(2)}`,
openGraph: {
title: product.name,
description: product.description || undefined,
images: product.image ? [product.image] : [],
},
}
}3.3 JSON-LD 结构化数据
JSON-LD 能描述商品价格和库存等信息,让页面有机会符合富媒体搜索结果的条件,但不保证展示。字段必须与页面实际可见内容一致,不能编造评分。下面替换详情组件的实现;保留 3.2 的 generateMetadata 和它的导入,将这里新增的导入合并到文件顶部:
// src/app/products/[id]/page.tsx:新增导入;prisma 导入复用 3.2 的同一条。
import { notFound } from 'next/navigation'
import AddToCartButton from './AddToCartButton'
import FavoriteButton from './FavoriteButton'
export const dynamic = 'force-dynamic'
export default async function ProductDetail({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const product = await prisma.product.findUnique({ where: { id } })
if (!product) notFound()
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description ?? undefined,
...(product.image ? { image: new URL(product.image, process.env.APP_URL ?? 'http://localhost:3000').href } : {}),
offers: {
'@type': 'Offer',
price: (product.price / 100).toFixed(2),
priceCurrency: 'CNY',
availability: product.stock > 0
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
},
}
return (
<>
{/* 可以放在页面 body 中。转义 <,避免商品文本中的 </script> 打断标签。 */}
<script type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c') }} />
<div className="max-w-4xl mx-auto px-4 py-12">
<p>{product.category}</p>
<h1 className="text-3xl font-bold mt-2">{product.name}</h1>
<p className="mt-4">{product.description}</p>
<p className="text-2xl mt-4">¥{(product.price / 100).toFixed(2)}</p>
<p>库存:{product.stock}</p>
<div className="mt-6 flex gap-3">
<AddToCartButton productId={product.id} name={product.name} price={product.price} />
<FavoriteButton productId={product.id} />
</div>
</div>
</>
)
}JSON 字符串不是 HTML 转义器,直接插入未经处理的 JSON.stringify 结果会带来脚本注入风险。转义方式见 Next.js JSON-LD 指南;商品富结果条件见 Google Product 文档。
四、🧠 深度专题:SEO 核心要素
五、练习
- 为搜索栏添加
useDebounce(Lesson 12 学过的),实现实时搜索。 - 仅当已有真实评分数据,并在页面上显示评分与评价数量时,再为 JSON-LD 添加
aggregateRating;本教程尚无评价模型,不要填模拟好评冒充真实评分。 - 使用 Google Search Console 的 Rich Results Test 验证你的 JSON-LD 是否正确。
📌 本节小结
| 你做了什么 | 你学到了什么 |
|---|---|
| 实现了服务端分类、搜索和分页 | URL searchParams 驱动的查询 |
| 构建了完整的分页组件 | 首页/末页/页码的通用分页逻辑 |
| 配置了静态和动态 Metadata | Next.js Metadata API + Open Graph |
| 添加了 JSON-LD 结构化数据 | Google Rich Snippets |
| — | next/link prefetch 预取机制 |