Skip to content

前言 ​

对于一个技术博客而言,SEO 非常重要,好的 SEO 会为你带来更多的流量。

可是问题在于,SEO 要做的事情非常多,一时间竟不知道如何开始优化。

所以本篇我们就梳理下 SEO 要做的事情。

1. 开发时注意 ​

1.1. 尽可能使用服务端组件 ​

尽管搜索引擎也开始支持 JS 生成的内容,但还是尽可能使用服务端组件,将主体内容渲染成 HTML 返回给爬虫

1.2. 尽可能使用 Streaming ​

使用 Streaming 并不会对 SEO 造成负面影响,相反,因为显著改善了首次页面呈现时间等性能指标,对 SEO 更好。

1.3. 尽可能使用 Image 组件 ​

Image 组件本身有尺寸优化、懒加载等功能,而且为了维持视觉稳定,它会在图片加载的时候自动阻止布局偏移,此外使用 Image 组件需要设置 alt 属性,这都有利于 SEO。

1.4. 尽可能使用 Font 组件 ​

Font 组件会在构建时获取外部字体文件,并自动使用 CSS size-adjust 属性对字体进行调整,以防止发生布局偏移。像谷歌公开表示过,CLS (Cumulative Layout Shift,累计布局偏移,网页整个生命周期内发生的所有意外布局偏移的得分总和)是一个重要指标。

简单来说,就是谷歌要求网页元素在加载期间不要“乱动”,比如点赞按钮本应该在下方,但因为文章还没有加载完毕,于是呈现在上方,文章加载完毕后,就被“挤”到下方,这就很不好。

Image 组件和 Font 组件默认都做了阻止布局偏移处理。

简而言之,优化 Next.js 应用程序的性能将直接影响 SEO。

2. 添加 robots.txt 和 sitemap.xml ​

关于 robots.txt 和 sitemap.xml,我们已经在 《Metadata 篇 | 基于文件》介绍过。这里我们就直接开始讲如何做。

新建 data/siteMetadata.js,用于存储网站常用 SEO 字段,代码如下:

javascript
const siteMetadata = {
  siteUrl: 'https://yayujs.com'
}

export default siteMetadata

为了方便引入,修改 jsconfig.json,代码如下:

javascript
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/data/*": ["data/*"],
      "contentlayer/generated": ["./.contentlayer/generated"]
    }
  },
  "include": [
    "next-env.d.js",
    "**/*.js",
    "**/*.jsx",
    ".next/types/**/*.js",
    ".contentlayer/generated"
  ]
}

新建 app/sitemap.js,代码如下:

javascript
import { allPosts } from 'contentlayer/generated'
import siteMetadata from '@/data/siteMetadata'

export default function sitemap() {
  const siteUrl = siteMetadata.siteUrl

  const blogRoutes = allPosts
    .map((post) => ({
      url: `${siteUrl}${post.url}`,
      lastModified: post.lastmod || post.date,
    }))

  const routes = ['', 'posts'].map((route) => ({
    url: `${siteUrl}/${route}`,
    lastModified: new Date().toISOString().split('T')[0],
  }))

  return [...routes, ...blogRoutes]
}

这里我们动态生成了所有页面地址,当你访问 http://localhost:3000/sitemap.xml 时,效果如下:

image.png

新建 app/robots.js,代码如下:

javascript
import siteMetadata from '@/data/siteMetadata'

export default function robots() {
  return {
    rules: {
      userAgent: '*',
      allow: '/',
    },
    sitemap: `${siteMetadata.siteUrl}/sitemap.xml`,
    host: siteMetadata.siteUrl,
  }
}

当你访问 http://localhost:3000/robots.txt 时,效果如下:

image.png

3. 设置每个页面的 Metadata ​

3.1. 基础字段 ​

为了方便爬取,每个页面都需要设置 title、description 等属性,除了这两个属性还有哪些属性要设置呢?

关键看搜索引擎会用到哪些字段,这个可以参考 Google 的官方文档《Google 支持的 meta 标记和属性》。简而言之,最常用的有:

html
<meta name="description" content="A description of the page">
<meta name="robots" content="..., ...">
<meta name="googlebot" content="..., ...">

注:keywords 这种属性,Google 已经废弃不用了,参考《Google 不会将关键字元标记用于网页排名》。

3.2. 网站规范化字段 ​

除此之外,还应该有:

html
<link rel="canonical" href="https://example.com/dresses/green-dresses" />
<link rel="alternate" type="application/rss+xml" href="http://example.com/rss.xml" />

前者用于网站规范化,参考《如何使用 rel="canonical" 及其他方法指定规范网址》。后者用于网站的 RSS 订阅(如果有的话)。

