Open23

Next.js(app router)+ヘッドレスWordpress+shadcn/uiで作るコーポレートサイト|WordPressとNext.jsの繋ぎ込み編

オオモリオオモリ

プロジェクト概要

会社のHPを刷新します。以前までWordpressで管理していたので、そのデータを引き継ぎヘッドレスCMSとしてWordpressを使用し、フロント部分は Next.jsで作ります。

  • なぜwordpressからNext.jsへ刷新するのか?
    • 昔ざっくり作っただけでその後あまり管理ができていなかった+phpで書かれていてレガシーな感じで保守性が悪い+プロダクトなどの概要が大きく変わりどちらにせよ大きく変更する必要があったから。
  • なぜWordpressを採用したのか?
    • Newtも考えたが記事執筆画面のUIガwordpressの方が優れている(慣れている)のと、今までwordpressで書いていた記事の継承が大変そうだったから。。

システムアーキテクチャ

  • フロントエンド:Next.js(app router)
  • ホスティング:Vercel
  • スタイリング:tailwind+shadcn/ui
  • ヘッドレスCMS:Wordpress
    • 投稿ページでNEWSの記事を管理
    • 以前から使用していたものを使うので、ムームーサーバー的なやつにホスティングされている。

ページ構成

  • TOP
  • COMPANY
  • NEWS
  • PRODUCT
  • WORKS
  • CAREERS
  • CONTACT
オオモリオオモリ

WordPressにGraphQLを導入する

WP GraphQLでは、GraphiQL IDEというものが用意されており、クエリを作りたい場合ここで作成&検証まで行えちゃいます。
Query Composerを開いて、左のメニューからposts > edges > nodeをチェックします。その中で取得したい項目(タイトルや内容、アイキャッチ画像やスラッグなどなどたくさんあります)を選びます。今回はidtitlecontentにしました。
右上の再生ボタンを押すとJSON形式でデータ取得できます!

  1. 「WPGraphQL」というプラグインを入れ、有効化。
  2. サイドメニューからGraphiQL IDEに移動
    クエリの参考
    https://gray-code.com/wordpress/enable-graphql-in-wordpress/
    postsが投稿ページ。
    一旦、以下のようにしてみる。
query NewQuery {
  posts {
    nodes {
      content
      date
      title
      status
      slug
    }
  }
}
オオモリオオモリ

.env.localを作成し、APIを記入する
WORDPRESS_API_URL=...
ここには、https://xxxx.com/graphql が入る。 ""は不要

(WORDPRESS_AUTH_REFRESH_TOKENやWORDPRESS_PREVIEW_SECRETはプレビューモードの時のみ)

プレビュー モードの認証を追加する (オプション)
このステップはオプションです。デフォルトでは、ブログは WordPress サイトからの公開投稿を処理します。未公開の投稿や非公開フィールドなどの非公開コンテンツは取得できません。未公開の投稿にアクセスするには、認証を設定する必要があります。

オオモリオオモリ

公式のエグザンプルを元にAPI部分を作った。

src/lib/api.tsの内容
const API_URL = process.env.WORDPRESS_API_URL;

async function fetchAPI(query = "", { variables }: Record<string, any> = {}) {
  const headers: Record<string, string> = {
    "Content-Type": "application/json",
  };

  if (process.env.WORDPRESS_AUTH_REFRESH_TOKEN) {
    headers["Authorization"] =
      `Bearer ${process.env.WORDPRESS_AUTH_REFRESH_TOKEN}`;
  }

  const res = await fetch(API_URL!, {
    headers,
    method: "POST",
    body: JSON.stringify({
      query,
      variables,
    }),
  });

  const json = await res.json();
  if (json.errors) {
    console.error(json.errors);
    throw new Error("Failed to fetch API");
  }
  return json.data;
}

export async function getAllPostsWithSlug() {
  const data = await fetchAPI(`
    {
      posts(first: 10000) {
        edges {
          node {
            slug
          }
        }
      }
    }
  `);
  return data?.posts;
}

export async function getAllPostsForHome() {
  const data = await fetchAPI(`
    query AllPosts {
      posts(first: 20, where: { orderby: { field: DATE, order: DESC } }) {
        edges {
          node {
            title
            excerpt
            slug
            date
            featuredImage {
              node {
                sourceUrl
              }
            }
            author {
              node {
                name
                firstName
                lastName
                avatar {
                  url
                }
              }
            }
          }
        }
      }
    }
  `);

  return data?.posts;
}

