用 Next.js 做一个真的能被搜到的内容站

这篇不讲 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 路由按需注意别默认被静态化

八、部署:三种形态怎么选

形态命令/配置适用
标准 Nodenext build && next start自托管服务器
standaloneoutput: "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 InsightsLCP < 2.5s

十、这个项目教会了什么

  • generateStaticParams 是收录的前提:没有它,动态路由的文章不会被预渲染
  • OG 图可以代码生成:一次写好,长期受益,比手动做图划算得多
  • sitemap 和 robots 是约定文件,不用写路由,放对位置就生效
  • 内容站的核心是「能被找到」:性能、SEO、分享卡片,三者缺一不可
  • standalone 部署是内容站的最优解:保留 ISR 又控制体积
暂无评论

发送评论 编辑评论

|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