303
326

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【初心者完全版】0からNext.js開発して2時間で基礎をマスターする最強チュートリアル【図解解説】

303
Last updated at Posted at 2026-04-05

IMG_0088.jpg

はじめに

こんにちは、Watanabe jinです。
AIが発展して多くの人がReactでアプリを作っているのをみてすごい時代だなと改めて実感しております。

しかし、AIがReactを(綺麗に)書くのは課題が多く難しいというのが私の感覚です。
Reactを書こうとするとなんでもかんでもuseEffectで解決しようとするし、Next.jsはクライアントとサーバーの境界を理解するのが苦手です。

何も考えずに簡単なアプリケーションを作るのは誰もができる世界になりましたが、規模感があるアプリを作るにはAIだけでなくエンジニアのスキルはまだまだ必要です。

AIで自分以上の出力は出せない

というのが私の中での結論です。

今回はNext.jsを0から網羅的に解説していきますが、例えばReact 19から登場した「Server Action」を知らなかったらクライアントサイドからサーバーの関数を呼び出せずに無理やりコードをAIが書くことになります。

AIを使いこなすためにも基礎をしっかりと身につけるのは大切です。
基礎をしっかりと理解して良いコードが何かを理解していることがよりAIを覚醒させることにつながります。

今回はこのような技術ブログサイトを開発していきます。

image.png

記事の最後に「プログラミング経験0からNext.jsを学ぶまでのロードマップ」についても話しているので楽しみにしてください。

動画教材も用意しています

こちらの教材にはより詳しく解説した動画もセットでご用意しています。
テキスト教材でわからない細かい箇所があれば動画も活用ください。

この記事の対象者

  • 最短でNext.jsを学びたい
  • Next.jsの必要な知識を網羅的に学びたい
  • アウトプット中心で学びたい
  • しっかりと実力をつけたい
  • Reactをなんとなく理解している

この記事はReactを一度でも学んだことがある方むけになっています。
JSXやuseStateなど基本的な解説は省略していますので、そこから学びたい方は以下のチュートリアルを進めてください。

Next.jsとは?

Next.jsは、Reactアプリケーションを作るときに従来は個別にインストール・設定していた様々なライブラリやツールを、最初から統合して提供するフルスタックフレームワークです。

従来のReactアプリ開発では、こんな感じで色々追加する必要がありました。

  • ルーティング → react-router
  • SEO対策 → react-helmet
  • ビルドツール → Webpack、Viteの設定
  • サーバーサイドレンダリング → Next.js等のフレームワーク
  • APIサーバー → Express.jsなど別途構築
  • 画像最適化 → 別のライブラリやツール

Next.jsなら、これらの多くが最初から組み込まれている、もしくは公式に最適化された形で提供されているので、設定に悩むことなくすぐに開発を始められます!

さらに、フロントエンドだけでなくバックエンド(API Routes)も同じプロジェクトで書けるため、「フルスタック」フレームワークと呼ばれています。

image.png

Reactの書き方は変わらないまま、プロダクション品質のアプリケーションを効率的に構築できる、いわばReactのオールインワンパッケージです。

その中でも以下の機能をアプリ開発の中で体験しながら特徴を深く理解していきます。

image.png

詳しくはチュートリアルの中で解説するので一通り学べるんだなくらいの認識で大丈夫です。

1. Next.jsの環境構築

まずは環境構築をしていきます。
Node.jsがあるかを確かめます。もしエラーになる場合は調べてインストールしてください。

node -v
v24.1.0

Next.jsの環境構築は公式ドキュメントに載っているコマンドで簡単に作れます。

npx create-next-app@latest tech-blog --yes

Need to install the following packages:
create-next-app@16.1.6
Ok to proceed? (y) y

注意:バージョンについて
本チュートリアルでは create-next-app@16.x(Next.js 16)を使用しています。
コード例や設定は Next.js 16 を前提としています。Next.js 15 を使う場合は後述の設定の違いに注意してください。

プロジェクトが作成できたら試しにサーバーを起動してみます。

cd tech-blog
npm run dev

http://localhost:3000 を開いて以下の画面がでれば成功です。

image.png

Next.jsではデフォルトでTailwindCSSもはいっているため、最後のスタイリングでは利用します。

2. ルーティングを設定しよう

次にApp Routerを利用してルーティングを行います。
今回は以下のページを作成します。

パス ページ名 ページの内容 ページイメージ
/ トップページ オリジナル記事とQiitaの記事が表示されます image.png
/qiita Qiitaの記事一覧 Qiitaの記事が20件表示されます image.png
/blogs オリジナル記事一覧 オリジナル記事が全て表示されます image.png
/blogs/:id オリジナル記事詳細 IDに対応した記事の詳細が表示されます image.png

App RouterとはNext.js 13以降で導入された新しいルーティングシステムです。
従来のPages Routerと比べて、ファイルベースルーティングの概念はそのままに、より柔軟で強力な機能が追加されました。

App Routerの最大の特徴は、appディレクトリ内のフォルダ構造がそのままURLパスになることです。さらに、Server Component(後述)がデフォルトで使用されるため、パフォーマンスとSEOに優れたアプリケーションを簡単に構築できます。複数ページで共通のレイアウトを効率的に管理できる点も大きなメリットです。

App Routerでは、appディレクトリ内のフォルダ構造が非常に重要になります。フォルダの名前がそのままURLのパスになり、その中に配置する特定のファイル名によってページの役割が決まります。

app/
├── page.tsx          # / (トップページ)
├── qiita/
│   └── page.tsx      # /qiita
├── blogs/
│   ├── page.tsx      # /blogs
│   └── [id]/
│       └── page.tsx  # /blogs/:id (動的ルート)
└── layout.tsx        # 全ページ共通のレイアウト

重要なファイル名として、page.tsxはそのルートのメインコンテンツを定義します。
これは必須のファイルで、このファイルがないとそのルートはアクセスできません。

layout.tsxは子ルートと共有するレイアウトを定義するファイルです。
また、[id]のように角括弧で囲まれたフォルダ名は動的なパラメータを表します。

実際にファイルを作って理解していきましょう。

app/page.tsx
export default function Home() {
  return (
    <div>
      <h1>Topページ</h1>
    </div>
  )
}

http://localhost:3000 にアクセスするとTOPが表示されました。

image.png

次にQiita一覧ページを作ります。

mkdir app/qiita
touch app/qiita/page.tsx
app/qiita/page.tsx
export default function Qiita() {
  return (
    <div>
      <h1>Qiitaページ</h1>
    </div>
  )
}

http://localhost:3000/qiita にアクセスします。

image.png

同じ要領でオリジナルブログ一覧を作ります。

mkdir app/blogs
touch app/blogs/page.tsx
app/blogs/page.tsx
export default function Blogs() {
  return (
    <div>
      <h1>Blogsページ</h1>
    </div>
  )
}

http://localhost:3000/blogs にアクセスします。

image.png

最後にブログ詳細ページですが、ここは動的なページとなります。
記事の詳細ページでは、URLに含まれる記事IDによって表示内容を変える必要があります。

例えば/blogs/1/blogs/2といった具合です。このような動的なパスを実現するために、Next.jsでは角括弧を使った特別な記法を使用します。

app/blogsフォルダの中に[id]という名前のフォルダを作成し、その中にpage.tsxを作成します。