export async function getPostAndMorePosts(slug: string) {
  const data = await fetchAPI(
    `
    fragment AuthorFields on User {
      name
      firstName
      lastName
      avatar {
        url
      }
    }
    fragment PostFields on Post {
      title
      excerpt
      slug
      date
      featuredImage {
        node {
          sourceUrl
        }
      }
      author {
        node {
          ...AuthorFields
        }
      }
      categories {
        edges {
          node {
            name
          }
        }
      }
      tags {
        edges {
          node {
            name
          }
        }
      }
    }
    query PostBySlug($id: ID!) {
      post(id: $id, idType: SLUG) {
        ...PostFields
        content
      }
      posts(first: 3, where: { orderby: { field: DATE, order: DESC } }) {
        edges {
          node {
            ...PostFields
          }
        }
      }
    }
  `,
    {
      variables: {
        id: slug,
      },
    }
  );

  // Filter out the main post
  data.posts.edges = data.posts.edges.filter(
    ({ node }: { node: { slug: string } }) => node.slug !== slug
  );
  // If there are still 3 posts, remove the last one
  if (data.posts.edges.length > 2) data.posts.edges.pop();

  return data;
}
オオモリオオモリ

上記を元にこのサイトのTOPページとして新着記事の4件を表示するようにする部分だけ作る。

AIとのやり取り
はい、TOPページの新着記事の4件を表示する部分を作成しましょう。提供された情報を元に、適切なディレクトリ構造でコードを書いていきます。
まず、lib/api.tsファイルに先ほどのfetchAPI関数を移動します。
typescriptCopy code// lib/api.ts
const API_URL = process.env.WORDPRESS_API_URL;

export async function fetchAPI(query = "", { variables }: Record<string, any> = {}) {
  const headers: Record<string, string> = {
    "Content-Type": "application/json",
  };

  if (process.env.WORDPRESS_AUTH_REFRESH_TOKEN) {
    headers["Authorization"] = `Bearer ${process.env.WORDPRESS_AUTH_REFRESH_TOKEN}`;
  }

  const res = await fetch(API_URL!, {
    headers,
    method: "POST",
    body: JSON.stringify({
      query,
      variables,
    }),
  });

  const json = await res.json();
  if (json.errors) {
    console.error(json.errors);
    throw new Error("Failed to fetch API");
  }
  return json.data;
}
次に、features/post/api/getRecentPosts.tsファイルを作成し、新着記事を取得するAPIを実装します。
typescriptCopy code// features/post/api/getRecentPosts.ts
import { fetchAPI } from "@/lib/api";

export async function getRecentPosts() {
  const data = await fetchAPI(`
    query RecentPosts {
      posts(first: 4, where: { orderby: { field: DATE, order: DESC } }) {
        edges {
          node {
            title
            excerpt
            slug
            date
            featuredImage {
              node {
                sourceUrl
              }
            }
            author {
              node {
                name
              }
            }
          }
        }
      }
    }
  `);

  return data?.posts;
}
続いて、features/post/types/index.tsファイルを作成し、投稿データの型を定義します。
typescriptCopy code// features/post/types/index.ts
export type Post = {
  title: string;
  excerpt: string;
  slug: string;
  date: string;
  featuredImage: {
    node: {
      sourceUrl: string;
    };
  };
  author: {
    node: {
      name: string;
    };
  };
};
次に、features/post/components/PostItem.tsxファイルを作成し、個々の投稿を表示するコンポーネントを実装します。
typescriptCopy code// features/post/components/PostItem.tsx
import { Post } from "../types";

type Props = {
  post: Post;
};

export function PostItem({ post }: Props) {
  return (
    <div>
      <h2>{post.title}</h2>
      <p>{post.excerpt}</p>
      <p>By {post.author.node.name}</p>
      <p>{post.date}</p>
    </div>
  );
}
最後に、app/page.tsxファイルを作成し、TOPページを実装します。
typescriptCopy code// app/page.tsx
import { getRecentPosts } from "@/features/post/api/getRecentPosts";
import { PostItem } from "@/features/post/components/PostItem";
import { Post } from "@/features/post/types";

export default async function Home() {
  const posts = await getRecentPosts();

  return (
    <div>
      <h1>Latest Posts</h1>
      {posts.edges.map(({ node }: { node: Post }) => (
        <PostItem key={node.slug} post={node} />
      ))}
    </div>
  );
}
オオモリオオモリ

「モジュール '@shadcn/ui' またはそれに対応する型宣言が見つかりません。ts(2307)」というエラーが出た。

} from "@shadcn/ui";となっていた。
正しくは、from "@/components/ui/card";

オオモリオオモリ

こんなエラーがでた。

