这篇不讲 Next.js 的 API,讲怎么用它做一个真的能被搜到的内容站。区别在于:能跑起来只是第一步,能让搜索引擎收录、让分享卡片好看、让页面秒开,才是内容站的价值所在。
一、先明确目标:内容站的四个硬指标
| 指标 | 为什么重要 | 怎么达成 |
|---|---|---|
| 收录 | 不收录等于不存在 | sitemap + robots + 内部链接 |
| 首屏快 | 3 秒打不开就走人 | SSG/ISR + 图片优化 + 字体自托管 |
| 分享好看 | 决定点击率 | OG 标签 + 分享卡片图 |
| 可结构化 | 可能出富摘要 | Article / BreadcrumbList 结构化数据 |
二、项目骨架与目录约定
npx create-next-app@latest content-site --typescript --tailwind --app
cd content-site
1. 目录结构(App Router)
app/
├── layout.tsx # 根布局
├── page.tsx # 首页
├── globals.css
├── blog/
│ ├── page.tsx # 文章列表
│ └── [slug]/
│ └── page.tsx # 文章详情(动态路由)
├── sitemap.ts # 自动生成 sitemap
├── robots.ts # 自动生成 robots.txt
└── opengraph-image.tsx # 动态生成分享图三、内容从哪来:先不用 CMS
做内容站最容易被「先选个 CMS」拖住。起步阶段用本地 Markdown 最快:
// lib/posts.ts —— 读本地 md 文件
import fs from 'node:fs'
import path from 'node:path'
import matter from 'gray-matter'
const DIR = path.join(process.cwd(), 'content')
export function getAllPosts() {
const files = fs.readdirSync(DIR).filter(f => f.endsWith('.md'))
return files
.map(f => {
const raw = fs.readFileSync(path.join(DIR, f), 'utf8')
const { data, content } = matter(raw)
return {
slug: f.replace(/\.md$/, ''),
title: data.title as string,
date: data.date as string,
excerpt: data.excerpt as string,
tags: (data.tags ?? []) as string[],
content,
}
})
.sort((a, b) => (a.date < b.date ? 1 : -1)) // 新的在前
}四、文章页:静态生成 + 增量再生
// app/blog/[slug]/page.tsx
import { getAllPosts, getPost } from '@/lib/posts'
import { notFound } from 'next/navigation'
// 1. 告诉 Next 有哪些 slug 需要预渲染
export async function generateStaticParams() {
return getAllPosts().map(p => ({ slug: p.slug }))
}
// 2. 每个文章页 60 秒后可后台再生(ISR)
export const revalidate = 60
export default async function PostPage({
params,
}: { params: Promise<{ slug: string }> }) {
const { slug } = await params // 15+ 起 params 是 Promise
const post = getPost(slug)
if (!post) notFound()
return (
<article>
<h1>{post.title}</h1>
<time dateTime={post.date}>{post.date}</time>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
)
}generateStaticParams是收录的关键:它让所有文章在构建时就生成静态页,搜索引擎一抓就是完整内容revalidate = 60:内容更新后最多 60 秒生效,不需要重新构建params在 15+ 之后是 Promise:必须await,忘了会拿到 undefined
五、SEO 三件套:metadata、OG、结构化数据
5.1 metadata(标题与描述)
// 静态部分:根布局
export const metadata: Metadata = {
metadataBase: new URL('https://example.com'),
title: { default: '我的技术站', template: '%s | 我的技术站' },
description: '技术笔记与参考字典',
}
// 动态部分:每篇文章
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const post = getPost(slug)
if (!post) return {}
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: `/blog/${slug}` }, // 规范链接
openGraph: {
title: post.title,
description: post.excerpt,
images: [`/blog/${slug}/opengraph-image`], // 指向动态生成的图
type: 'article',
publishedTime: post.date,
},
}
}5.2 动态生成分享卡片图
每篇文章都配一张自动生成的 OG 图,分享到社交平台时点击率明显更高。Next 支持用代码生成:
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getPost } from '@/lib/posts'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = getPost(slug)
return new ImageResponse(
(
<div style={{
width: '100%', height: '100%', display: 'flex', flexDirection: 'column',
justifyContent: 'space-between', padding: 80, background: '#F7F5F0',
}}>
<div style={{ fontSize: 28, color: '#5A5A5A' }}>example.com</div>
<div style={{ fontSize: 64, fontWeight: 700, color: '#141414', lineHeight: 1.3 }}>
{post?.title}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{ width: 100, height: 4, background: '#B33A2B' }} />
<div style={{ fontSize: 24, color: '#7A7A7A' }}>{post?.date}</div>
</div>
</div>
),
size,
)
}这条最划算:一次写好,每篇新文章自动有符合品牌调的分享图,不用手动做设计。参数用
params(Promise),返在ImageResponse里用 JSX 画图。
5.3 结构化数据(可能出富摘要)
// 在文章页里注入 JSON-LD
export default async function PostPage({ params }: Props) {
const { slug } = await params
const post = getPost(slug)!
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'TechArticle',
headline: post.title,
description: post.excerpt,
datePublished: post.date,
dateModified: post.updated ?? post.date,
author: { '@type': 'Person', name: '作者名' },
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>{/* ... */}</article>
</>
)
}六、sitemap 与 robots 全自动
// app/sitemap.ts —— 自动生成 /sitemap.xml
import type { MetadataRoute } from 'next'
import { getAllPosts } from '@/lib/posts'
export default function sitemap(): MetadataRoute.Sitemap {
const base = 'https://example.com'
const posts = getAllPosts().map(p => ({
url: `${base}/blog/${p.slug}`,
lastModified: new Date(p.date),
changeFrequency: 'monthly' as const,
priority: 0.8,
}))
return [
{ url: base, lastModified: new Date(), priority: 1 },
{ url: `${base}/blog`, lastModified: new Date(), priority: 0.9 },
...posts,
]
}
// app/robots.ts —— 自动生成 /robots.txt
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: [{ userAgent: '*', allow: '/', disallow: ['/api/'] }],
sitemap: 'https://example.com/sitemap.xml',
}
}这两个文件是「约定式」的,放对位置就自动生效,不需要写路由。搜索引擎提交 /sitemap.xml 就能收录全部内容。
七、性能:三件必做
7.1 图片用 next/image
import Image from 'next/image'
<Image src="/hero.png" width={800} height={600} alt="" priority />
<Image src={url} fill sizes="(max-width:768px) 100vw, 50vw" alt="" />7.2 字体自托管(消除布局抖动)
// app/layout.tsx
import { Noto_Serif_SC } from 'next/font/google'
const serif = Noto_Serif_SC({ subsets: ['latin'], weight: ['400', '700'] })
// 构建期下载并自托管,页面加载不请求 Google Fonts
<body className={serif.className}>7.3 静态优先,只在必要时动态
| 页面 | 策略 | 原因 |
|---|---|---|
| 首页 / 列表页 | ISR(revalidate 60) | 内容变化不频繁 |
| 文章详情 | SSG + ISR | 构建时生成,之后再生 |
| 搜索结果 | 动态(no-store) | 每次都要查 |
| API 路由 | 按需 | 注意别默认被静态化 |
八、部署:三种形态怎么选
| 形态 | 命令/配置 | 适用 |
|---|---|---|
| 标准 Node | next build && next start | 自托管服务器 |
| standalone | output: "standalone" | 容器部署,只拷必要文件 |
| 静态导出 | output: "export" | 纯静态,无 SSR/ISR/Server Action |
内容站推荐 standalone:既有 ISR 能力(需要 Node 运行时),又能压到较小的镜像体积。
# next.config.ts 里设 output: 'standalone'
next build
1. 部署时只需要这三样
1. .next/standalone .next/static public
node .next/standalone/server.js九、验收清单
| 检查项 | 怎么验 | 期望 |
|---|---|---|
| 所有文章被静态生成 | 看 .next/server/app/blog/ 下是否有各 slug 目录 | 每篇都有 |
| sitemap 完整 | curl /sitemap.xml | 文章数 = 实际数 |
| robots 正确 | curl /robots.txt | 含 sitemap 地址 |
| OG 图能生成 | 打开 /blog/xxx/opengraph-image | 返回 PNG |
| 结构化数据有效 | 用 Google 富摘要测试工具 | 无错误 |
| 首屏 < 2 秒 | PageSpeed Insights | LCP < 2.5s |
十、这个项目教会了什么
generateStaticParams是收录的前提:没有它,动态路由的文章不会被预渲染- OG 图可以代码生成:一次写好,长期受益,比手动做图划算得多
- sitemap 和 robots 是约定文件,不用写路由,放对位置就生效
- 内容站的核心是「能被找到」:性能、SEO、分享卡片,三者缺一不可
- standalone 部署是内容站的最优解:保留 ISR 又控制体积