3.3. Open Graph protocol 字段 ​

再然后要支持 Open Graph protocol,介绍参考《VuePress 博客之 SEO 优化(四) Open Graph protocol》。

就是我们常见的 og: 开头的字段:

javascript
<meta property="og:url" content="http://www.nytimes.com/2015/02/19/arts/international/when-great-minds-dont-think-alike.html" />
<meta property="og:type" content="article" />
<meta property="og:title" content="When Great Minds Don’t Think Alike" />
<meta property="og:description" content="How much does culture influence creative thinking?" />
<meta property="og:image" content="http://static01.nyt.com/images/2015/02/19/arts/international/19iht-btnumbers19A/19iht-btnumbers19A-facebookJumbo-v2.jpg" />

因为我们是博客,自然是选择 og 的 article 类型:

image.png

最终生成的字段示例如下:

image.png

3.4. 其他字段 ​

最后还有一些其他字段,比如 Twitter Card,用于在 Twitter 进行分享展示,视情况添加。

至于在 Next.js 中如何添加,我们会和下一节一起实现。

4. 添加 JSON-LD 数据 ​

英文全程:JavaScript Object Notation for Linked Data,官方地址:json-ld.org/,简单的来说,就是用来描述网页的类型和内容,方便搜索引擎做展现。

比如如果我们在 Google 搜索 「Chocolate in a mug」,我们会看到这样的搜索结果:

我们打开页面,就可以看到搜索展示的内容对应了 application/ld+json 中的内容:

注:关于 JSON-LD,参考《VuePress 博客之 SEO 优化(五)添加 JSON-LD 数据》

修改 data/siteMetadata.js,代码如下:

javascript
const siteMetadata = {
  siteUrl: 'https://yayujs.com',
  title: '冴羽的技术博客',
  description: '冴羽的技术博客,分享技术、个人成长等内容',
  author: '冴羽',
  locale: 'zh-CN',
  socialBanner: 'https://cdna.artstation.com/p/assets/images/images/028/138/058/large/z-w-gu-bandageb5f.jpg?1593594749'
}

export default siteMetadata

这里我们添加了 SEO 常用的字段,方便复用以及作为默认值。其中 socialBanner 用于 og 或者 twitter 分享时的 image 默认图片。

修改 app/layout.js,代码如下:

javascript
import siteMetadata from '@/data/siteMetadata'
import "./globals.css";

export const metadata = {
  metadataBase: new URL(siteMetadata.siteUrl),
  title: {
    default: siteMetadata.title,
    template: `%s | ${siteMetadata.title}`,
  },
  description: siteMetadata.description,
  openGraph: {
    title: siteMetadata.title,
    description: siteMetadata.description,
    url: './',
    siteName: siteMetadata.title,
    images: [siteMetadata.socialBanner],
    locale: 'zh_CN',
    type: 'website',
  },
  alternates: {
    canonical: './',
    types: {
      'application/rss+xml': `${siteMetadata.siteUrl}/feed.xml`,
    },
  },
  robots: {
    index: true,
    follow: true,
    googleBot: {
      index: true,
      follow: true,
      'max-video-preview': -1,
      'max-image-preview': 'large',
      'max-snippet': -1,
    },
  },
  twitter: {
    title: siteMetadata.title,
    card: 'summary_large_image',
    images: [siteMetadata.socialBanner],
  },
}

export default function RootLayout({ children }) {
  return (
    <html lang={siteMetadata.locale}>
      <body>{children}</body>
    </html>
  );
}

此时访问 http://localhost:3000/,效果如下:

截屏2024-05-13 17.30.15.png

因为写在了根布局中,这些元数据相当于默认值。

如果你要修改某个页面的元数据,比如修改 app/posts/page.js,添加代码如下:

javascript
export const generateMetadata = ({ params }) => {
  return { 
    title: "博客列表",
    description: "这是博客列表页面",
    openGraph: {
      title: '博客列表',
      description: '这是博客列表页面'
    }
  }
}

此时访问 http://localhost:3000/posts,效果如下:

截屏2024-05-13 17.33.49.png

最为复杂的应该是具体的文章页面,因为我们需要通过 FrontMatter 设置页面的元数据,并且我们使用了 Contentlayer 校验 FrontMatter 的值。

修改 contentlayer.config.js,代码如下:

javascript
import { defineDocumentType, makeSource } from 'contentlayer/source-files'
import remarkGfm from 'remark-gfm'
import rehypePrismPlus from 'rehype-prism-plus'
import siteMetadata from './data/siteMetadata'