Error: Invalid src prop (https://soudanbako-inc.com/wp-content/uploads/2023/09/98964-3-0fab532d66514f7643d8c30c1f9d88d7-1280x720-1.webp) on `next/image`, hostname "soudanbako-inc.com" is not configured under images in your `next.config.js`
See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host

エラーメッセージによると、next.config.jsのimages設定でsoudanbako-inc.comホスト名が構成されていないために、next/imageコンポーネントで画像のURLが無効になっているようです。
この問題を解決するには、next.config.jsファイルを開き、imagesのdomains配列にsoudanbako-inc.comを追加します。

オオモリオオモリ

一旦立ち戻って実装戦略を練る。HPで投稿記事データを活用するのは以下のパターン

  • TOPページに新着記事を数個配置する
  • 記事一覧ページ(NEWSページ)
  • 個別投稿ページ
今回実装しないものとしては、以下のものがある
  • カテゴリページ
  • タグページ
  • 関連記事
  • 人気記事

パターンそれぞれに対応するAPIを作成する。

  1. 新着記事を取得するAPI(getRecentPosts)
    • 引数としてlimit(取得する記事の数)とoffset(スキップする記事の数)を受け取る
    • TOPページではoffsetを0にして、limitを任意の値、例えば5にする。
    • NEWSページでは、limitを任意の値、例えば10にして、offsetに現在のページ番号に応じた値を設定(例えば、(currentPage - 1) * limit)。
  2. 個別投稿を取得するAPI(getPostBySlug)
    • 引数にスラッグを受け取り、記事の内容を返す。
    • (公式のドキュメントにはgetPostAndMorePostsとして、関連記事も取得するようになっていたが、今回は割愛。)

これを、事前に決めておいたディレクトリ構造に基づき、
features/post/api/内にそれぞれファイルを作成する。

src/features/post/api/getRecentPosts.tsの内容
// features/post/api/getRecentPosts.ts
import { fetchAPI } from "@/lib/api";

export async function getRecentPosts(limit: number, offset: number) {
  const data = await fetchAPI(
    `
    query RecentPosts($limit: Int!, $offset: Int!) {
      posts(first: $limit, skip: $offset, where: { orderby: { field: DATE, order: DESC } }) {
        edges {
          node {
            title
            excerpt
            slug
            date
            featuredImage {
              node {
                sourceUrl
              }
            }
            author {
              node {
                name
              }
            }
          }
        }
        pageInfo {
          totalCount
          hasNextPage
          hasPreviousPage
        }
      }
    }
  `,
    {
      variables: {
        limit,
        offset,
      },
    }
  );

  return data?.posts;
}

src/features/post/api/getPostBySlug.tsの内容
// features/post/api/getPostAndMorePosts.ts
import { fetchAPI } from "@/lib/api";

export async function getPostBySlug(slug: string) {
  const data = await fetchAPI(
    `
    fragment AuthorFields on User {
      name
      firstName
      lastName
      avatar {
        url
      }
    }
    fragment PostFields on Post {
      title
      excerpt
      slug
      date
      featuredImage {
        node {
          sourceUrl
        }
      }
      author {
        node {
          ...AuthorFields
        }
      }
      categories {
        edges {
          node {
            name
          }
        }
      }
      tags {
        edges {
          node {
            name
          }
        }
      }
    }
    query PostBySlug($slug: ID!) {
      post(id: $slug, idType: SLUG) {
        ...PostFields
        content
      }
    }
  `,
    {
      variables: {
        slug,
      },
    }
  );

  return data?.post;
}

オオモリオオモリ

次に、実装が必要なページとそれを構成するコンポーネントについて考える。
今回は規模感も小さいので、できるだけ簡素な構成にしたい。
コンポーネント化するものには[コンポーネント]とつける。

  1. TOPページ
    1. 新着記事ハイライト
      • 記事リスト[コンポーネント]
        • タイトル[コンポーネント]
        • 日時[コンポーネント]
  2. NEWSページ(新着記事一覧)
    • 記事リスト[コンポーネント]
      • サムネイル[コンポーネント]
      • タイトル[コンポーネント]
      • 日時[コンポーネント]
  3. 個別記事ページ
    • サムネイル[コンポーネント]
    • タイトル[コンポーネント]
    • 本文[コンポーネント]
    • 日時[コンポーネント]

上記をもとに、作成が必要なコンポーネントは以下。

  • 記事リスト(src/features/post/components/PostList.tsx)
  • 記事サムネイル(src/features/post/components/Thumbnail.tsx)
  • 記事タイトル(src/features/post/components/Title.tsx)
  • 記事公開日時(src/features/post/components/PublishDate.tsx)
  • 記事本文(src/features/post/components/Content.tsx)
オオモリオオモリ

各コンポーネントをひとまず実装した。

src/features/post/components/PostList.tsx
// src/features/post/components/PostList.tsx
import { PostType } from "@/features/post/types";
import React from "react";
import PublishDate from "./PublishDate";
import Thumbnail from "./Thumbnail";
import Title from "./Title";

type Props = {
  posts: PostType[];
};

const PostList: React.FC<Props> = ({ posts }) => {
  return (
    <div className="grid gap-10">
      {posts.map((post) => (
        <div key={post.slug} className="group">
          <div className="relative">
            <Thumbnail
              imageUrl={post.featuredImage?.node.sourceUrl}
              alt={post.title}
            />
          </div>
          <div className="mt-4 text-gray-500">
            <PublishDate date={post.date} />
          </div>
          <Title title={post.title} slug={post.slug} />
          <p className="mt-2 line-clamp-3 text-gray-500">{post.excerpt}</p>
        </div>
      ))}
    </div>
  );
};

export default PostList;

src/features/post/components/Thumbnail.tsx
// src/features/post/components/Thumbnail.tsx
import Image from "next/image";
import React from "react";

type Props = {
  imageUrl: string;
  alt: string;
};

const Thumbnail: React.FC<Props> = ({ imageUrl, alt }) => {
  return (
    <Image
      src={imageUrl}
      alt={alt}
      width={500}
      height={300}
      className="rounded-md transition-transform group-hover:scale-105"
    />
  );
};

export default Thumbnail;

src/features/post/components/Title.tsx
// src/features/post/components/Title.tsx
import Link from "next/link";
import React from "react";

type Props = {
  title: string;
  slug: string;
};

const Title: React.FC<Props> = ({ title, slug }) => {
  return (
    <Link href={`/news/${slug}`} className="group-hover:underline">
      <h3 className="font-semibold text-gray-800">{title}</h3>
    </Link>
  );
};

export default Title;

src/features/post/components/PublishDate.tsx
// src/features/post/components/PublishDate.tsx
import { formatDate } from "@/lib/formatDate";
import React from "react";

type Props = {
  date: string;
};

const PublishDate: React.FC<Props> = ({ date }) => {
  return <time dateTime={date}>{formatDate(date)}</time>;
};

export default PublishDate;

src/features/post/components/Content.tsx
// src/features/post/components/Content.tsx
import React from "react";

type Props = {
  content: string;
};

const Content: React.FC<Props> = ({ content }) => {
  return <div dangerouslySetInnerHTML={{ __html: content }} />;
};

export default Content;

ページもひとまず実装した。

src/app/page.tsx(TOPページ)
// app/page.tsx
import { getRecentPosts } from "@/features/post/api/getRecentPosts";
import PostList from "@/features/post/components/PostList";
import { PostEdge, PostType } from "@/features/post/types";

const HomePage = async () => {
  const postsData = await getRecentPosts(5, null);
  const posts: PostType[] = postsData.edges.map((edge: PostEdge) => edge.node);

  return (
    <div>
      <h2 className="text-2xl font-bold mb-4">新着記事</h2>
      <PostList posts={posts} />
    </div>
  );
};

export default HomePage;

src/app/[slug]/page.tsx(個別記事ページ)
// app/news/[slug]/page.tsx
import { getPostBySlug } from "@/features/post/api/getPostBySlug";
import Content from "@/features/post/components/Content";
import PublishDate from "@/features/post/components/PublishDate";
import Thumbnail from "@/features/post/components/Thumbnail";
import Title from "@/features/post/components/Title";

type Props = {
  params: { slug: string };
};

const PostPage = async ({ params }: Props) => {
  const post = await getPostBySlug(params.slug);

  return (
    <div>
      <Thumbnail
        imageUrl={post.featuredImage.node.sourceUrl}
        alt={post.title}
      />
      <Title title={post.title} slug={post.slug} />
      <PublishDate date={post.date} />
      <Content content={post.content} />
    </div>
  );
};

export default PostPage;

src/app/news/page.tsx(NEWSページ)
// app/news/page.tsx
import { getRecentPosts } from "@/features/post/api/getRecentPosts";
import PostList from "@/features/post/components/PostList";
import { PostEdge, PostType } from "@/features/post/types";

const NewsPage = async () => {
  const postsData = await getRecentPosts(10, null);
  const posts: PostType[] = postsData.edges.map((edge: PostEdge) => edge.node);

  return (
    <div>
      <h2 className="text-2xl font-bold mb-4">記事一覧</h2>
      <PostList posts={posts} />
    </div>
  );
};

export default NewsPage;

オオモリオオモリ
src/lib/api.ts (24:11) @ fetchAPI
 ⨯ Error: Failed to fetch API
    at fetchAPI (./src/lib/api.ts:22:15)
    at async getRecentPosts (./src/features/post/api/getRecentPosts.ts:9:18)
    at async HomePage (./src/app/page.tsx:14:23)
digest: "1447891987"
  22 |   if (json.errors) {
  23 |     console.error(json.errors);
> 24 |     throw new Error("Failed to fetch API");
     |           ^
  25 |   }
  26 |   return json.data;
  27 | }
 GET / 500 in 59ms

