🔖

Next.js SEOタグの最適化 + OGP画像の動作確認方法(page router)

2024/04/01に公開

概要

ページごとに基本的な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の場合の例

いいね!してね

この記事の事例は必要に応じて今後追記していく予定です!
「新しい事例が知りたい」「他の事例も知りたい」と思った人は、ぜひこの記事にいいね👍してください。筆者のモチベーションにつながります!

それでは以下が本編です。

結論

サンプルコミットはこちら:
https://github.com/r-sugi/nextjs-seo-template/commit/1ffe3c0324a11aac7228270a477fa02785cd2710

ライブラリを使わない理由

next-seo というライブラリを使わなかった。
使うメリットが少ないと感じたからである。

理由

  1. やりたいことを自力で実装できそう
    やりたいこと: defaultのseoタグを指定して、各ページで一部上書きしたい。
    これはコンポーネント作ればできそう。

  2. ライブラリの学習コストが高い
    フロントエンドエンジニアを数年やっているが、メタタグは見慣れないし、使い慣れていない。というか興味が全く湧かない。
    例: <meta property="og:description" content={description} /> 
    このタグの指定方法を覚えるだけでうんざりなのに、さらにライブラリの使い方を理解するのはしんどいと思った。ライブラリが数年後に存在しているか不明だし

  3. ライブラリの型が微妙
    オブジェクトのキー名に、設定上固定値のもの(例: 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