普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月14日首页

return null:Next.js App Router 博客的 14 个 SEO 死穴

作者 DiffServ
2026年4月14日 18:15

return null:Next.js App Router 博客的 14 个 SEO 死穴

Googlebot 爬你的博客,看到的是一片空白。不是服务器挂了,不是页面 404,是你亲手写的 return null 把整个 <body> 清空了。


0. 症状

部署了一个 Next.js 16 + App Router 的技术博客,文章全是 Server Component,metadata 配得整整齐齐,sitemap 也有,robots.txt 也放了。但 Google Search Console 里,收录数是 0。

curl 一看 HTML 源码:

<body>
  <!-- 空的 -->
</body>

6 篇精心写的深度技术文章,Googlebot 一个字都没看到。


1. 元凶:ClientOnly 的 return null

根 layout 里有一个 ClientOnly 组件包裹了整个 {children}

// components/AuthProvider.tsx
'use client'

export function ClientOnly({ children }) {
  const [mounted, setMounted] = useState(false)
  useEffect(() => { setMounted(true) }, [])

  if (!mounted) return null  // ← SSR 阶段永远走这里

  return <AuthProvider>{children}</AuthProvider>
}
// app/layout.tsx
<body>
  <ClientOnly>{children}</ClientOnly>
</body>

SSR 阶段 mounted = falsereturn null → HTML body 为空。

这个组件的原意是等客户端 hydration 完成后再渲染,避免 auth 状态闪烁。但副作用是:所有页面的 SSR 输出为零。Googlebot 虽然能执行 JS,但需要等 hydration 完成才能看到内容,爬取效率和索引优先级大幅下降。

修复:删掉 if (!mounted) return null,让 SSR 阶段也正常输出 children。

export function ClientOnly({ children }) {
  const [mounted, setMounted] = useState(false)
  useEffect(() => { setMounted(true) }, [])
  // 不阻塞 SSR:mounted=false 时也输出 children
  return <AuthProvider>{children}</AuthProvider>
}

Auth 状态在 SSR 阶段是空的,没关系——博客文章不需要登录态。


2. cookies() 暗杀 ISR

修完 SSR 后,给博客列表页配了 ISR:

export const revalidate = 3600 // 每小时重新生成

但发现每次请求仍然走服务端渲染,ISR 缓存完全没生效。

原因:页面里调用了 cookies()

// blog/page.tsx
import { cookies } from 'next/headers'

export default async function BlogPage() {
  const cookieStore = await cookies()  // ← 这行杀死了 ISR
  const token = cookieStore.get('token')?.value
  // ...
}

在 Next.js App Router 中,cookies() 是动态函数(Dynamic Function)。一旦调用,无论你怎么设 revalidate,页面都会强制进入动态渲染模式。ISR 形同虚设。

修复:把 cookie 逻辑移到客户端组件里。博客列表页本来就不需要在服务端读 cookie。


3. 缺 metadataBase,canonical 全废

每篇文章都配了 openGraph.url,但没在根 layout 设 metadataBase

// ❌ 之前
export const metadata: Metadata = {
  title: "DiffServ — V8 Performance Lab",
}

// ✅ 之后
export const metadata: Metadata = {
  metadataBase: new URL("https://diffserv.xyz"),
  title: "DiffServ — V8 Performance Lab",
}

没有 metadataBase,所有相对路径的 canonical URL、OG 图片地址都无法被 Next.js 解析为绝对 URL。搜索引擎拿到的是残缺的 meta 信息。


4. www 和裸域同时响应,权重分裂

Nginx 配置:

server_name diffserv.xyz www.diffserv.xyz;

两个域名同时响应相同内容,Google 视为两个独立站点,PageRank 被一分为二。

修复:www 单独做 301:

server {
    listen 443 ssl http2;
    server_name www.diffserv.xyz;
    return 301 https://diffserv.xyz$request_uri;
}

5. 没有 HSTS,每次首访多一次重定向

有 HTTP→HTTPS 301,但没有 Strict-Transport-Security 头。用户每次输入 diffserv.xyz 都要经历一次 80→443 的重定向,白白多 100-300ms。

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