export const Post = defineDocumentType(() => ({
  name: 'Post',
  filePathPattern: `**/*.mdx`,
  contentType: 'mdx',
  fields: {
    title: { type: 'string', required: true },
    date: { type: 'date', required: true },
    description: { type: 'string' },
    lastmod: { type: 'date' },
    images: { type: 'json' },
    canonicalUrl: { type: 'string' },
  },
  computedFields: {
    url: { type: 'string', resolve: (post) => `/posts/${post._raw.flattenedPath}` },
    structuredData: {
      type: 'json',
      resolve: (doc) => ({
        '@context': 'https://schema.org',
        '@type': 'BlogPosting',
        headline: doc.title,
        datePublished: doc.date,
        dateModified: doc.lastmod || doc.date,
        description: doc.description,
        image: doc.images ? doc.images[0] : siteMetadata.socialBanner,
        url: `${siteMetadata.siteUrl}/posts/${doc._raw.flattenedPath}`,
      }),
    },
  },
}))

export default makeSource({ 
  contentDirPath: 'posts', 
  documentTypes: [Post],
  mdx: {
    remarkPlugins: [remarkGfm],
    rehypePlugins: [[rehypePrismPlus, { defaultLanguage: 'js', ignoreMissing: true }],],
  }
})

我们定义了一些 FrontMatter 的字段,并基于此和 siteMetadata 的字段计算当前文章的 JSON-LD 数据。

修改 app/posts/[id]/page.js,代码如下:

javascript
import { allPosts } from 'contentlayer/generated'
import { useMDXComponent } from 'next-contentlayer/hooks'
import { notFound } from 'next/navigation'
import dayjs from "dayjs";
import siteMetadata from '@/data/siteMetadata'

export async function generateStaticParams() {
  return allPosts.map((post) => ({
    id: post._raw.flattenedPath,
  }))
}
export const generateMetadata = ({ params }) => {
  const post = allPosts.find((post) => post._raw.flattenedPath === params.id)
  if (!post) throw new Error(`Post not found for id: ${params.id}`)

  const publishedAt = new Date(post.date).toISOString()
  const modifiedAt = new Date(post.lastmod || post.date).toISOString()

  let imageList = [siteMetadata.socialBanner]
  if (post.images) {
    imageList = typeof post.images === 'string' ? [post.images] : post.images
  }
  const ogImages = imageList.map((img) => {
    return {
      url: img.includes('http') ? img : siteMetadata.siteUrl + img,
    }
  })

  const authors = post?.authors || [siteMetadata.author]
  
  return {
    title: post.title,
    description: post.description,
    openGraph: {
      title: post.title,
      description: post.description,
      siteName: siteMetadata.title,
      locale: 'zh_CN',
      type: 'article',
      publishedTime: publishedAt,
      modifiedTime: modifiedAt,
      url: './',
      images: ogImages,
      authors: authors
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.description,
      images: imageList,
    },
  }
}

const Page = ({ params }) => {
  const post = allPosts.find((post) => post._raw.flattenedPath === params.id)
  if (!post) notFound()
  const MDXContent = useMDXComponent(post.body.code)
  const jsonLd = post.structuredData
  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <article className="mx-auto max-w-xl py-8 prose prose-slate">
        <div className="mb-8 text-center">
          <time dateTime={post.date} className="mb-1 text-xs text-gray-600">
            {dayjs(post.date).format('DD/MM/YYYY')}
          </time>
          <h1 className="text-3xl font-bold">{post.title}</h1>
        </div>
        <MDXContent />
      </article>
    </>
  )
}

export default Page

我们根据文章页面的设置的 FrontMatter 数据动态生成页面元数据。

修改 posts/first.mdx,添加 FrontMatter 代码如下:

javascript
---
title: 使用 Next.js App Router 常犯的 10 个错误
date: 2024-05-01
lastmod: 2024-05-02
description: 本篇介绍了使用 Next.js App Router 常犯的 10 个错误
images: [https://cdnb.artstation.com/p/assets/images/images/007/367/401/large/z-w-gu-dsassd2.jpg?1505659743]
---

此时访问 http://localhost:3000/posts/first,效果如下:

截屏2024-05-13 17.48.13.png

5. 直接添加站点 ​

不用等待 Google 慢慢收录,你可以直接将站点提交给 Google、百度等搜索引擎,参考 《VuePress 博客之 SEO 优化(一) sitemap 与搜索引擎收录》

6. 项目源码 ​

  1. 功能实现:博客添加 SEO
  2. 源码地址:https://github.com/mqyqingfeng/next-app-demo/tree/next-blog-2
  3. 下载代码:git clone -b next-blog-2 git@github.com:mqyqingfeng/next-app-demo.git