mkdir app/blogs/\[id\]
touch app/blogs/\[id\]/page.tsx
app/blogs/[id]/page.tsx
async function BlogContent({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return (
    <div>
      <h1>BlogDetailページ</h1>
      <p>ID: {id}</p>
    </div>
  );
}

export default function BlogDetail({ params }: { params: Promise<{ id: string }> }) {
  return <BlogContent params={params} />;
}

この[id]というフォルダ名が動的なパラメータを表しています。

ユーザーが/blogs/123にアクセスすると、自動的にparams{ id: "123" }が格納されます。

http://localhost:3000/blogs/123 にアクセスします。

image.png

この仕組みを使って、どんな記事IDでも対応できるページを作ることができるのです。

コードについても解説します。
まず、関数にasyncキーワードが付いていることです。

async function BlogContent({ params }: { params: Promise<{ id: string }> })

これはServer Componentの大きな特徴で、非同期処理を直接コンポーネント内で実行できます。

Server Componentとは、Server Componentとは、サーバー上でのみ実行され、HTMLとして完成した状態でブラウザに送られるReactコンポーネントのことです。

image.png

RSC(React Server Components)が登場する以前のReactアプリ(Create React Appなど)では、すべてのコンポーネントがブラウザ上で実行されていました。サーバーからはほぼ空のHTMLが送られ、JavaScriptをダウンロード・実行することで初めてページが組み立てられます。これがCSR(クライアントサイドレンダリング)です。
一方、Server Componentはサーバー上でのみ実行されるため、useStateやuseEffectといったブラウザ依存のAPIは使えませんが、サーバー上でデータ取得を完結させてからHTMLを生成してブラウザに送ることができます。これにより初期表示が速く、JavaScriptのバンドルサイズも小さくなります。

なお、"use client"を付けたClient ComponentはSSR/SSGと組み合わせて使われることが多く、「Client Component = CSR」ではありません。CSR・SSR・SSGはあくまでレンダリング戦略であり、Server Component / Client Componentはコンポーネントの実行場所を表す別の概念です。

次に、paramsがPromise型になっている点です。

const { id } = await params;

Next.js 15以降では、動的ルートのパラメータは非同期で取得する必要があるため、await paramsとして受け取ります。そこから分割代入でidを取り出しています。

3. SSRでトップページを作ろう

まずはトップページを作成します。
QiitaとMicroCMSの記事を取得して4つずつ表示をします。

Next.jsにはページのレンダリング戦略がいくつか存在しますが、サーバーサイドレンダリング(SSR) でページを作成していきます。

Next.jsのレンダリング戦略について

トップページを実装する前にNext.jsで利用できるレンダリング方式について解説します。

1. クライアントサイドレンダリング(CSR)

image.png

クライアントサイドレンダリングは、従来のReactアプリケーションで一般的に使われてきた手法です。サーバーからは最小限のHTMLだけが送られ、ブラウザ上でJavaScriptが実行されることで初めてページの内容が構築されます。

この方式では、ユーザーがページにアクセスすると、まずほぼ空のHTMLファイルがダウンロードされます。その後、Reactのコードを含むJavaScriptファイルがダウンロードされ、ブラウザ上で実行されることでページが組み立てられていきます。TypeScriptで書かれたコードはビルド時にJavaScriptに変換され、最終的にはHTMLとJavaScriptのファイルとして配信されます。

この方式の大きな特徴は、初回読み込み後のページ遷移が非常に高速になることです。一度JavaScriptがダウンロードされてしまえば、別のページに移動する際も新しいHTMLをサーバーから取得する必要がなく、必要なデータだけをAPIから取得してページを更新できます。

また、ユーザーの操作に応じて動的にUIを変更することが得意で、リアルタイムな更新が必要なダッシュボードやチャットアプリケーションなどに適しています。

ただし、初期表示が遅くなりやすいという欠点があります。 JavaScriptのダウンロードと実行が完了するまでユーザーは何も見ることができません。また、検索エンジンのクローラーがページにアクセスした時点ではまだHTMLがほぼ空の状態のため、SEOの観点でも不利になります。

Next.jsでクライアントサイドレンダリングを使用する場合は、コンポーネントの先頭に"use client"ディレクティブを記述します。

"use client"

import { useState, useEffect } from 'react'

export default function ClientPage() {
  const [data, setData] = useState(null)
  
  useEffect(() => {
    fetch('/api/data')
      .then(res => res.json())
      .then(setData)
  }, [])
  
  return <div>{data ? data.title : 'Loading...'}</div>
}

この例では、コンポーネントがマウントされた後にデータをフェッチし、取得できたら画面に表示します。

従来のReactと同じ書き方ですが、Next.jsでは明示的に "use client" を宣言する必要があります。

補足:"use client" = 完全なCSRではない
CSR・SSR・SSGは「どこでHTMLを生成するか」というレンダリング戦略です。一方、Server Component / Client Componentは「コンポーネントがどこで実行されるか」を表すReactのコンポーネント種別であり、独立した概念です。"use client"を付けたClient ComponentはSSRと組み合わせることが多く、「Client Component = CSR」ではありません。

2. サーバーサイドレンダリング(SSR)

image.png

サーバーサイドレンダリングは、ユーザーがページにアクセスするたびに、サーバー上でHTMLを生成してブラウザに送る方式です。アクセスのたびにサーバーで記事データをAPIから取得し、そのデータを使ってHTMLを完成させてから配信します。

この方式の最大の利点は、ブラウザが受け取る時点で既に完成したHTMLが届くことです。ユーザーは即座にコンテンツを閲覧できますし、検索エンジンのクローラーも完全なHTMLを読み取れるためSEOに有利です。また、常に最新のデータでページが生成されるため、頻繁に更新されるコンテンツに適しています。

一方で、アクセスのたびにサーバーで処理が走るため、サーバーの負荷が高くなります。APIの呼び出しに時間がかかると、ページ全体の表示も遅れてしまいます。

async function getArticles() {
  const res = await fetch('https://api.example.com/articles', {
    cache: 'no-store' // キャッシュを使わず、常に最新データを取得
  })
  return res.json()
}

export default async function ArticlesPage() {
  const articles = await getArticles()
  
  return (
    <div>
      <h1>記事一覧</h1>
      {articles.map(article => (
        <div key={article.id}>{article.title}</div>
      ))}
    </div>
  )
}

この例では、コンポーネント自体をasync関数として定義し、その中で直接データ取得を行っています。
従来のReactではuseEffectの中でデータ取得を行っていましたが、Server ComponentではuseEffectは使えません。代わりに、コンポーネント関数自体を非同期にして、その中でデータ取得を行います。

3. スタティックサイトジェネレーション(SSG)

image.png

スタティックサイトジェネレーションは、ビルド時に一度だけHTMLを生成し、それを事前に用意しておく方式です。ビルド時に記事データをAPIから取得し、HTMLファイルを生成します。その後、どれだけアクセスがあっても事前に用意したHTMLを返すだけなので、APIを都度呼ぶ必要がありません。

この方式の最大の利点は、非常に高速であることです。
既に完成したHTMLファイルを配信するだけなので、サーバーの処理時間がほぼゼロになります。CDN(コンテンツ配信ネットワーク)との相性も良く、世界中のユーザーに高速にコンテンツを届けられます。

ただし、内容の更新には再ビルドが必要です。
記事を追加したり更新したりした場合、その変更を反映させるにはアプリケーション全体を再ビルドしてデプロイする必要があります。そのため、頻繁に更新されるコンテンツには向いていません。

Next.js のApp Routerでは、fetchにcache: 'force-cache'を指定することでSSGを実現できます。

async function getDocs() {
  const res = await fetch('https://api.example.com/docs', {
    cache: 'force-cache' // ビルド時にキャッシュされる
  })
  return res.json()
}

また、動的ルート([id]など)でSSGを使う場合は、generateStaticParams関数でビルド時に生成するページを指定します。

export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts')
    .then(res => res.json())
  
  return posts.map((post: { id: string }) => ({
    id: post.id
  }))
}