6. 静态资源没有长缓存头

Next.js 的 /_next/static/ 文件名自带 content hash,天然可以永久缓存。但 Nginx 没配:

location /_next/static/ {
    proxy_pass http://web;
    add_header Cache-Control "public, max-age=31536000, immutable" always;
}

没有这行,浏览器每次都要发条件请求验证缓存,白白浪费 RTT。


7. 没有 RSS

技术博客没有 /feed.xml = 放弃了 Feedly、Inoreader 等 RSS 阅读器的整个流量入口。在 Next.js App Router 里用 Route Handler 生成:

// app/feed.xml/route.ts
export async function GET() {
  const items = blogPosts.map(post => `
    <item>
      <title>${post.title}</title>
      <link>https://diffserv.xyz/blog/${post.slug}</link>
      <pubDate>${new Date(post.date).toUTCString()}</pubDate>
      <description>${post.description}</description>
    </item>
  `).join('')

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
    <rss version="2.0">
      <channel>
        <title>DiffServ Lab</title>
        <link>https://diffserv.xyz</link>
        ${items}
      </channel>
    </rss>`

  return new Response(xml, {
    headers: { 'Content-Type': 'application/rss+xml' },
  })
}

8. 没有 OG 图片

所有文章声明了 twitter.card: summary_large_image 但没给图片 URL。社交平台分享是纯文本链接,点击率比带图低 40%+。

Next.js App Router 支持 app/opengraph-image.tsx 动态生成 OG 图片,或者在 public/ 放一张默认图然后在全局 metadata 里引用。


9. JSON-LD 缺 dateModifiedimage

Google Rich Results 要求 BlogPosting 类型至少包含 headlinedatePublisheddateModifiedimageauthor。缺少 dateModifiedimage,搜索结果中不会显示富媒体摘要(发布日期、缩略图)。


10. 没有 404 / 500 页面

Next.js App Router 默认的 404 是一个白底黑字的 "404 | This page could not be found",没有导航、没有推荐内容。用户点到死链直接流失。

创建 app/not-found.tsxapp/error.tsx,至少给一个回首页的链接和几篇推荐文章。


11. next.config.ts 为空

const nextConfig: NextConfig = {};

至少加两行:

const nextConfig: NextConfig = {
  poweredByHeader: false,           // 隐藏 X-Powered-By: Next.js
  images: { formats: ['image/avif', 'image/webp'] },
};

poweredByHeader 暴露技术栈给攻击者;不启用 AVIF 意味着放弃了 30-50% 的图片压缩率。


12. viewport 禁止缩放

export const viewport: Viewport = {
  maximumScale: 1,
  userScalable: false,
}

WCAG 2.1 明确要求用户能放大到至少 200%。这两行让 Lighthouse Accessibility 直接扣分。删掉。


13. sitemap lastModified 每次构建都变

lastModified: new Date(),  // ← 每次 ISR 重生成都是新时间

Google 看到所有 URL 的 lastModified 同时变化,会重新爬取全站,浪费 crawl budget。硬编码真实的修改日期。


14. 内部链接用了 <a> 而不是 <Link>

部分博客文章里的内部跳转(/lab/blog/xxx)用了原生 <a> 标签。Next.js 的 <Link> 组件会自动 prefetch 目标页面,用 <a> 则触发全页刷新,白白丢掉了客户端路由的性能优势。


对标 Astro:Next.js 的额外成本

维度 Astro 默认 Next.js 需要手动做
SSR 输出 纯 HTML,零 JS 需确保不被 ClientOnly 阻断
ISR 默认 SSG 需手动配 revalidate,且不能碰 cookies()
RSS @astrojs/rss 一行配 手写 Route Handler
OG 图片 社区包成熟 opengraph-image.tsx 或手动
零 JS 默认不发送 runtime Server Component 不 hydrate,但仍有 React runtime 开销
sitemap @astrojs/sitemap 自动 手动实现,需注意 lastModified

Astro 的优势是默认值就是最佳实践。Next.js 的优势是灵活性——但灵活性的代价是你必须知道每个默认值背后的坑。

如果你的博客是纯内容站,Astro 确实省心。但如果你的站点同时有博客、交互式 Lab、用户系统、API——Next.js 的全栈能力是 Astro 替代不了的。关键是:把该配的配好,把该删的删掉


修完之后

14 项全部修完后的状态:

  • HTML 源码可见全部文章内容,Googlebot 无需执行 JS
  • ISR 缓存生效,TTFB 从 ~500ms 降到 ~50ms
  • 社交分享带品牌 OG 图片
  • RSS 接入全球阅读器生态
  • HSTS preload + www 301 + immutable 缓存
  • Lighthouse Performance / SEO / Accessibility / Best Practices 全绿

不需要换框架。Next.js 能做到 Astro 做的一切,前提是你知道哪些地方需要手动补。


GitHub: hlng2002/stw-sentinel 在线实验: diffserv.xyz/lab

16÷4 陷阱:一行代码让 SharedArrayBuffer 数据全部错位

作者 DiffServ
2026年4月14日 11:50

16÷4 陷阱:一行代码让 SharedArrayBuffer 数据全部错位

主线程写进去的采样数据,Worklet 线程读出来全是乱码。

不是数据损坏。不是跨线程竞争。不是字节序。

你把 16 字节偏移当成了 16 个元素索引。

这个 bug 我在写 stw-sentinel 时踩的。processor.js 里 HEADER_SIZE = 16,TypedArray 构造器第三个参数是元素个数不是字节数——16 个 Int32 元素 = 64 字节,header 直接膨胀 4 倍,后面的数据全偏了 48 字节。SAB 没坏,Atomics 没报错,数据就是永远对不上。


陷阱解剖

SharedArrayBuffer 是一块裸内存。你在上面建视图,同一个偏移量,不同类型的索引含义完全不同:

// ❌ 我的 bug
const HEADER_SIZE = 16; // 16 字节

const header = new Int32Array(sab, 0, HEADER_SIZE); // 16 个 Int32 元素 = 64 字节!
const data = new Float32Array(sab, HEADER_SIZE * 4); // 偏移 64 字节,完全错位

// ✅ 修完
const HEADER_BYTES = 16;
const headerElements = HEADER_BYTES / 4; // 4 个 Int32 元素

const header = new Int32Array(sab, 0, headerElements); // 4 元素 = 16 字节
const data = new Float32Array(sab, HEADER_BYTES);      // 从第 16 字节开始

错误版本里,Int32Array(sab, 0, 16) 创建了 16 个 Int32 元素,占 64 字节。你的 header 本该占 16 字节,实际占了 64 字节。后面的数据区跟着偏移了 48 字节——不多不少,刚好 4 倍。

数据不会报错。 Int32Array 和 Float32Array 都能正常读写,Atomics 操作也不报异常。你的监控面板上看到的只是"数据对不上",没有任何 red flag 告诉你偏移算错了。

为什么 AudioWorklet 里这个坑最致命

非实时场景下,写错偏移顶多是初始化失败,加个 try-catch 就能定位。但 AudioWorklet 的 process() 回调每 128 帧跑一次(约 2.67ms),数据是流式消费的——错位就是错位,没有重传机制,没有校验和,数据流永远对不齐。

更毒的是:console.log 打出来全是 Int32 值,值本身没坏,只是写到了错误的内存位置。你盯着输出看半天,看不出任何异常。

前端开发者对"字节对齐"几乎没直觉。JavaScript 层面你碰不到字节,new ArrayBuffer(16) 对你来说就是"16 个槽位",很少去想这 16 个槽位的单位是什么。直到你用 SharedArrayBuffer 搭实时管道,字节和元素的分界线才会咬你一口。

超新星核爆.png

底层代码没有类型系统保护你。字节和元素搞混,编译器不报错,运行不崩溃,就是数据不对。这种 bug 最毒——你不一定发现得了。

在线验证:diffserv.xyz/lab, Worklet 心跳(~2.67ms),黄线是主线程帧间隔。两条线各跑各的,SAB 是唯一的桥。数据对齐了,就没有坑。

npm install stw-sentinel

GitHub: github.com/hlng2002/st…

❌
❌