上記のようなエラーが出た。
クエリが間違っていたようだ。

query RecentPosts($limit: Int!, $offset: Int!) {このように、limitとoffsetを用いて表示させる記事を選対区していたが、
正解はfirstafter

before&after
**before**
    query RecentPosts($limit: Int!, $offset: Int!) {
      posts(first: $limit, skip: $offset, where: { orderby: { field: DATE, order: DESC } }) {
**after**
query RecentPosts($first: Int!, $after: String) {
  posts(first: $first, after: $after, where: {orderby: {field: DATE, order: DESC}}) {
オオモリオオモリ

記事一覧は表示できるようになったが、個別記事が表示されない。
localhost:3000/news/****にアクセスしても
404|This page could not be found.
こう出る。

GraphiQLで確認してみる。 -> 取得できた。なぜ

クエリ
    fragment AuthorFields on User {
      name
      firstName
      lastName
      avatar {
        url
      }
    }
    fragment PostFields on Post {
      title
      excerpt
      slug
      date
      featuredImage {
        node {
          sourceUrl
        }
      }
      author {
        node {
          ...AuthorFields
        }
      }
      categories {
        edges {
          node {
            name
          }
        }
      }
      tags {
        edges {
          node {
            name
          }
        }
      }
    }
    query PostBySlug($slug: ID!) {
      post(id: $slug, idType: SLUG) {
        ...PostFields
        content
      }
    }
バリアブル
{
  "slug": "example-post"
}

A。凡ミス。
app/[slug]/page.tsxにしてしまっていた。
正しくはapp/news/[slug]/page.tsx

オオモリオオモリ

エラー。

Unhandled Runtime Error
Error: Cannot read properties of null (reading 'node')
Source
src/app/news/[slug]/page.tsx (18:38) @ node
Copy code  16 |     <div>
 17 |       <Thumbnail
> 18 |         imageUrl={post.featuredImage.node.sourceUrl}
    |                                      ^
 19 |         alt={post.title}
 20 |       />
 21 |       <Title title={post.title} slug={post.slug} />


サムネイルが存在しない時に表示させようとしているのが問題。

      {post.featuredImage && (
        <Thumbnail
          imageUrl={post.featuredImage.node.sourceUrl}
          alt={post.title}
        />
      )}

このように回避。

オオモリオオモリ

動的ルートを生成した方がいいと言われた。

動的ルートの説明

Next.jsでは、動的ルーティングを使用して、同じテンプレートを使用しながら、異なるパラメータに基づいて複数のページを生成することができます。今回の場合、src/app/news/[slug]/page.tsxというファイル構造を使用しています。ここで、[slug]は動的な部分を表しています。

しかし、Next.jsはデフォルトでは、どのようなslugの値が有効であるかを知ることができません。そのため、ビルド時に有効なslugの値を明示的に指定する必要があります。これを行うのがgenerateStaticParams関数です。

generateStaticParams関数は、有効なslugの値の配列を返します。この関数は、ビルド時に呼び出され、返されたslugの値ごとに個別の記事ページが生成されます。

ここで、getAllPostSlugs関数の役割は、WordPressから全ての記事のslugを取得することです。この関数では、GraphQLのクエリを使用して、WordPressから記事データを取得し、その中からslugの値だけを抽出しています。

以下は、getAllPostSlugs関数の簡単な説明です:

fetchAPI関数を使用して、GraphQLクエリを実行します。
クエリでは、postsフィールドを使用して、最大1000件の記事データを取得します。
取得した記事データから、slugの値だけを抽出します。
slugの値の配列を返します。
generateStaticParams関数では、getAllPostSlugs関数から取得したslugの値の配列を使用して、動的ルートを生成します。具体的には、slugの値ごとに{ slug }という形式のオブジェクトを作成し、それらを配列にまとめて返します。

もしgenerateStaticParams関数を実装しない場合、Next.jsはビルド時に有効なslugの値を知ることができないため、動的ルートが生成されません。その結果、個別の記事ページにアクセスしようとすると、404エラーが発生することになります。

generateStaticParams関数を実装することで、Next.jsはビルド時に有効なslugの値を認識し、それぞれのslugの値に対応する個別の記事ページを事前に生成することができます。これにより、ユーザーがそれらのページにアクセスした際に、すぐにページが表示され、高速で快適なユーザーエクスペリエンスを提供することができるのです。

上記に対する自分なりの解釈。

  • サーバーサイドでレンダリングしたい(SSG)場合、事前にサーバー側でページを用意しておく必要がある。
  • ユーザーが特定の記事、例えばslugAにアクセスしたときに、slugAでクエリを作成しwordpressに問い合わせる方法だと、問い合わせに対してページを生成≒クライアントでページの生成が必要になる。(CSR)
  • サーバーサイドで事前にレンダリングしておくほうがパフォーマンスがいいので、事前に全ての有効なスラッグを取得しページを生成。事前に用意しているもの以外は存在しないものとして扱う。

↑合ってる????

一応動的ルートの実装

src/app/news/[slug]/page.tsx
// src/app/news/[slug]/page.tsx
import { getAllPostSlugs } from "@/features/post/api/getAllPostSlugs";
import { getPostBySlug } from "@/features/post/api/getPostBySlug";
import Content from "@/features/post/components/Content";
import PublishDate from "@/features/post/components/PublishDate";
import Thumbnail from "@/features/post/components/Thumbnail";
import Title from "@/features/post/components/Title";

type Props = {
  params: { slug: string };
};

const PostPage = async ({ params }: Props) => {
  const post = await getPostBySlug(params.slug);

  return (
    <div>
      {post.featuredImage && (
        <Thumbnail
          imageUrl={post.featuredImage.node.sourceUrl}
          alt={post.title}
        />
      )}
      <Title title={post.title} slug={post.slug} />
      <PublishDate date={post.date} />
      <Content content={post.content} />
    </div>
  );
};

export default PostPage;

export async function generateStaticParams() {
  const slugs = await getAllPostSlugs();

  return slugs.map(({ slug }) => ({
    slug,
  }));
}
src/features/post/api/getAllPostSlugs.ts
import { fetchAPI } from "@/lib/api";

export type PostSlug = {
  slug: string;
};

export async function getAllPostSlugs(): Promise<PostSlug[]> {
  const data = await fetchAPI(`
    {
      posts(first: 10000) {
        edges {
          node {
            slug
          }
        }
      }
    }
  `);

  return data?.posts.edges.map(({ node }: { node: PostSlug }) => ({
    slug: node.slug,
  }));
}
オオモリオオモリ

存在しないslugでページを表示しようとした場合に404などを表示させるようにしたい。

AIの回答

存在しないslugでページを表示しようとした場合に404などを表示させるようにしたい。存在しないslugでページを表示しようとした場合に、404エラーページを表示するには、src/app/news/[slug]/page.tsxファイルを以下のように修正します。
typescriptCopy code// src/app/news/[slug]/page.tsx
import { getAllPostSlugs } from "@/features/post/api/getAllPostSlugs";
import { getPostBySlug } from "@/features/post/api/getPostBySlug";
import Content from "@/features/post/components/Content";
import PublishDate from "@/features/post/components/PublishDate";
import Thumbnail from "@/features/post/components/Thumbnail";
import Title from "@/features/post/components/Title";
import { notFound } from "next/navigation";

type Props = {
params: { slug: string };
};

const PostPage = async ({ params }: Props) => {
const post = await getPostBySlug(params.slug);

if (!post) {
notFound();
}

return (
<div>
{post.featuredImage && (
<Thumbnail
imageUrl={post.featuredImage.node.sourceUrl}
alt={post.title}
/>
)}
<Title title={post.title} slug={post.slug} />
<PublishDate date={post.date} />
<Content content={post.content} />
</div>
);
};

export default PostPage;

export async function generateStaticParams() {
const slugs = await getAllPostSlugs();

return slugs.map(({ slug }) => ({
slug,
}));
}
ここでは、以下の変更を行いました:

next/navigationからnotFound関数をインポートしました。
getPostBySlug関数の戻り値をpost変数に代入した後、if (!post) { notFound(); }という条件分岐を追加しました。

getPostBySlug関数は、指定されたslugに対応する記事データを返します。もし、指定されたslugに対応する記事が存在しない場合、post変数にはundefinedが代入されます。
if (!post) { notFound(); }という条件分岐では、postがfalsyな値(undefined、null、falseなど)の場合に、notFound関数を呼び出しています。notFound関数は、Next.jsの組み込み関数で、404エラーページをレンダリングするために使用されます。
これにより、存在しないslugが指定された場合、自動的に404エラーページが表示されるようになります。
ビルドし直して、存在しないslugを指定してページにアクセスしてみてください。404エラーページが表示されれば、正しく実装できています。

オオモリオオモリ

NEWSページでのページネーションを実装する。

  • src/features/post/api/getRecentPosts.tsを修正
内容
// src/features/post/api/getRecentPosts.ts
import { fetchAPI } from "@/lib/api";
import { PostsData } from "../types";

export async function getRecentPosts(
  first: number,
  after: string | null
): Promise<PostsData> {
  const data = await fetchAPI(
    `
    query RecentPosts($first: Int!, $after: String) {
      posts(first: $first, after: $after) {
        edges {
          node {
            title
            excerpt
            slug
            date
            featuredImage {
              node {
                sourceUrl
              }
            }
            author {
              node {
                name
              }
            }
          }
        }
        pageInfo {
          hasNextPage
          endCursor
        }
      }
    }
  `,
    {
      variables: {
        first,
        after,
      },
    }
  );

  return data?.posts;
}
  • app/news/page.tsxを以下のように修正
内容
// app/news/page.tsx
import { getRecentPosts } from "@/features/post/api/getRecentPosts";
import PostList from "@/features/post/components/PostList";
import { Post, PostEdge } from "@/features/post/types";
import { Button } from "@/components/ui/button";
import { useState } from "react";

const NewsPage = async () => {
  const [posts, setPosts] = useState<Post[]>([]);
  const [after, setAfter] = useState<string | null>(null);
  const [hasNextPage, setHasNextPage] = useState(true);

  const loadMore = async () => {
    const postsData = await getRecentPosts(10, after);
    setPosts([...posts, ...postsData.edges.map((edge: PostEdge) => edge.node)]);
    setAfter(postsData.pageInfo.endCursor);
    setHasNextPage(postsData.pageInfo.hasNextPage);
  };

  if (posts.length === 0) {
    const initialPostsData = await getRecentPosts(10, null);
    setPosts(initialPostsData.edges.map((edge: PostEdge) => edge.node));
    setAfter(initialPostsData.pageInfo.endCursor);
    setHasNextPage(initialPostsData.pageInfo.hasNextPage);
  }

  return (
    <div className="container mx-auto px-4 py-8">
      <h2 className="text-2xl font-bold mb-4">記事一覧</h2>
      <PostList posts={posts} />
      {hasNextPage && (
        <div className="mt-8 flex justify-center">
          <Button onClick={loadMore}>もっと見る</Button>
        </div>
      )}
    </div>
  );
};

export default NewsPage;

app/news/page.tsxがクライアントサイドじゃないと動かないぞと怒られたので、'use client'を文頭につける。
→クライアントコンポーネント内で非同期処理を行ったためエラーが発生。クライアントコンポーネント内で直接非同期処理を行うことがサポートされていないため。
→useEffect フックを使用して、コンポーネントのマウント時に初期の記事を取得するように

修正後のapp/news/page.tsx
// app/news/page.tsx
'use client';

import { useEffect, useState } from 'react';
import { getRecentPosts } from '@/features/post/api/getRecentPosts';
import PostList from '@/features/post/components/PostList';
import { Post, PostEdge } from '@/features/post/types';
import { Button } from '@/components/ui/button';

const NewsPage = () => {
  const [posts, setPosts] = useState<Post[]>([]);
  const [after, setAfter] = useState<string | null>(null);
  const [hasNextPage, setHasNextPage] = useState(true);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchInitialPosts = async () => {
      try {
        setIsLoading(true);
        const initialPostsData = await getRecentPosts(10, null);
        setPosts(initialPostsData.edges.map((edge: PostEdge) => edge.node));
        setAfter(initialPostsData.pageInfo.endCursor);
        setHasNextPage(initialPostsData.pageInfo.hasNextPage);
      } catch (error) {
        setError('Failed to fetch posts.');
      } finally {
        setIsLoading(false);
      }
    };

    fetchInitialPosts();
  }, []);

  const loadMore = async () => {
    try {
      setIsLoading(true);
      const postsData = await getRecentPosts(10, after);
      setPosts([...posts, ...postsData.edges.map((edge: PostEdge) => edge.node)]);
      setAfter(postsData.pageInfo.endCursor);
      setHasNextPage(postsData.pageInfo.hasNextPage);
    } catch (error) {
      setError('Failed to fetch more posts.');
    } finally {
      setIsLoading(false);
    }
  };

  if (error) {
    return <div className="text-red-500">{error}</div>;
  }

  return (
    <div className="container mx-auto px-4 py-8">
      <h2 className="text-2xl font-bold mb-4">記事一覧</h2>
      {isLoading ? (
        <div>Loading...</div>
      ) : (
        <>
          <PostList posts={posts} />
          {hasNextPage && (
            <div className="mt-8 flex justify-center">
              <Button onClick={loadMore} disabled={isLoading}>
                {isLoading ? 'Loading...' : 'もっと見る'}
              </Button>
            </div>
          )}
        </>
      )}
    </div>
  );
};

export default NewsPage;
オオモリオオモリ

Failed to fetch posts.というエラーが出た。
getRecentPosts 関数内で使用されているGraphQLクエリに問題がありそう。

クライアントサイドだと、.envでは環境変数を認識できないことが原因だった。
→next.config.jsファイルを以下のように修正します:

module.exports = {
  env: {
    WORDPRESS_API_URL: process.env.NEXT_PUBLIC_WORDPRESS_API_URL,
  },
};

これはセキュリティ上の理由からです。クライアントサイドのコードは、ユーザーのブラウザ上で実行されるため、.envファイルの内容がユーザーに公開されてしまう可能性があります。

オオモリオオモリ

app/news/page.tsxで表示が激烈に遅かった。
graphQLとの通信が4回、全て800msのものが発生している。

以下対応。

  1. app/news/page.tsxで表示が遅い原因は、GraphQLとの通信が複数回発生しているためです。これを最適化するために以下の手順を行います。
  2. サーバーコンポーネントでデータを事前に取得する:getRecentPostsを使ってサーバー側でデータを取得し、初期データを読み込みます。
  3. クライアントコンポーネントでページネーションを行う:クライアントコンポーネントで追加の投稿をフェッチし、ページネーションを行います。

以下実装

src/features/post/api/getRecentPosts.ts
import { fetchAPI } from "@/lib/api";
import { PostsData } from "@/features/post/types";

export async function getRecentPosts(
  first: number,
  after: string | null = null
): Promise<PostsData> {
  const query = `
    query GetRecentPosts($first: Int!, $after: String) {
      posts(first: $first, after: $after) {
        edges {
          node {
            title
            excerpt
            slug
            date
            content
            featuredImage {
              node {
                sourceUrl
              }
            }
            author {
              node {
                name
              }
            }
          }
        }
        pageInfo {
          hasNextPage
          endCursor
        }
      }
    }
  `;

  const variables = { first, after };

  const data = await fetchAPI(query, variables);

  return data.posts;
}
app/news/page.tsx
// app/news/page.tsx
import { Suspense } from "react";
import { getRecentPosts } from "@/features/post/api/getRecentPosts";
import NewsPageContent from "@/features/post/components/NewsPageContent";
import Loading from "@/app/loading";

export default async function NewsPage() {
  const initialPostsData = await getRecentPosts(10, null);
  const initialPosts = initialPostsData.edges.map((edge) => edge.node);
  const pageInfo = initialPostsData.pageInfo;

  return (
    <div className="container mx-auto px-4 py-8">
      <h2 className="text-2xl font-bold mb-4">記事一覧</h2>
      <Suspense fallback={<Loading />}>
        <NewsPageContent initialPosts={initialPosts} initialPageInfo={pageInfo} />
      </Suspense>
    </div>
  );
}
src/features/post/components/NewsPageContent.tsx
"use client";

import { Button } from "@/components/ui/button";
import PostList from "@/features/post/components/PostList";
import { Post, PostEdge, PostsData } from "@/features/post/types";
import { useEffect, useState } from "react";
import { getRecentPosts } from "@/features/post/api/getRecentPosts";

interface NewsPageContentProps {
  initialPosts: Post[];
  initialPageInfo: {
    hasNextPage: boolean;
    endCursor: string;
  };
}

const NewsPageContent = ({ initialPosts, initialPageInfo }: NewsPageContentProps) => {
  const [posts, setPosts] = useState<Post[]>(initialPosts);
  const [after, setAfter] = useState<string | null>(initialPageInfo.endCursor);
  const [hasNextPage, setHasNextPage] = useState(initialPageInfo.hasNextPage);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const loadMore = async () => {
    try {
      setIsLoading(true);
      const postsData: PostsData = await getRecentPosts(10, after);
      setPosts([
        ...posts,
        ...postsData.edges.map((edge: PostEdge) => edge.node),
      ]);
      setAfter(postsData.pageInfo.endCursor);
      setHasNextPage(postsData.pageInfo.hasNextPage);
    } catch (error) {
      setError("Failed to fetch more posts.");
    } finally {
      setIsLoading(false);
    }
  };

  if (error) {
    return <div className="text-red-500">{error}</div>;
  }

  return (
    <>
      <PostList posts={posts} />
      {hasNextPage && (
        <div className="mt-8 flex justify-center">
          <Button onClick={loadMore} disabled={isLoading}>
            {isLoading ? "Loading..." : "もっと見る"}
          </Button>
        </div>
      )}
    </>
  );
};

export default NewsPageContent;
loading.tsx
// app/loading.tsx
export default function Loading() {
  return <div>Loading...</div>;
}
オオモリオオモリ

・追加読み込みが遅いような気がしましたが、最適化されていますか?
・ページネーションは、1 ,2 3 .... 19(最後のページ) のような数字でページを切り替えられるようにしたい
・概要部分にhtmlタグが入ってしまっているので入らないようにしたい。
・そのほか、あなたが改善した方がいいと思って改善できる部分があれば全て
上記を元に、さらに改善。

改善内容

  1. 追加読み込みの最適化:hasNextPageで次のページがあるかどうか確認し、無駄なリクエストを避けます。isLoadingで同時に複数のリクエストが行われないようにします。
  2. ページネーションの導入:currentPageとtotalPagesを利用して、ページネーションを構築します。getRecentPostsにページ番号を渡してデータを取得します。
  3. 概要部分のHTMLタグの除去:excerptを表示する際にHTMLタグを除去します。
    4.その他の改善点:タイポやクラス名の見直し。ローディング中の表示をよりユーザーフレンドリーにする。
オオモリオオモリ

現状のままだと、wordpressで記事を更新してもWEBサイトに反映されないので、ISRを実装する。

  • next.config.jsでISRの設定を行います。
  • revalidateを使用し、記事一覧と個別記事ページでISRを適用します。
next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  images: {
    domains: ['your-wordpress-domain.com'], // WordPressの画像ドメインを指定
  },
};

module.exports = nextConfig;

など...

オオモリオオモリ

ここまでやっておきつつ、ちょっと果てなかったので、一旦休止。