export default async function BlogPost({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const post = await fetch(`https://api.example.com/posts/${id}`)
    .then(res => res.json())
  
  return <article>{post.content}</article>
}

4. インクリメンタルスタティックリジェネレーション(ISR)

image.png

インクリメンタルスタティックリジェネレーションは、SSGとSSRの良いとこ取りをした方式です。ビルド時に一度HTMLを生成するのはSSGと同じですが、一定時間が経過したらアクセス時に再度HTMLを生成し直します。

具体的には、revalidateという設定で「60秒」と指定した場合、最初のアクセス時にはビルド時に生成されたHTMLが返されます。その後60秒間は同じHTMLが使われますが、60秒経過後の次のアクセス時にバックグラウンドで新しいHTMLの生成が開始されます。

async function getNews() {
  const res = await fetch('https://api.example.com/news', {
    next: { revalidate: 60 } // 60秒ごとに再検証
  })
  return res.json()
}

5. パーシャルプリレンダリング(PPR)

image.png

パーシャルプリレンダリングは、Next.js 14以降で導入された比較的新しい機能で、一つのページの中で静的な部分と動的な部分を組み合わせることができます。

ページコンテンツを静的部分と動的部分に分割し、静的なコンテンツは事前にレンダリングされて初回アクセス時に即座に提供されます。一方、動的なコンテンツは必要に応じてストリーミングされ、ページの読み込み中でも表示できる部分から徐々に表示されていきます。

Next.jsでPPRを使うには、Suspenseコンポーネントと組み合わせます。

import { Suspense } from 'react'

async function Comments() {
  const comments = await fetch('https://api.example.com/latest-comments', {
    cache: 'no-store'
  }).then(res => res.json())
  
  return (
    <ul>
      {comments.map((c: any) => <li key={c.id}>{c.text}</li>)}
    </ul>
  )
}

export default function Page() {
  return (
    <main>
      {/* 静的な部分:ビルド時に生成され、ユーザーがアクセスした瞬間に表示される */}
      <h1>最新のユーザーコメント</h1>
      <p>ここは静的シェルとして、一瞬で画面に表示されます。</p>
      <hr />

      {/* 動的な部分:静的部分が表示された後、裏でデータを取得して完了次第表示される */}
      <Suspense fallback={<div>コメントを読み込み中...</div>}>
        <Comments />
      </Suspense>
    </main>
  )
}

レンダリングの使い分け

image.png

これらのレンダリング方式にはそれぞれ明確な用途があります。

CSRは、ユーザーの操作に応じてリアルタイムに変化するUI(ダッシュボード、チャット、管理画面)や、SEOが重要でないページ(ログイン後の画面など)に適しています。

SSRは、常に最新の情報を表示する必要があるページに適しています。ニュースサイトやSNSのタイムライン、在庫情報など、SEOが重要で頻繁に更新されるコンテンツに最適です。

SSGは、更新頻度が低い静的なコンテンツに最適です。会社概要ページ、利用規約、ドキュメントサイトなどが該当します。最も高速でサーバー負荷も最小限です。

ISRは、定期的に更新されるがリアルタイム性はそれほど求められないコンテンツに向いています。商品ページや天気予報など、SSGの速さとSSRの鮮度のバランスを取りたい場合に最適です。

PPRは、一つのページの中に静的な部分と動的な部分が混在する場合に有効です。記事本文は静的だがコメントは動的、商品情報は静的だが在庫数は動的、といったケースで力を発揮します。

今回のトップページでは、QiitaとMicroCMSから記事を取得して表示するため、常に最新の記事を表示したいという要件があります。そのため、SSRを採用します。

Qiita記事を表示しよう

まずはQiitaの記事を取得するためにアクセスキーを取得します。
Qiitaの画面から「ユーザー」→「設定」をクリック

image.png

左メニューの「アプリケーション」→「新しいトークンを発行する」をクリック

image.png

アクセストークンの説明に「テックブログアプリ」と入力して「発行する」をクリック

image.png

発行されたアクセストークンをクリップボードにコピーします。

image.png

環境変数を.envに設定します。

touch .env
.env
QIITA_API_KEY=コピーしたトークン

アクセスキーは秘密情報なのでGitHubにPushされないようにします。
create-next-appが生成した.gitignoreには既に.env*が含まれていますので、基本的にはこの手順は不要ですが、念のため確認しておくと良いでしょう。.gitignoreに追記したい場合は上書きにならないよう>>(追記)を使用してください。

echo .env >> .gitignore

それでは実際に記事を取得してみましょう。

npm i axios
app/page.tsx
import axios from "axios";

type QiitaResponse = {
  id: string;
  title: string;
  url: string;
  image: string;
}

export default async function Home() {

  const getQiitaItems = async () => {
    const response = await axios.get<QiitaResponse[]>(
      "https://qiita.com/api/v2/items?query=user:Sicut_study&per_page=4",
      {
        headers: {
          "Authorization": `Bearer ${process.env.QIITA_API_KEY}`
        }
      }
    );
    return response.data;
  }

  const qiitaItems = await getQiitaItems();

  return (
    <div>
      <h1>Topページ</h1>
      <ul>
        {qiitaItems.map((item) => (
          <li key={item.id}>{item.title}</li>
        ))}
      </ul>
    </div>
  )
}

image.png

4つの記事が取得できれば成功です。実装したコードを詳しく解説していきます。

まず、Qiita APIから返ってくるデータの型を定義しています。

type QiitaResponse = {
  id: string;
  title: string;
  url: string;
  image: string;
}

TypeScriptでは、このように型を明示的に定義することで、コードの安全性が高まり、開発中に間違いに気づきやすくなります。実際のQiita APIのレスポンスにはもっと多くのフィールドがありますが、今回のアプリケーションで使用する項目だけを定義しています。

次に、Server Componentでの非同期処理についてです。

export default async function Home() {

このコンポーネントにasyncキーワードが付いていることに注目してください。これがServer Componentの大きな特徴で、コンポーネント内で直接awaitを使ってデータ取得ができます。従来のReactのようにuseEffectフックを使う必要がありません。

const getQiitaItems = async () => {
  const response = await axios.get<QiitaResponse[]>(
    "https://qiita.com/api/v2/items?query=user:Sicut_study&per_page=4",
    {
      headers: {
        "Authorization": `Bearer ${process.env.QIITA_API_KEY}`
      }
    }
  );
  return response.data;
}

process.env.QIITA_API_KEYで環境変数にアクセスしています。.envファイルに設定した値は、Next.jsによって自動的にprocess.envオブジェクトに読み込まれます。

本番ではエラーハンドリングを入れましょう
このチュートリアルではコードを簡潔に保つためtry/catchを省略していますが、本番アプリケーションではAPI呼び出しに適切なエラーハンドリングを追加することを強くお勧めします。

それではQiitaのOGP画像も表示しましょう。
QiitaにはOGP画像を手軽に取れる仕組みがないため、ここでは固定の画像を使います。

app/page.tsx
import axios from "axios";
import Image from "next/image";

type QiitaResponse = {
  id: string;
  title: string;
  url: string;
  image: string;
}

export default async function Home() {

  const getQiitaItems = async () => {
    const response = await axios.get<QiitaResponse[]>(
      "https://qiita.com/api/v2/items?query=user:Sicut_study&per_page=4",
      {
        headers: {
          "Authorization": `Bearer ${process.env.QIITA_API_KEY}`
        }
      }
    );
    return response.data.map((item) => ({
      id: item.id,
      title: item.title,
      url: item.url,
      image: "https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F810513%2F04c6ef92-7b08-467f-95b0-efd05a0e7ea4.png?ixlib=rb-4.0.0&auto=format&gif-q=60&q=75&w=1400&fit=max&s=255a4084e07534dc5871b77aa1318d0e"
    }));
  }

  const qiitaItems = await getQiitaItems();

  return (
    <div>
      <h1>Topページ</h1>
      <ul>
        {qiitaItems.map((item) => (
          <li key={item.id}>
            <Image src={item.image} alt={item.title} width={100} height={100} />
            <a href={item.url} target="_blank" rel="noopener noreferrer">{item.title}</a>
          </li>
        ))}
      </ul>
    </div>
  )
}

Next.jsには、通常のHTMLの<img>タグの代わりに使えるImageコンポーネントが用意されています。このコンポーネントを使うことで、画像の最適化やパフォーマンス向上を自動的に行ってくれます。

<Image src={item.image} alt={item.title} width={100} height={100} />

Imageコンポーネントには必須のプロパティがいくつかあります。srcは画像のURL、altは画像の説明文(アクセシビリティやSEOに重要)、そしてwidthheightは画像の幅と高さをピクセル単位で指定します。主な利点として、WebPなどの次世代フォーマットへの自動変換、遅延読み込み(Lazy Loading)のデフォルト有効化、レイアウトシフトの防止などがあります。

画面を表示すると以下のエラーが発生します。

image.png

これは、Next.jsのセキュリティ機能によるものです。外部のドメインから画像を読み込む場合、そのドメインを明示的に許可する必要があります。

外部ドメインの画像を使用するには、next.config.tsファイルで許可するドメインを設定します。

next.config.ts
const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'qiita-user-contents.imgix.net',
      },
      {
        protocol: 'https',
        hostname: 'images.microcms-assets.io',
      },
    ],
  },
};

MicroCMSというサービスもこのあと利用するので追加しておきました。

// 一度サーバーを切る
npm run dev

image.png

無事画像が表示されました。
それでは同じ要領でMicroCMSも表示しましょう。

image.png

アカウントがない場合は作成から始めてください。
サービス管理画面から「追加」をクリック

image.png

「一から作成する」をクリック

image.png

サービス名に「テックブログアプリ」と入力して「サービスを作成する」クリック

image.png

「ブログ」をクリック

image.png

最初に用意されているブログの三点リーダーをクリックして「コンテンツをコピーして新規作成」をクリック

image.png

タイトルを「テックブログ2」に変更して「公開」をクリック
アラートが表示されるので「OK」をクリック

image.png

この手順を繰り返して「テックブログ3」と「テックブログ4」を作って下さい

image.png

まずはアクセスキーを取得します。
左メニューから「1個のAPIキー」をクリックして、APIキーをコピーします。

image.png

環境変数に設定します。

.env
QIITA_API_KEY=xxxxxxxx
MICROCMS_API_KEY=コピーしたAPIキー

それではドキュメントをみながらAPIを叩いてブログ記事一覧を取得します。

app/page.tsx
import axios from "axios";
import Image from "next/image";

type QiitaResponse = {
  id: string;
  title: string;
  url: string;
  image: string;
}

type MicrocmsContent = {
  id: string;
  title: string;
  eyecatch: {
    url: string;
  };
};

type MicrocmsResponse = {
  contents: MicrocmsContent[];
}

