Next.js SEOタグの最適化 + OGP画像の動作確認方法(page router)
概要
ページごとに基本的なSEOタグ(title, description, keywords, 各種SNS系も併せて)を設定したい!
SEOタグの例
OGP画像(画像URLと画像の縦横指定、各種SNS系併せて)も正しく表示されているか確認したい!
OGP画像の例
SEOタグは検索結果に影響を与える。
実は設定が間違っていて、検索エンジンに無視されていた、、
OGP画像の設定が間違っていてユーザーの流入率が下がっていた、、
とかを防ぎたい。
「SEOタグがページごとに正しく描画されているか」
を手作業で確認したくない。デグレも怖いのでテストコードも欲しい。
ググったけど微妙な記事ばかりだったので、今回自分なりにまとめてみた。
※タイトルに「Next.js」とつけたが、React.js, Vue.js, Angular.jsでも同様に実装できると思う。
対象読者
- SEOタグの実装方法を知りたい方
- OGP画像を設定したい方
説明すること/説明しないこと
説明すること
- ライブラリを使わない理由
- 実装方法+テストコードの説明
- OGP画像の動作確認方法
説明しないこと
- SEOタグ, OGP画像の意味の説明
- SSRの場合の例
いいね!してね
この記事の事例は必要に応じて今後追記していく予定です!
「新しい事例が知りたい」「他の事例も知りたい」と思った人は、ぜひこの記事にいいね👍してください。筆者のモチベーションにつながります!
それでは以下が本編です。
結論
サンプルコミットはこちら:
ライブラリを使わない理由
next-seo というライブラリを使わなかった。
使うメリットが少ないと感じたからである。
理由
-
やりたいことを自力で実装できそう
やりたいこと: defaultのseoタグを指定して、各ページで一部上書きしたい。
これはコンポーネント作ればできそう。 -
ライブラリの学習コストが高い
フロントエンドエンジニアを数年やっているが、メタタグは見慣れないし、使い慣れていない。というか興味が全く湧かない。
例:<meta property="og:description" content={description} />
このタグの指定方法を覚えるだけでうんざりなのに、さらにライブラリの使い方を理解するのはしんどいと思った。ライブラリが数年後に存在しているか不明だし -
ライブラリの型が微妙
オブジェクトのキー名に、設定上固定値のもの(例: twitterとか)を間違えても指定できてしまうっぽい。
で、動作確認で気づいて直す、みたいなパターンがありそう。
実装方法+テストコードの説明
実装のポイント
✅可読性: 共通化しすぎず、ある程度ベタ書きの方が見やすい。
✅シンプル: ページが増えたら、<Seo>
タグをコピペで増やすだけ
// pages/home/index.tsx
import styles from "@/pages/index.module.css";
import { Seo } from "../_seo";
import { publicPages } from "../../paths/routes";
export default function Home() {
return (
<div className={styles.container}>
<Seo
title={publicPages.index.title()}
description={publicPages.index.description()}
path={publicPages.index.path()}
/>
</div>
);
}
テストコード
✅ シンプル: pageが増えたら、publicPagesのパスを変更してコピペするだけ。
pages/home/_index.spec.tsx
import { render } from "@testing-library/react";
import Page from "./index";
import { publicPages } from "../../paths/routes";
import { mockNextHead, assertSeoTags } from "../__testing__/seo-helper";
describe(Page, () => {
function setup() {
return {
view: render(<Page />),
};
}
beforeAll(() => {
mockNextHead();
});
afterEach(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
});
it("SEO tag rendered", async () => {
// Act
setup();
// Assert
assertSeoTags({
titleText: publicPages.index.title(),
descriptionText: publicPages.index.description(),
ogUrlText: `${process.env.NEXT_HOST_URI}${publicPages.index.path()}`,
});
const metaRobots = document.querySelector('meta[name="robots"]');
expect(metaRobots).toBeInTheDocument();
expect(metaRobots).toHaveAttribute("content", "all");
});
});
SEOコンポーネント
✅ 可読性: ベタ書きで見やすい。if文での条件分岐をできるかぎりなくした。
※FacebookのOGPは未対応
// pages/_seo.tsx
import Head from "next/head";
import { FC } from "react";
type SeoOptions = {
robots?: "all" | "noindex" | "nofollow" | "none" | "noarchive" | "nosnippet";
};
export type SeoPops = {
title: string;
description: string;
path: string;
options?: SeoOptions;
};
export const Seo: FC<SeoPops> = ({ title, description, path, options }) => {
const NEXT_PUBLIC_HOST_URI = process.env.NEXT_PUBLIC_HOST_URI;
const defaultSeoOptions: SeoOptions = {
robots: "all",
};
return (
<Head>
{
<meta
name="robots"
content={options?.robots ?? defaultSeoOptions.robots}
/>
}
<title>{title}</title>
{description && (
<meta key="description" name="description" content={description} />
)}
{/* og */}
<meta property="og:url" content={`${NEXT_PUBLIC_HOST_URI}${path}`} />
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:site_name" content={title} />
<meta
property="og:image"
content={`${NEXT_PUBLIC_HOST_URI}/this_is_og_image.png`} // イメージパスを指定する
/>
<meta property="og:image:alt" content="this_is_og_image" />
<meta property="og:image:width" content={String(1200)} />
<meta property="og:image:height" content={String(630)} />
{/* twitter */}
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
</Head>
);
};
SEOコンポーネントのテストコード
// pages/_seo.spec.tsx
import { render } from "@testing-library/react";
import { Seo, SeoPops } from "./_seo";
import { mockNextHead } from "./__testing__/seo-helper";
function setup(props: SeoPops) {
return {
view: render(<Seo {...props} />),
};
}
describe(Seo, () => {
beforeEach(() => {
mockNextHead();
});
afterEach(() => {
jest.resetAllMocks();
jest.restoreAllMocks();
});
it("HeadタグにSEO系のhtmlタグが存在すること", async () => {
const titleText = "ダミータイトル";
const descriptionText = "ダミー説明文";
const path = "/dummy";
setup({
title: titleText,
description: descriptionText,
path,
});
// title
const titleTag = document.querySelector("title");
expect(titleTag).toBeInTheDocument();
expect(titleTag?.textContent).toBe(titleText);
// description
const metaDescription = document.querySelector("meta[name='description']");
expect(metaDescription).toBeInTheDocument();
expect(metaDescription?.attributes?.getNamedItem("content")?.value).toBe(
descriptionText
);
/**
og
**/
const ogUrl = document.querySelector("meta[property='og:url']");
expect(ogUrl).toBeInTheDocument();
expect(ogUrl?.attributes?.getNamedItem("content")?.value).toBe(
`${process.env.NEXT_PUBLIC_HOST_URI}${path}`
);
const ogType = document.querySelector("meta[property='og:type']");
expect(ogType).toBeInTheDocument();
expect(ogType?.attributes?.getNamedItem("content")?.value).toBe("website");
const ogTitle = document.querySelector("meta[property='og:title']");
expect(ogTitle).toBeInTheDocument();
expect(ogTitle?.attributes?.getNamedItem("content")?.value).toBe(titleText);
const ogDescription = document.querySelector(
"meta[property='og:description']"
);
expect(ogDescription).toBeInTheDocument();
expect(ogDescription?.attributes?.getNamedItem("content")?.value).toBe(
descriptionText
);
const ogSiteName = document.querySelector("meta[property='og:site_name']");
expect(ogSiteName).toBeInTheDocument();
expect(ogSiteName?.attributes?.getNamedItem("content")?.value).toBe(
titleText
);
const ogImage = document.querySelector("meta[property='og:image']");
expect(ogImage).toBeInTheDocument();
expect(ogImage?.attributes?.getNamedItem("content")?.value).toBe(
`${process.env.NEXT_PUBLIC_HOST_URI}/this_is_og_image.png`
);
const ogImageAlt = document.querySelector("meta[property='og:image:alt']");
expect(ogImageAlt).toBeInTheDocument();
expect(ogImageAlt?.attributes?.getNamedItem("content")?.value).toBe(
"this_is_og_image"
);
const ogImageWidth = document.querySelector(
"meta[property='og:image:width']"
);
expect(ogImageWidth).toBeInTheDocument();
expect(ogImageWidth?.attributes?.getNamedItem("content")?.value).toBe(
String(1200)
);
const ogImageHeight = document.querySelector(
"meta[property='og:image:height']"
);
expect(ogImageHeight).toBeInTheDocument();
expect(ogImageHeight?.attributes?.getNamedItem("content")?.value).toBe(
String(630)
);
/**
twitter
**/
const twitterCard = document.querySelector("meta[name='twitter:card']");
expect(twitterCard).toBeInTheDocument();
const twitterTitle = document.querySelector("meta[name='twitter:title']");
expect(twitterTitle).toBeInTheDocument();
expect(twitterTitle?.attributes?.getNamedItem("content")?.value).toBe(
titleText
);
const twitterDescription = document.querySelector(
"meta[name='twitter:description']"
);
expect(twitterDescription?.attributes?.getNamedItem("content")?.value).toBe(
descriptionText
);
expect(twitterDescription).toBeInTheDocument();
});
describe("ブランクの場合、タグが存在しないこと", () => {
it("descriptionがブランクの場合、descriptionタグが存在しないこと", async () => {
const titleText = "ダミータイトル";
const descriptionText = "";
const path = "/dummy";
setup({
title: titleText,
description: descriptionText,
path,
});
const metaDescription = document.querySelector(
"meta[name='description']"
);
expect(metaDescription).not.toBeInTheDocument();
});
});
});
OGP画像の動作確認方法
https://zenn.dev/rsugi/scraps/ac20f0525b5e45 にまとめた!
まとめ
SEOタグとOGP画像の設定は、重要度が高い+ 動作確認コストが高いのでテストコードは必須だと思う。
どのプロジェクトでもほぼ必須な機能なのに、ネット上に良い記事がなかった。
この記事を良いね!👏と思ったら、いいね!をお願いします。
Discussion