export default async function Home() {

  const getQiitaItems = async () => {
    const response = await axios.get<QiitaResponse[]>(
      "https://qiita.com/api/v2/items?query=user:Sicut_study&per_page=4",
      {
        headers: {
          "Authorization": `Bearer ${process.env.QIITA_API_KEY}`
        }
      }
    );
    return response.data.map((item) => ({
      id: item.id,
      title: item.title,
      url: item.url,
      image: "https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F810513%2F04c6ef92-7b08-467f-95b0-efd05a0e7ea4.png?ixlib=rb-4.0.0&auto=format&gif-q=60&q=75&w=1400&fit=max&s=255a4084e07534dc5871b77aa1318d0e"
    }));
  }

  const getMicrocmsItems = async () => {
    const response = await axios.get<MicrocmsResponse>(
      "https://[あなたのドメイン].microcms.io/api/v1/blogs",
      {
        headers: {
          "X-MICROCMS-API-KEY": `${process.env.MICROCMS_API_KEY}`
        }
      }
    );

    return response.data.contents.map((item) => ({
      id: item.id,
      title: item.title,
      url: `/blogs/${item.id}`,
      image: item.eyecatch.url
    }));
  }

  const qiitaItems = await getQiitaItems();
  const microcmsItems = await getMicrocmsItems();

  return (
    <div>
      <h1>Topページ</h1>
      <ul>
        {qiitaItems.map((item) => (
          <li key={item.id}>
            <Image src={item.image} alt={item.title} width={100} height={100} />
            <a href={item.url} target="_blank" rel="noopener noreferrer">{item.title}</a>
          </li>
        ))}
      </ul>
      <ul>
        {microcmsItems.map((item) => (
          <li key={item.id}>
            <Image src={item.image} alt={item.title} width={100} height={100} />
            <a href={item.url} target="_blank" rel="noopener noreferrer">{item.title}</a>
          </li>
        ))}
      </ul>
    </div>
  )
}

エンドポイントのhttps://[あなたのドメイン].microcms.io/api/v1/blogsですが、各自のサービスIDに置き換える必要があります。これはMicroCMSの管理画面から確認できます。

image.png

image.png

いい感じに表示されました。
ネットワークタブからリクエストをみるとサーバーからはJavaScriptがサーバーサイドで処理されてできたHTMLが返却されているのをみることもできます。

image.png

最後に作成した型はこのあとも利用するので別のファイルに分けておきます。

mkdir domain
touch domain/Article.ts
domain/Article.ts
export type QiitaResponse = {
  id: string;
  title: string;
  url: string;
  image: string;
}

export type MicrocmsContent = {
  id: string;
  title: string;
  eyecatch: {
    url: string;
  };
};

export type MicrocmsResponse = {
  contents: MicrocmsContent[];
}
app/page.tsx
import { MicrocmsResponse, QiitaResponse } from "@/domain/Article";
import axios from "axios";
import Image from "next/image";

export default async function Home() {

  const getQiitaItems = async () => {
    const response = await axios.get<QiitaResponse[]>(
      "https://qiita.com/api/v2/items?query=user:Sicut_study&per_page=4",
      {
        headers: {
          "Authorization": `Bearer ${process.env.QIITA_API_KEY}`
        }
      }
    );
    return response.data.map((item) => ({
      id: item.id,
      title: item.title,
      url: item.url,
      image: "https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F810513%2F04c6ef92-7b08-467f-95b0-efd05a0e7ea4.png?ixlib=rb-4.0.0&auto=format&gif-q=60&q=75&w=1400&fit=max&s=255a4084e07534dc5871b77aa1318d0e"
    }));
  }

  const getMicrocmsItems = async () => {
    const response = await axios.get<MicrocmsResponse>(
      "https://[あなたのドメイン].microcms.io/api/v1/blogs",
      {
        headers: {
          "X-MICROCMS-API-KEY": `${process.env.MICROCMS_API_KEY}`
        }
      }
    );

    return response.data.contents.map((item) => ({
      id: item.id,
      title: item.title,
      url: `/blogs/${item.id}`,
      image: item.eyecatch.url
    }));
  }

  const qiitaItems = await getQiitaItems();
  const microcmsItems = await getMicrocmsItems();

  return (
    <div>
      <h1>Topページ</h1>
      <ul>
        {qiitaItems.map((item) => (
          <li key={item.id}>
            <Image src={item.image} alt={item.title} width={100} height={100} />
            <a href={item.url} target="_blank" rel="noopener noreferrer">{item.title}</a>
          </li>
        ))}
      </ul>
      <ul>
        {microcmsItems.map((item) => (
          <li key={item.id}>
            <Image src={item.image} alt={item.title} width={100} height={100} />
            <a href={item.url} target="_blank" rel="noopener noreferrer">{item.title}</a>
          </li>
        ))}
      </ul>
    </div>
  )
}

4. CSRでQiita一覧ページを作ろう

ここではCSRを体験するためにあえてCSRでQiitaページを作ります。
先ほどと実装はほとんど変わりませんが、先頭に'use client'とつけてクライアントでレンダリングします。

app/qiita/page.tsx
'use client';

import { QiitaResponse } from "@/domain/Article";
import axios from "axios";
import { useEffect, useState } from "react";

export default function Qiita() {

  const [qiitaItems, setQiitaItems] = useState<QiitaResponse[]>([]);

  const fetchQiitaItems = async () => {
    const response = await axios.get<QiitaResponse[]>(
      "https://qiita.com/api/v2/items?query=user:Sicut_study&per_page=20",
      {
        headers: {
          "Authorization": `Bearer ${process.env.QIITA_API_KEY}`
        }
      }
    );
    return response.data;
  }; 

  useEffect(() => {
    fetchQiitaItems().then((items) => {
      setQiitaItems(items);
    });
  }, []);

  return (
    <div>
      <h1>Qiitaページ</h1>
      <ul>
        {qiitaItems.map((item) => (
          <li key={item.id}>
            <a href={item.url} target="_blank" rel="noopener noreferrer">{item.title}</a>
          </li>
        ))}
      </ul>
    </div>
  )
}

ここで http://localhost:3000/qiita をみてみるとエラーが発生しています。

image.png

これはQiitaのアクセストークンが読み込めないためにエラーが起きています。
ここにはサーバーサイドとクライアントサイドでのレンダリングに原因があります。

Next.jsでは、環境変数は基本的にサーバーサイドでのみアクセス可能です。.envファイルに記述した環境変数は、サーバー上で動作するコードからしかアクセスできません。

トップページで実装したServer Componentでは、コンポーネント全体がサーバー上で実行されるため、process.env.QIITA_API_KEYで環境変数にアクセスできました。一方、'use client'を指定したコンポーネントは、最終的にブラウザ上で実行されるJavaScriptとしてバンドルされます。ブラウザ側には環境変数が存在しないためundefinedになってしまいます。

それでは今回はあえて環境変数をクライアントサイドでも読み込めるように設定してみましょう。

.env
QIITA_API_KEY=あなたのAPI KEY
MICROCMS_API_KEY=あなたのAPI KEY
NEXT_PUBLIC_QIITA_API_KEY=QIITA_API_KEYと同じ値

NEXT_PUBLIC_というプレフィックスをつけるとクライアントサイドで使える環境変数になります。

app/qiita/page.tsx
'use client';

import { QiitaResponse } from "@/domain/Article";
import axios from "axios";
import { useEffect, useState } from "react";

export default function Qiita() {

  const [qiitaItems, setQiitaItems] = useState<QiitaResponse[]>([]);

  const fetchQiitaItems = async () => {
    const response = await axios.get<QiitaResponse[]>(
      "https://qiita.com/api/v2/items?query=user:Sicut_study&per_page=20",
      {
        headers: {
          "Authorization": `Bearer ${process.env.NEXT_PUBLIC_QIITA_API_KEY}` // 修正
        }
      }
    );
    return response.data;
  }; 

  useEffect(() => {
    fetchQiitaItems().then((items) => {
      setQiitaItems(items);
    });
  }, []);

  return (
    <div>
      <h1>Qiitaページ</h1>
      <ul>
        {qiitaItems.map((item) => (
          <li key={item.id}>
            <a href={item.url} target="_blank" rel="noopener noreferrer">{item.title}</a>
          </li>
        ))}
      </ul>
    </div>
  )
}

image.png

CSRで表示しているのでリロードをかけるとJavaScriptを読み込み直すため白いページが表示されます。これがSEOがうまく効かない理由の1つになります。

この方法には問題があります。
クライアント上で環境変数を埋め込むため、開発者ツールなどで環境変数を外部の人が見ることができてしまいます。

image.png

機密情報を扱うときはサーバーサイドレンダリングが必要になってきます。

5. SSGでMicroCMS一覧ページを作ろう

次はMicroCMSで作成した記事一覧をSSGで作成します。
SSGでページを作成することでビルド時に作成したページが読み込まれるため、JavaScriptで読み込む時間が不要になり高速にページ読み込みが行えます。

app/blogs/page.tsx
'use cache';

import { MicrocmsResponse } from "@/domain/Article";
import axios from "axios";
import Image from "next/image";

export default async function Blogs() {

  const getBlogs = async () => {
    const response = await axios.get<MicrocmsResponse>(
      "https://[あなたのドメイン].microcms.io/api/v1/blogs",
      {
        headers: {
          "X-MICROCMS-API-KEY": `${process.env.MICROCMS_API_KEY}`
        }
      }
    );

    return response.data.contents.map((item) => ({
      id: item.id,
      title: item.title,
      url: `/blogs/${item.id}`,
      image: item.eyecatch.url
    }));
  }

  const blogs = await getBlogs();

  return (
    <div>
      <h1>Blogsページ</h1>
      <ul>
        {blogs.map((blog) => (
          <li key={blog.id}>
            <Image src={blog.image} alt={blog.title} width={100} height={100} />
            <a href={blog.url}>{blog.title}</a>
          </li>
        ))}
      </ul>
    </div>
  )
}

Next.js 16からuse cacheディレクティブを使うことで、コンポーネントやデータ取得関数をキャッシュすることができます。

'use cache';

これにより、このコンポーネント全体がキャッシュ対象となり、ビルド時にこのページのHTMLが生成され、その後のアクセスでは事前に生成されたHTMLが返されるようになります(SSG)。

use cacheを使うにはNext.jsの設定を追加する必要があります。

バージョンによる設定の違い
use cacheを有効にする設定はバージョンによって異なります。

  • Next.js 15: next.config.tsexperimental: { dynamicIO: true }を追加
  • Next.js 16: next.config.tscacheComponents: trueを追加

本チュートリアルはNext.js 16を使用しているため、以下のように設定します。

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'qiita-user-contents.imgix.net',
      },
      {
        protocol: 'https',
        hostname: 'images.microcms-assets.io',
      },
    ],
  },
  cacheComponents: true, // Next.js 16の設定
};

export default nextConfig;

SSGはnpm run devの開発環境では動作しません。
開発環境では、コードの変更を即座に反映させるためにすべてのページが毎回サーバーサイドでレンダリングされます。

そのためビルドをしてから本番モードで起動する必要があります。

npm run build // ここでエラーになる
npm run start

するとnpm run buildでエラーが発生しました。

Error: Route "/blogs/[id]": Uncached data was accessed outside of <Suspense>.
...

このエラーメッセージは、「キャッシュされていないデータがSuspenseの外でアクセスされている」 ことを示しています。

エラーに対応する前にSuspenseについて理解しておきましょう。
Suspenseは、Reactが提供するコンポーネントで、非同期処理(データ取得など)の待機状態を管理するための仕組みです。

従来の方法では、以下のようにローディング状態を手動で管理する必要がありました。

function BlogList() {
  const [loading, setLoading] = useState(true);
  const [data, setData] = useState(null);

  useEffect(() => {
    fetchData().then(result => {
      setData(result);
      setLoading(false);
    });
  }, []);

  if (loading) return <div>読み込み中...</div>;
  return <div>{data.title}</div>;
}

Suspenseを使うと、これをもっとシンプルに書けます。

import { Suspense } from 'react';

function App() {
  return (
    <Suspense fallback={<div>読み込み中...</div>}>
      <BlogList />
    </Suspense>
  );
}

Suspenseで囲まれたコンポーネントがデータを取得している間は、fallbackに指定したコンポーネントが表示されます。データ取得が完了すると、自動的に実際のコンテンツに切り替わります。

cacheComponents: trueを設定に追加したことで、Next.jsは自動的にPPR(Partial Prerendering)を有効にしようとします。現在のブログ詳細ページ(/blogs/[id])は動的ルートで、キャッシュされていないデータアクセス(paramsの取得)がSuspenseで囲まれていないためエラーが発生しています。

Suspenseを利用するように実装を変更しましょう。

app/blogs/[id]/page.tsx
import { Suspense } from "react";

async function BlogContent({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return (
    <div>
      <h1>BlogDetailページ</h1>
      <p>ID: {id}</p>
    </div>
  );
}

export default function BlogDetail({ params }: { params: Promise<{ id: string }> }) {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <BlogContent params={params} />
    </Suspense>
  );
}

またトップページも同様にエラーの影響を受けるのでPPRで実装していきましょう。
静的な部分(データ取得によって表示が変わらない不変な部分)はSuspenseの外に出して、最初からSSGで表示するようにしましょう。

app/page.tsx
import { MicrocmsResponse, QiitaResponse } from "@/domain/Article";
import axios from "axios";
import Image from "next/image";
import { Suspense } from "react";

async function QiitaArticles() {
  const response = await axios.get<QiitaResponse[]>(
    "https://qiita.com/api/v2/items?query=user:Sicut_study&per_page=4",
    {
      headers: {
        Authorization: `Bearer ${process.env.QIITA_API_KEY}`,
      },
    }
  );

  const items = response.data.map((item) => ({
    id: item.id,
    title: item.title,
    url: item.url,
    image: "https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F810513%2F04c6ef92-7b08-467f-95b0-efd05a0e7ea4.png?ixlib=rb-4.0.0&auto=format&gif-q=60&q=75&w=1400&fit=max&s=255a4084e07534dc5871b77aa1318d0e",
  }));

  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>
          <Image src={item.image} alt={item.title} width={100} height={100} />
          <a href={item.url} target="_blank" rel="noopener noreferrer">
            {item.title}
          </a>
        </li>
      ))}
    </ul>
  );
}

async function MicrocmsArticles() {
  const response = await axios.get<MicrocmsResponse>(
    "https://[あなたのドメイン].microcms.io/api/v1/blogs",
    {
      headers: {
        "X-MICROCMS-API-KEY": `${process.env.MICROCMS_API_KEY}`,
      },
    }
  );

  const items = response.data.contents.map((item) => ({
    id: item.id,
    title: item.title,
    url: `/blogs/${item.id}`,
    image: item.eyecatch.url,
  }));

  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>
          <Image src={item.image} alt={item.title} width={100} height={100} />
          <a href={item.url} target="_blank" rel="noopener noreferrer">
            {item.title}
          </a>
        </li>
      ))}
    </ul>
  );
}

export default function Home() {
  return (
    <div>
      <h1>Topページ</h1>

      <h2>Qiita記事</h2>
      <Suspense fallback={<div>Loading Qiita articles...</div>}>
        <QiitaArticles />
      </Suspense>

      <h2>ブログ記事</h2>
      <Suspense fallback={<div>Loading blog articles...</div>}>
        <MicrocmsArticles />
      </Suspense>
    </div>
  );
}

それではもう一度ビルドして起動してみましょう。

npm run build
npm run start

http://localhost:3000/blogs を開くとブログが表示されます。

image.png

リロードすると一瞬で表示されているのが確認できます。これがSSGの強さです。

またトップページを開きます。

image.png

すると最初に「Topページ」などの静的なテキストが表示されて、しばらくすると記事が表示されます。PPRにすることで静的な部分を先に表示してユーザーに早くフィードバックを送り、あとから動的な部分を表示することが可能になります。

6. ISRでMicroCMS詳細ページを作ろう

まずはこれまでと同じくPPRで画面を実装します。
今回は詳細画面なので記事の内容が必要なため、型を少し変更しました。

domain/Article.ts
export type QiitaResponse = {
  id: string;
  title: string;
  url: string;
  image: string;
}

export type MicrocmsContent = {
  id: string;
  title: string;
  eyecatch: {
    url: string;
  };
  content: string; // 追加
};

export type MicrocmsResponse = {
  contents: MicrocmsContent[];
}
app/blogs/[id]/page.tsx
import { Suspense } from "react";
import axios from "axios";
import Image from "next/image";
import { MicrocmsContent } from "@/domain/Article";

async function BlogContent({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;

  const response = await axios.get<MicrocmsContent>(
    `https://[あなたのドメイン].microcms.io/api/v1/blogs/${id}`,
    {
      headers: {
        "X-MICROCMS-API-KEY": `${process.env.MICROCMS_API_KEY}`,
      },
    }
  );

  const blog = response.data;

  return (
    <article>
      <Image
        src={blog.eyecatch.url}
        alt={blog.title}
        width={600}
        height={300}
      />
      <h2>{blog.title}</h2>
      <div dangerouslySetInnerHTML={{ __html: blog.content }} />
    </article>
  );
}

export default function BlogDetail({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  return (
    <div>
      <h1>ブログ詳細</h1>
      <Suspense fallback={<div>Loading...</div>}>
        <BlogContent params={params} />
      </Suspense>
    </div>
  );
}

image.png

記事が表示されたら次にISRができるようにします。

ページは基本キャッシュしておいて(SSG)、更新ボタンが押されたら再ビルドするようにします。今回は、記事の下に「再読み込み」ボタンを設置し、このボタンを押すことで最新の記事内容に更新できるようにします。

app/blogs/[id]/page.tsx
import { Suspense } from "react";
import axios from "axios";
import Image from "next/image";
import { MicrocmsContent } from "@/domain/Article";
import ReloadButton from "./ReloadButton";

async function BlogContent({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;

  const response = await axios.get<MicrocmsContent>(
    `https://[あなたのドメイン].microcms.io/api/v1/blogs/${id}`,
    {
      headers: {
        "X-MICROCMS-API-KEY": `${process.env.MICROCMS_API_KEY}`,
      },
    }
  );

  const blog = response.data;

  return (
    <article>
      <Image
        src={blog.eyecatch.url}
        alt={blog.title}
        width={600}
        height={300}
      />
      <h2>{blog.title}</h2>
      <div dangerouslySetInnerHTML={{ __html: blog.content }} />
      {/* 追加 */}
      <ReloadButton id={id} />
    </article>
  );
}

export default function BlogDetail({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  return (
    <div>
      <h1>ブログ詳細</h1>
      <Suspense fallback={<div>Loading...</div>}>
        <BlogContent params={params} />
      </Suspense>
    </div>
  );
}
touch app/blogs/\[id\]/ReloadButton.tsx
touch app/blogs/\[id\]/actions.ts
app/blogs/[id]/ReloadButton.tsx
'use client';

import { reloadBlog } from "./actions";

export default function ReloadButton({ id }: { id: string }) {
  return (
    <form action={reloadBlog}>
      <input type="hidden" name="id" value={id} />
      <button type="submit">再読み込み</button>
    </form>
  );
}

ボタンをクリックしたらページを更新する、という一見シンプルな機能ですが、ここにはクライアントサイドとサーバーサイドの境界という重要な問題があります。

ボタンのクリックイベントはユーザーのブラウザ上(クライアントサイド)で発生します。一方、キャッシュの無効化はサーバー上で実行する必要があります。

ユーザーがブラウザでボタンをクリック(クライアント)
→ サーバーに「このページを更新して」というリクエストを送る
→ サーバーがキャッシュを無効化(サーバー)
→ 次のアクセス時に新しいHTMLを生成(サーバー)

Next.js 15/16とReact 19では、この問題をServer Actionsという新しい仕組みで解決します。Server Actionsを使うと、クライアントコンポーネントからサーバー側の関数を直接呼び出せるようになります。

<form action={reloadBlog}>

サーバーアクションの実装は別ファイルに書いていきます。

app/blogs/[id]/actions.ts
'use server';

import { revalidatePath } from "next/cache";

export async function reloadBlog(formData: FormData) {
  const id = formData.get("id") as string;
  revalidatePath(`/blogs/${id}`);
}

'use server'ディレクティブにより、このファイル全体がサーバー上でのみ実行されます。revalidatePathは、Next.jsが提供する関数で、指定したパスのキャッシュを無効化します。この関数を実行すると、/blogs/${id}のキャッシュが削除されて、次にユーザーがこのページにアクセスすると、サーバーが再度MicroCMS APIを呼び出します。そして最新データで新しいHTMLを生成して再びキャッシュされます。

それでは最後にuse cacheディレクティブを追加して確かめてみましょう。

app/blogs/[id]/page.tsx
import { Suspense } from "react";
import axios from "axios";
import Image from "next/image";
import { MicrocmsContent } from "@/domain/Article";
import ReloadButton from "./ReloadButton";

async function BlogContent({ params }: { params: Promise<{ id: string }> }) {
  "use cache";
  const { id } = await params;

  const response = await axios.get<MicrocmsContent>(
    `https://[あなたのドメイン].microcms.io/api/v1/blogs/${id}`,
    {
      headers: {
        "X-MICROCMS-API-KEY": `${process.env.MICROCMS_API_KEY}`,
      },
    }
  );

  const blog = response.data;

  return (
    <article>
      <Image
        src={blog.eyecatch.url}
        alt={blog.title}
        width={600}
        height={300}
      />
      <h2>{blog.title}</h2>
      <div dangerouslySetInnerHTML={{ __html: blog.content }} />
      <ReloadButton id={id} />
    </article>
  );
}

export default function BlogDetail({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  return (
    <div>
      <h1>ブログ詳細</h1>
      <Suspense fallback={<div>Loading...</div>}>
        <BlogContent params={params} />
      </Suspense>
    </div>
  );
}

アプリを起動します。ここではISRを検証するために本番モードで起動してください。

npm run build
npm run start

まずは http://localhost:3000/blogs にアクセスして、記事を一つ選択してください。

image.png

そしたらMicroCMSを開いて文章を変更してみましょう。本文を「変更しました」にして右上の「公開」ボタンを押します。

image.png

もちろん記事詳細ページをリロードしてもSSGされているので更新されません。
それでは一番下にある「再読み込み」ボタンをクリックしてみましょう。

image.png

クリックすると内容が更新されました。

image.png

もちろんリロードするとすぐに表示されているのでSSGが効いているのもわかります。

その他のキャッシュ制御方法

今回実装したのは手動でのキャッシュ無効化でしたが、Next.jsには他にもいくつかのキャッシュ制御方法があります。

時間ベースの自動更新は、一定時間が経過したら自動的にキャッシュを更新する方法です。

// 60秒ごとに自動的にキャッシュを再生成
const response = await fetch('https://api.example.com/news', {
  next: { revalidate: 60 }
});

タグを使ったキャッシュ管理は、複数のページのキャッシュをまとめて管理したい場合に使います。

// データ取得時にタグを付ける
const response = await fetch('https://api.example.com/blogs', {
  next: { tags: ['blog-posts'] }
});

// 後から'blog-posts'タグが付いた全てのキャッシュを一括で無効化
revalidateTag('blog-posts');

7. スタイリング

最後にスタイリングを行って終わりにしましょう。

app/page.tsx
import { MicrocmsResponse, QiitaResponse } from "@/domain/Article";
import axios from "axios";
import Image from "next/image";
import Link from "next/link";
import { Suspense } from "react";

function CardSkeleton() {
  return (
    <div className="grid gap-4 sm:grid-cols-2">
      {[...Array(4)].map((_, i) => (
        <div
          key={i}
          className="overflow-hidden rounded-xl border border-card-border bg-card"
        >
          <div className="skeleton h-40 w-full" />
          <div className="p-4">
            <div className="skeleton mb-2 h-5 w-3/4" />
            <div className="skeleton h-4 w-1/2" />
          </div>
        </div>
      ))}
    </div>
  );
}

async function QiitaArticles() {
  const response = await axios.get<QiitaResponse[]>(
    "https://qiita.com/api/v2/items?query=user:Sicut_study&per_page=4",
    {
      headers: {
        Authorization: `Bearer ${process.env.QIITA_API_KEY}`,
      },
    }
  );

  const items = response.data.map((item) => ({
    id: item.id,
    title: item.title,
    url: item.url,
    image: "https://qiita-user-contents.imgix.net/https%3A%2F%2Fqiita-image-store.s3.ap-northeast-1.amazonaws.com%2F0%2F810513%2F04c6ef92-7b08-467f-95b0-efd05a0e7ea4.png?ixlib=rb-4.0.0&auto=format&gif-q=60&q=75&w=1400&fit=max&s=255a4084e07534dc5871b77aa1318d0e",
  }));

  return (
    <div className="grid gap-4 sm:grid-cols-2">
      {items.map((item) => (
        <a
          key={item.id}
          href={item.url}
          target="_blank"
          rel="noopener noreferrer"
          className="group overflow-hidden rounded-xl border border-card-border bg-card transition-all hover:border-qiita-green hover:shadow-lg"
        >
          <div className="relative h-40 w-full overflow-hidden">
            <Image
              src={item.image}
              alt={item.title}
              fill
              className="object-cover transition-transform duration-300 group-hover:scale-105"
            />
          </div>
          <div className="p-4">
            <p className="line-clamp-2 text-sm font-semibold leading-snug text-foreground group-hover:text-qiita-green">
              {item.title}
            </p>
            <span className="mt-2 inline-block rounded-full bg-qiita-green-light px-2.5 py-0.5 text-xs font-medium text-qiita-green">
              Qiita
            </span>
          </div>
        </a>
      ))}
    </div>
  );
}

async function MicrocmsArticles() {
  const response = await axios.get<MicrocmsResponse>(
    "https://[あなたのドメイン].microcms.io/api/v1/blogs",
    {
      headers: {
        "X-MICROCMS-API-KEY": `${process.env.MICROCMS_API_KEY}`,
      },
    }
  );

  const items = response.data.contents.map((item) => ({
    id: item.id,
    title: item.title,
    url: `/blogs/${item.id}`,
    image: item.eyecatch.url,
  }));

  return (
    <div className="grid gap-4 sm:grid-cols-2">
      {items.map((item) => (
        <Link
          key={item.id}
          href={item.url}
          className="group overflow-hidden rounded-xl border border-card-border bg-card transition-all hover:border-accent hover:shadow-lg"
        >
          <div className="relative h-40 w-full overflow-hidden">
            <Image
              src={item.image}
              alt={item.title}
              fill
              className="object-cover transition-transform duration-300 group-hover:scale-105"
            />
          </div>
          <div className="p-4">
            <p className="line-clamp-2 text-sm font-semibold leading-snug text-foreground group-hover:text-accent">
              {item.title}
            </p>
            <span className="mt-2 inline-block rounded-full bg-accent-bg px-2.5 py-0.5 text-xs font-medium text-accent">
              Blog
            </span>
          </div>
        </Link>
      ))}
    </div>
  );
}

export default function Home() {
  return (
    <div className="space-y-12">
      {/* Hero */}
      <section className="text-center">
        <h1 className="text-4xl font-extrabold tracking-tight sm:text-5xl">
          Tech Blog
        </h1>
        <p className="mx-auto mt-3 max-w-xl text-lg text-muted">
          QiitaとMicroCMSの最新記事をまとめてチェック
        </p>
      </section>

      {/* Qiita Section */}
      <section>
        <div className="mb-4 flex items-center justify-between">
          <h2 className="flex items-center gap-2 text-xl font-bold">
            <span className="inline-block h-6 w-1 rounded-full bg-qiita-green" />
            Qiita 記事
          </h2>
          <Link
            href="/qiita"
            className="text-sm font-medium text-muted transition-colors hover:text-qiita-green"
          >
            すべて見る &rarr;
          </Link>
        </div>
        <Suspense fallback={<CardSkeleton />}>
          <QiitaArticles />
        </Suspense>
      </section>

      {/* Blog Section */}
      <section>
        <div className="mb-4 flex items-center justify-between">
          <h2 className="flex items-center gap-2 text-xl font-bold">
            <span className="inline-block h-6 w-1 rounded-full bg-accent" />
            ブログ記事
          </h2>
          <Link
            href="/blogs"
            className="text-sm font-medium text-muted transition-colors hover:text-accent"
          >
            すべて見る &rarr;
          </Link>
        </div>
        <Suspense fallback={<CardSkeleton />}>
          <MicrocmsArticles />
        </Suspense>
      </section>
    </div>
  );
}
app/qiita/page.tsx
'use client';

import { QiitaResponse } from "@/domain/Article";
import axios from "axios";
import { useEffect, useState } from "react";

export default function Qiita() {
  const [qiitaItems, setQiitaItems] = useState<QiitaResponse[]>([]);
  const [isLoading, setIsLoading] = useState(true);

  const fetchQiitaItems = async () => {
    const response = await axios.get<QiitaResponse[]>(
      "https://qiita.com/api/v2/items?query=user:Sicut_study&per_page=20",
      {
        headers: {
          Authorization: `Bearer ${process.env.NEXT_PUBLIC_QIITA_API_KEY}`,
        },
      }
    );
    return response.data;
  };

  useEffect(() => {
    fetchQiitaItems()
      .then((items) => {
        setQiitaItems(items);
      })
      .finally(() => {
        setIsLoading(false);
      });
  }, []);

  return (
    <div>
      {/* Page Header */}
      <div className="mb-8">
        <h1 className="text-3xl font-extrabold tracking-tight">Qiita</h1>
        <p className="mt-2 text-muted">Qiita に投稿した技術記事一覧</p>
      </div>

      {/* Loading State */}
      {isLoading && (
        <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
          {[...Array(6)].map((_, i) => (
            <div
              key={i}
              className="rounded-xl border border-card-border bg-card p-5"
            >
              <div className="skeleton mb-3 h-5 w-3/4" />
              <div className="skeleton mb-2 h-4 w-full" />
              <div className="skeleton h-4 w-1/2" />
            </div>
          ))}
        </div>
      )}

      {/* Article List */}
      {!isLoading && (
        <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
          {qiitaItems.map((item) => (
            <a
              key={item.id}
              href={item.url}
              target="_blank"
              rel="noopener noreferrer"
              className="group rounded-xl border border-card-border bg-card p-5 transition-all hover:border-qiita-green hover:shadow-lg"
            >
              <h2 className="line-clamp-2 text-base font-semibold leading-snug text-foreground group-hover:text-qiita-green">
                {item.title}
              </h2>
              <div className="mt-3 flex items-center gap-2">
                <span className="inline-block rounded-full bg-qiita-green-light px-2.5 py-0.5 text-xs font-medium text-qiita-green">
                  Qiita
                </span>
                <span className="text-xs text-muted">外部リンク &nearr;</span>
              </div>
            </a>
          ))}
        </div>
      )}
    </div>
  );
}
app/blogs/page.tsx
'use cache';

import { MicrocmsResponse } from "@/domain/Article";
import axios from "axios";
import Image from "next/image";
import Link from "next/link";

export default async function Blogs() {
  const getBlogs = async () => {
    const response = await axios.get<MicrocmsResponse>(
      "https://[あなたのドメイン].microcms.io/api/v1/blogs",
      {
        headers: {
          "X-MICROCMS-API-KEY": `${process.env.MICROCMS_API_KEY}`,
        },
      }
    );

    return response.data.contents.map((item) => ({
      id: item.id,
      title: item.title,
      url: `/blogs/${item.id}`,
      image: item.eyecatch.url,
    }));
  };

  const blogs = await getBlogs();

  return (
    <div>
      {/* Page Header */}
      <div className="mb-8">
        <h1 className="text-3xl font-extrabold tracking-tight">Blogs</h1>
        <p className="mt-2 text-muted">MicroCMS で管理しているブログ記事一覧</p>
      </div>

      {/* Article Grid */}
      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
        {blogs.map((blog) => (
          <Link
            key={blog.id}
            href={blog.url}
            className="group overflow-hidden rounded-xl border border-card-border bg-card transition-all hover:border-accent hover:shadow-lg"
          >
            <div className="relative aspect-video w-full overflow-hidden">
              <Image
                src={blog.image}
                alt={blog.title}
                fill
                className="object-cover transition-transform duration-300 group-hover:scale-105"
              />
            </div>
            <div className="p-4">
              <h2 className="line-clamp-2 text-base font-semibold leading-snug text-foreground group-hover:text-accent">
                {blog.title}
              </h2>
              <span className="mt-3 inline-block rounded-full bg-accent-bg px-2.5 py-0.5 text-xs font-medium text-accent">
                Blog
              </span>
            </div>
          </Link>
        ))}
      </div>
    </div>
  );
}
app/blogs/[id]/page.tsx
import { Suspense } from "react";
import axios from "axios";
import Image from "next/image";
import Link from "next/link";
import { MicrocmsContent } from "@/domain/Article";
import ReloadButton from "./ReloadButton";

function BlogDetailSkeleton() {
  return (
    <div className="mx-auto max-w-3xl">
      <div className="skeleton mb-6 h-8 w-1/3" />
      <div className="skeleton mb-8 aspect-video w-full rounded-xl" />
      <div className="space-y-3">
        <div className="skeleton h-5 w-full" />
        <div className="skeleton h-5 w-5/6" />
        <div className="skeleton h-5 w-4/6" />
        <div className="skeleton h-5 w-full" />
        <div className="skeleton h-5 w-3/4" />
      </div>
    </div>
  );
}

async function BlogContent({ params }: { params: Promise<{ id: string }> }) {
  "use cache";
  const { id } = await params;

  const response = await axios.get<MicrocmsContent>(
    `https://[あなたのドメイン].microcms.io/api/v1/blogs/${id}`,
    {
      headers: {
        "X-MICROCMS-API-KEY": `${process.env.MICROCMS_API_KEY}`,
      },
    }
  );

  const blog = response.data;

  return (
    <article className="mx-auto max-w-3xl">
      {/* Back Link */}
      <Link
        href="/blogs"
        className="mb-6 inline-flex items-center gap-1 text-sm font-medium text-muted transition-colors hover:text-accent"
      >
        &larr; ブログ一覧に戻る
      </Link>

      {/* Title */}
      <h1 className="mb-6 text-3xl font-extrabold leading-tight tracking-tight sm:text-4xl">
        {blog.title}
      </h1>

      {/* Eye Catch */}
      <div className="relative mb-8 aspect-video w-full overflow-hidden rounded-xl border border-card-border">
        <Image
          src={blog.eyecatch.url}
          alt={blog.title}
          fill
          className="object-cover"
          priority
        />
      </div>

      {/* Content */}
      <div
        className="prose"
        dangerouslySetInnerHTML={{ __html: blog.content }}
      />

      {/* Footer Actions */}
      <div className="mt-10 flex items-center justify-between border-t border-card-border pt-6">
        <Link
          href="/blogs"
          className="text-sm font-medium text-muted transition-colors hover:text-accent"
        >
          &larr; ブログ一覧に戻る
        </Link>
        <ReloadButton id={id} />
      </div>
    </article>
  );
}

export default function BlogDetail({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  return (
    <Suspense fallback={<BlogDetailSkeleton />}>
      <BlogContent params={params} />
    </Suspense>
  );
}
app/blogs/[id]/ReloadButton.tsx
'use client';

import { reloadBlog } from "./actions";

export default function ReloadButton({ id }: { id: string }) {
  return (
    <form action={reloadBlog}>
      <input type="hidden" name="id" value={id} />
      <button
        type="submit"
        className="inline-flex items-center gap-1.5 rounded-lg border border-card-border bg-card px-4 py-2 text-sm font-medium text-muted transition-all hover:border-accent hover:text-accent hover:shadow-sm active:scale-95"
      >
        <svg
          xmlns="http://www.w3.org/2000/svg"
          width="14"
          height="14"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
        >
          <path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2" />
        </svg>
        再読み込み
      </button>
    </form>
  );
}

ここで新たに<Link>というコンポーネントが使われているので解説します。
Next.jsでは、ページ間の遷移に<Link>コンポーネントを使用します。これは通常のHTMLの<a>タグの代わりに使うもので、いくつかの重要な利点があります。

1つ目はページ全体をリロードせずにページを遷移できます。通常の<a>タグでページ遷移するとブラウザがページ全体を再読み込みしますが、<Link>を使うとJavaScriptでページ遷移が行われ、必要な部分だけが更新されます。

2つ目にプリフェッチ(事前読み込み)ができます。<Link>は画面に表示されると自動的に遷移先のページをバックグラウンドで事前に読み込みます。

3つ目にクライアントサイドナビゲーションが可能です。ブラウザの履歴(戻る・進むボタン)も正しく動作し、URLも変わるため、見た目は通常のページ遷移と同じですが、実際はJavaScriptによる高速な画面更新が行われています。

Next.jsでは<a>タグの代わりに<Link>コンポーネントを利用するのが良いでしょう。

app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import Link from "next/link";
import "./globals.css";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "Tech Blog",
  description: "Qiita & MicroCMS Tech Blog",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ja">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen flex flex-col`}
      >
        {/* Header */}
        <header className="sticky top-0 z-50 border-b border-card-border bg-card/80 backdrop-blur-md">
          <nav className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
            <Link
              href="/"
              className="text-xl font-bold tracking-tight text-foreground transition-colors hover:text-accent"
            >
              Tech Blog
            </Link>
            <ul className="flex items-center gap-1">
              <li>
                <Link
                  href="/"
                  className="rounded-lg px-4 py-2 text-sm font-medium text-muted transition-colors hover:bg-accent-bg hover:text-accent"
                >
                  Home
                </Link>
              </li>
              <li>
                <Link
                  href="/blogs"
                  className="rounded-lg px-4 py-2 text-sm font-medium text-muted transition-colors hover:bg-accent-bg hover:text-accent"
                >
                  Blogs
                </Link>
              </li>
              <li>
                <Link
                  href="/qiita"
                  className="rounded-lg px-4 py-2 text-sm font-medium text-muted transition-colors hover:bg-accent-bg hover:text-accent"
                >
                  Qiita
                </Link>
              </li>
            </ul>
          </nav>
        </header>

        {/* Main */}
        <main className="mx-auto w-full max-w-5xl flex-1 px-6 py-10">
          {children}
        </main>

        {/* Footer */}
        <footer className="border-t border-card-border bg-card/50">
          <div className="mx-auto max-w-5xl px-6 py-6 text-center text-sm text-muted">
            Tech Blog. All rights reserved.
          </div>
        </footer>
      </body>
    </html>
  );
}
app/globals.css
@import "tailwindcss";

:root {
  --background: #f8fafc;
  --foreground: #0f172a;
  --card: #ffffff;
  --card-border: #e2e8f0;
  --accent: #6366f1;
  --accent-light: #818cf8;
  --accent-bg: #eef2ff;
  --muted: #64748b;
  --qiita-green: #55c500;
  --qiita-green-light: #dcfce7;
}

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-card: var(--card);
  --color-card-border: var(--card-border);
  --color-accent: var(--accent);
  --color-accent-light: var(--accent-light);
  --color-accent-bg: var(--accent-bg);
  --color-muted: var(--muted);
  --color-qiita-green: var(--qiita-green);
  --color-qiita-green-light: var(--qiita-green-light);
  --font-sans: var(--font-geist-sans);
  --font-mono: var(--font-geist-mono);
}

@media (prefers-color-scheme: dark) {
  :root {
    --background: #0b1120;
    --foreground: #e2e8f0;
    --card: #1e293b;
    --card-border: #334155;
    --accent: #818cf8;
    --accent-light: #a5b4fc;
    --accent-bg: #1e1b4b;
    --muted: #94a3b8;
    --qiita-green: #4ade80;
    --qiita-green-light: #052e16;
  }
}

body {
  background: var(--background);
  color: var(--foreground);
  font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
}

.prose h1 { font-size: 1.875rem; font-weight: 700; margin-top: 2rem; margin-bottom: 1rem; line-height: 1.3; }
.prose h2 { font-size: 1.5rem; font-weight: 700; margin-top: 1.75rem; margin-bottom: 0.75rem; line-height: 1.3; padding-bottom: 0.5rem; border-bottom: 1px solid var(--card-border); }
.prose h3 { font-size: 1.25rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.5rem; }
.prose p { margin-bottom: 1rem; line-height: 1.8; }
.prose a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
.prose a:hover { color: var(--accent-light); }
.prose ul, .prose ol { margin-bottom: 1rem; padding-left: 1.5rem; }
.prose ul { list-style-type: disc; }
.prose ol { list-style-type: decimal; }
.prose li { margin-bottom: 0.25rem; line-height: 1.8; }
.prose pre { background: var(--card); border: 1px solid var(--card-border); border-radius: 0.5rem; padding: 1rem; overflow-x: auto; margin-bottom: 1rem; font-family: var(--font-geist-mono), monospace; font-size: 0.875rem; }
.prose code { font-family: var(--font-geist-mono), monospace; font-size: 0.875rem; background: var(--accent-bg); padding: 0.125rem 0.375rem; border-radius: 0.25rem; }
.prose pre code { background: transparent; padding: 0; }
.prose blockquote { border-left: 4px solid var(--accent); padding-left: 1rem; margin-bottom: 1rem; color: var(--muted); font-style: italic; }
.prose img { border-radius: 0.5rem; margin: 1rem 0; max-width: 100%; }

@keyframes shimmer {
  0% { background-position: -200% 0; }
  100% { background-position: 200% 0; }
}

.skeleton {
  background: linear-gradient(90deg, var(--card-border) 25%, var(--card) 50%, var(--card-border) 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s ease-in-out infinite;
  border-radius: 0.5rem;
}

layout.tsxというファイルを置くことで、共通部分をコンポーネント化できます。今回はapp/layout.tsxにヘッダーなどを作成したので、/配下すべてのページでヘッダーが出るようになります。

発展課題

現在のQiita一覧ページ(/qiita)では、クライアントサイドで直接Qiita APIを呼び出しています。そのため、以下の問題があります。

app/qiita/page.tsx
'use client';

export default function Qiita() {
  const fetchQiitaItems = async () => {
    const response = await axios.get<QiitaResponse[]>(
      "https://qiita.com/api/v2/items?query=user:Sicut_study&per_page=20",
      {
        headers: {
          "Authorization": `Bearer ${process.env.NEXT_PUBLIC_QIITA_API_KEY}` // 問題:APIキーがブラウザに露出
        }
      }
    );
    return response.data;
  };
  // ...
}

APIキーがNEXT_PUBLIC_始まるため、ブラウザ(クライアント側)に露出しており、セキュリティ上のリスクがあります。そこでAPI Routeを作成して、サーバー側でQiita APIを呼び出すように変更してください。

おわりに

いかがでしたでしょうか?
これにてNext.jsで抑えておきたい話はおおよそできたかと思います。
他にも細かい機能はたくさんあるのでぜひ調べてみてもいいかもですね。

詳しく解説した動画を投稿しているのでよかったらみてみてください!

JISOUのメンバー募集中!

プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページからお気軽にカウンセリングをお申し込みください!

図解ハンズオンたくさん投稿しています!

本チュートリアルのレビュアーの皆様

次回のハンズオンのレビュアーはXにて募集します。

  • kazu様
  • ナツキ
  • k-kamijima様
  • asahi
  • ARISA様
  • Kosukes様
303
326
5

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
303
326

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?