はじめに
Next.js13がリリースされてもうすぐ1ヶ月が経ちましたが、まだ公式チュートリアルが最新版に対応したものが出ていないため、公式ドキュメントを読み進めながら{JSON} Placeholdeと連携したSSG対応の簡易ブログを作成しました。
本記事は、ハンズオン形式でこのブログの作成方法をまとめています。
ハンズオンの流れ
- 開発環境の構築
- Routing
- レイアウトコンポーネント
- ページコンポーネント
- ブログ一覧を作成
- 記事ページを作成
- タイトルをつける
開発環境の構築
インストール
まず、開発環境が以下の要件を満たしていることを確認してください。
- Node.js 16.8以降(
node -v
で確認) - MacOS、Windows(WSLを含む)、Linuxに対応
ターミナルを開いて作業フォルダに移動後、下記のいずれかのコマンドを実行します。
npx create-next-app@latest --experimental-app .
yarn create next-app --experimental-app .
pnpm create next-app --experimental-app .
インストール前に2つの設問に答えます。
質問:このプロジェクトでTypeScriptを使いたいですか?
Would you like to use TypeScript with this project? › No / Yes
本記事ではTypeScriptを使うためYes
を選択しています。
質問:このプロジェクトでESLintを使いたいですか?
Would you like to use ESLint with this project? › No / Yes
本記事ではESLintを使うためYes
を選択しています。
回答が終わるとインストールが行われます。
npmスクリプト
package.jsonのscriptsには下記のnpmスクリプトが用意されています。
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
}
}
- dev: Next.jsを開発モードで開始します。
- build: 本番用のアプリケーションをビルドします。
- start: Next.jsのプロダクションサーバーを起動します。
- lint: Next.jsの組み込みESLintを設定します。
レイアウトの確認などはdev
、SSG/SSRの動きを確認する場合はbuild
とstart
を組み合わせて使います。
動作確認
dev
を実行してhttp://localhost:3000
にアクセスすると、下記の画面が表示されることを確認します。
ディレクトリ構造
動作確認後のディレクトリ構造は下記の通りです。
特に重要なディレクトリは以下の3つです。
- .next:
dev
やbuild
した際に自動生成されるディレクトリです。静的ファイルやフェッチデータのキャッシュなどが含まれているため、変更は厳禁です。 - app: Routingのルートディレクトリです。詳細は後述しますが、Next.js13では、このディレクトリを中心に開発を行います。
- pages: API Routesのディレクトリです。
Routing
/app
のディレクトリ構造
-
global.css
: アプリ全体のスタイルシート -
page.module.css
: ページ単位のスタイルシート -
head.tsx
: 特殊ファイル。共通のタグを設定することができます。 -
layout.tsx
: 特殊ファイル。共通のレイアウトを設定することができます。 -
page.tsx
: 特殊ファイル。コンテンツを設定することができます。
新しいページを追加する
/app
ディレクトリにblog
フォルダと、その中にpage.tsx
作成します。
/app/blog/page.tsx
を開き、下記コードを追加します。
const page = () => {
return <div>blog</div>;
};
export default page;
http://localhost:3000/blog
にアクセスして、下記の内容が表示されることを確認します。
解説
/app
とその中に入れたフォルダー内にpage.tsx
が存在すれば、下記のページにアクセスすることができます。
- /app/page.tsx =>
http://localhost:3000/
- /app/blog/page.tsx =>
http://localhost:3000/blog
記事ページを作成する
/app/blog
ディレクトリに[id]
ディレクトリと、その中にpage.tsx
作成します。
/app/blog/[id]/page.tsx
を開き、下記コードを追加します。
type paramsType = {
id: string;
};
export async function generateStaticParams(): Promise<paramsType[]> {
return [{ id: '1' }, { id: '2' }];
}
const page = ({ params }: { params: paramsType }) => {
return <div>blog記事:その{params.id}</div>;
};
export default page;
export const dynamicParams = false;
http://localhost:3000/blog/1
にアクセスして、下記の内容が表示されることを確認します。
http://localhost:3000/blog/2
にアクセスして、下記の内容が表示されることを確認します。
解説: generateStaticParams関数
フォルダ名を大括弧で囲い、page.tsxでgenerateStaticParams
関数の戻り値を以下のように設定すると、[id]
を1
や2
として扱うことができるようになります。
return [{ id: '1' }, { id: '2' }];
Next.js13では大括弧で囲ったフォルダをDynamic Segments
と呼称しています。
参考文献:Dynamic Segmentsの公式ドキュメント
フォルダ名とgenerateStaticParams
関数の戻り値のオブジェクト名が同じであれば良いので、例えばフォルダ名を[slug]
にしたい場合、戻り値は次のようにします。
return [{ slug: '1' }, { slug: '2' }];
解説: ページコンポーネントのprops
Dynamic Segmentsのページコンポーネントではparams
propを受け取っています。
const page = ({ params }: { params: paramsType }) => {
return <div>blog記事:その{params.id}</div>;
};
export default page;
http://localhost:3000/blog/1
にアクセスした場合、params
には{id:1}
入ります。
http://localhost:3000/blog/2
にアクセスした場合、params
には{id:2}
入ります。
解説: dynamicParams
最後の行は動的パラメータの設定です。(デフォルトは有効)
export const dynamicParams = false;
有効の場合、generateStaticParams
関数で設定したセグメント以外でもアクセスが可能となります。
設定を有効にして、http://localhost:3000/blog/3
にアクセスすると、params
に{id:3}
を渡してSSRされた結果が表示されます。
無効の場合、generateStaticParams
で設定しなかったセグメンにアクセスしようとすると、404ページが表示されます。
設定を無効にして、http://localhost:3000/blog/3
にアクセスしようとすると、下記のページが表示されます。
このページはフレームワークが用意したデフォルトの404ページとなります。
レイアウトコンポーネント(/app/layout.tsx)
/app/layout.tsx
を開き、下記のコードの差し替えます。
import Image from 'next/image';
import './globals.css';
import styles from './page.module.css';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="jp">
<head />
<body>
<div className={styles.container}>
{children}
<footer className={styles.footer}>
<a
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Powered by{' '}
<span className={styles.logo}>
<Image
src="/vercel.svg"
alt="Vercel Logo"
width={72}
height={16}
/>
</span>
</a>
</footer>
</div>
</body>
</html>
);
}
{children}
にpageコンポーネント(/app/page.tsx
、/app/blog/page.tsx
、/app/blog/[id]/page.tsx
)の内容が入ります。
ただし、<div data-nextjs-scroll-focus-boundary></div>
でラップされるため、レイアウトがうまく反映できない問題が発生します。
これを回避するため、global.css
の最後の行に次のスタイルを追加します。
[data-nextjs-scroll-focus-boundary] {
display: contents;
}
また、Blogのリストや記事のスタイルを追加するため、page.module.css
を下記の内容に書き換えます。
.container {
padding: 0 2rem;
display: flex;
min-height: 100vh;
flex-direction: column;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
row-gap: 64px;
}
.footer {
display: flex;
padding: 2rem 0;
border-top: 1px solid #eaeaea;
justify-content: center;
align-items: center;
}
.footer a {
display: flex;
justify-content: center;
align-items: center;
flex-grow: 1;
}
.title {
margin: 0;
line-height: 1.15;
font-size: 4rem;
font-style: normal;
font-weight: 800;
letter-spacing: -0.025em;
}
.title a {
text-decoration: none;
color: #0070f3;
}
.title a:hover,
.title a:focus,
.title a:active {
text-decoration: underline;
}
.title,
.description {
text-align: center;
}
.description {
margin: 4rem 0;
line-height: 1.5;
font-size: 1.5rem;
}
.article {
margin: 4rem 0;
line-height: 1.5;
font-size: 1.5rem;
}
.code {
background: #fafafa;
border-radius: 5px;
padding: 0.75rem;
font-size: 1.1rem;
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
Bitstream Vera Sans Mono, Courier New, monospace;
}
.grid {
display: flex;
justify-content: center;
flex-wrap: wrap;
max-width: 1200px;
}
.card {
margin: 1rem;
padding: 1.5rem;
text-align: left;
color: inherit;
text-decoration: none;
border: 1px solid #eaeaea;
border-radius: 10px;
transition: color 0.15s ease, border-color 0.15s ease;
width: 300px;
}
.card:hover,
.card:focus,
.card:active {
color: #0070f3;
border-color: #0070f3;
}
.card h2 {
margin: 0 0 1rem 0;
font-size: 1.5rem;
}
.card p {
margin: 0;
font-size: 1.25rem;
line-height: 1.5;
}
.logo {
height: 1em;
margin-left: 0.5rem;
}
@media (max-width: 600px) {
.grid {
width: 100%;
flex-direction: column;
}
}
@media (prefers-color-scheme: dark) {
.title {
background: linear-gradient(180deg, #ffffff 0%, #aaaaaa 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-fill-color: transparent;
}
.title a {
background: linear-gradient(180deg, #0070f3 0%, #0153af 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-fill-color: transparent;
}
.card,
.footer {
border-color: #222;
}
.code {
background: #111;
}
.logo img {
filter: invert(1);
}
}
スタイルについて
本記事のスタイルはCSS Modules
とGlobal Styles
を用いています。
この他にもSass
やTailwind CSS
、CSS-in-JS
が使えますが、CSS-in-JS
は使える場所がクライアントコンポーネントのみに制限されています。
また、CSS-in-JSを用いたChakra UIやMUIも同様の制限を受けます。
サーバーコンポーネントとクライアントコンポーネントについて
Next.js13のアプリはサーバーコンポーネント
とクライアントコンポーネント
を組み合わせて開発を進めていきます。
/app
ディレクトリ内のコンポーネントは、設定変更しない限り全てサーバーコンポーネントとして動作します。
両コンポーネントはできる事が排他的となっており、サーバーコンポーネントは、
- データフェッチ可能
- バックエンドリソースへのアクセス可能
- 機密情報(アクセストークン、APIキーなど)をサーバーに保管する
- 大きな依存関係をサーバーに残すことで、クライアントサイドのJavaScriptを削減する
クライアントコンポーネントは、
- インタラクティブ機能とイベントリスナー(onClick()、onChange()など)を使用可能
- ステートとライフサイクルエフェクト (useState(), useReducer(), useEffect(), etc.)が使用可能
- ブラウザ専用のAPIを使用可能
- ステート、エフェクト、ブラウザ専用APIに依存するカスタムフックの使用可能
- React Class componentsを使用可能
となっています。
クライアントコンポーネントを使うには、ファイルの1行目に下記のコメントを追加することで有効になります。
'use client';
サーバーコンポーネントはステートを持つ事ができませんが、クライアントコンポーネントをインポートして配置する事ができます。
本記事で作成するBlogは、errorコンポーネント(error.tsx)を除き全てサーバーコンポーネントとなります。
ページコンポーネント(/app/page.tsx)
/app/page.tsx
を開いてコードを修正します。
import Link from 'next/link';
import styles from './page.module.css';
export default function Home() {
return (
<main className={styles.main}>
<h1 className={styles.title}>
Welcome to <a href="https://nextjs.org">Next.js 13!</a>
</h1>
<div className={styles.grid}>
<Link href="/blog" className={styles.card}>
<h2>Blog(SSG版)</h2>
<p>ビルド時にSSGされたBlogはこちら</p>
</Link>
</div>
</main>
);
}
http://localhost:3000/
にアクセスして、下記の内容が表示されることを確認します。
解説:<Link/>
アプリケーション内のページ移動は<Link/>
を用います。
リンク先はhref
で指定します。
<Link href="/blog"></Link>
これでhttp://localhost:3000/
からhttp://localhost:3000/blog
へ移動する事ができるようになります。
(Next.js12以前は内側に<a>
タグが入れる必要がありましたが、不要になりました。)
ブログ一覧を作成(/app/blog/page.tsx)
/app/blog/page.tsx
では、{JSON} Placeholde
のposts
エンドポイントからブログ一覧を取得して、ブログ一覧をカード形式で表示させます。
取得関数を作成
src
フォルダとその中にlib
フォルダを作成して、getJsonPlaceholder.ts
ファイルを作成します。
ファイルを開いて、下記のコードを追加します。
export type postType = {
userId: number;
id: number;
title: string;
body: string;
};
type postsType = postType[];
export const getPosts = async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/posts');
if (!res.ok) throw new Error('getPostsで異常が発生しました。');
return res.json() as Promise<postsType>;
};
通信が完了すると、postsType
型のデータを取得する事ができます。
fetch関数は通信を失敗するなどしてレスポンスを受け取れなくてもErrorを投げないため、res.okのフラグ(レンスポンスを受け取れなかった場合false)を用いて任意のエラーを投げるようにしています。
if (!res.ok) throw new Error('getPostsで異常が発生しました。');
ページコンポーネントを修正(/app/blog/page.tsx)
/app/blog/page.tsx
を開いてコードを修正します。
import Link from 'next/link';
import { getPosts } from '../../src/lib/getJsonPlaceholder';
import styles from '../page.module.css';
const page = async () => {
const posts = await getPosts();
return (
<main className={styles.main}>
<h1 className={styles.title}>Blog List</h1>
<div className={styles.grid}>
{posts.map(({ id, title }) => (
<Link key={id} href={`/blog/${id}`} className={styles.card}>
<div>{title}</div>
</Link>
))}
</div>
</main>
);
};
export default page;
ここでSSGの動きを確認するため一度dev
サーバを止めて、build
を実行します。
下記のメッセージが表示されたらビルド完了です。
λ (Server) server-side renders at runtime (uses getInitialProps or getServerSideProps)
○ (Static) automatically rendered as static HTML (uses no initial props)
● (SSG) automatically generated as static HTML + JSON (uses getStaticProps)
次にstart
を実行して、http://localhost:3000/
にアクセスして、下記のカードをクリックします。
解説:ページコンポーネントがasyncファンクション
サーバーコンポーネントはデータフェッチができるようにasyncファンクションにする事ができます。
const page = async () => {
const posts = await getPosts();
return ();
};
解説:取得データの処理
取得データは配列のため、map関数で展開します。
{posts.map(({ id, title }) => (
<Link key={id} href={`/blog/${id}`} className={styles.card}>
<div>{title}</div>
</Link>
))}
hrefはDynamic Segments
に対応させるため、次のようにしています。
href={`/blog/${id}`}
データの取得はビルド時にサーバー上で行った後、静的ファイルを生成します。
こうすることで、クライアント側でフェッチ関数をダウンロードして実行することなく一覧をすばやく表示する事ができるようになります。
記事ページを作成(/app/blog/[id]/page.tsx)
取得関数を作成
/src/lib/getJsonPlaceholder.ts
のgetPosts
関数の後に次の関数を追加します。
export const getPost = async (id: string) => {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
if (!res.ok) throw new Error('getPostで異常が発生しました。');
return res.json() as Promise<postType>;
};
引数に文字列型のidを渡すことで、個別のデータを取得する事ができます。
次に/app/blog/[id]/page.tsx
を書き換えます。
import { getPost, getPosts } from '../../../src/lib/getJsonPlaceholder';
import styles from '../../page.module.css';
type paramsType = {
id: string;
};
export async function generateStaticParams(): Promise<paramsType[]> {
const posts = await getPosts();
return posts.map((post) => ({
id: post.id.toString(),
}));
}
const page = async ({ params }: { params: paramsType }) => {
const { title, body } = await getPost(params.id);
const bodys = body.split('\n');
return (
<main className={styles.main}>
<h1 className={styles.title}>{title}</h1>
<div className={styles.article}>
{bodys.map((body, i) => (
<p key={i}>{body}</p>
))}
</div>
</main>
);
};
export default page;
export const dynamicParams = false;
Blog一覧の時と同様にサーバを止めて、buildを実行します。
ビルドが完了したら、start
を実行して、http://localhost:3000/blog
にアクセスして、一番上のカードをクリックします。
解説:generateStaticParams()
[id]
ディレクトリをDynamic Segments
に対応させるため、一覧習得で作成したgetPostsを用いてデータ配列を取得して、map関数でオブジェクト配列に変換して返しています。
export async function generateStaticParams(): Promise<paramsType[]> {
const posts = await getPosts();
return posts.map((post) => ({
id: post.id.toString(),
}));
}
一覧作成とDynamic Segments
で同一のフェッチ関数を実行していますが、Next.js13では1度フェッチした結果を/.next
でキャッシュしているため、フェッチ回数は1度だけで以降はキャッシュデータを渡してサーバー処理を行います。
解説:ページコンポーネント内のデータ取得
ページコンポーネント内でデータの取得を行います。
一覧の時と同様にページコンポーネントはasyncファンクションにしています。
bodyには改行\n
が含まれているため、\n
で区切って文字列の配列に変換しています。
const page = async ({ params }: { params: paramsType }) => {
const { title, body } = await getPost(params.id);
const bodys = body.split('\n');
return ...
}
タイトルをつける(/app/blog/head.tsx, /app/blog/[id]/head.tsx)
/app/blog
ディレクトリにhead.tsx
を作成して、下記のコードを追加します。
const head = () => {
return <title>Blog List</title>;
};
export default head;
/app/blog/[id]
ディレクトリにhead.tsx
を作成して、下記のコードを追加します。
import { getPost } from '../../../src/lib/getJsonPlaceholder';
type paramsType = {
id: string;
};
const head = async ({ params }: { params: paramsType }) => {
const { title } = await getPost(params.id);
return <title>{title}</title>;
};
export default head;
http://localhost:3000/blog
にアクセスすると、タイトルがBlog List
に変わります。
Blog Listから任意の記事に移動すると、タイトルがブログのタイトルに変わります。
解説:head.tsx
各ディレクトリに配置したヘッダファイルにはそれぞれページタイトルを設定しています。
/app/head.tsx
でもページタイトルを設定していますが、この場合は各ディレクトリに配置したヘッダファイルが優先されます。
/app/blog/[id]/head.tsx
では/app/blog/[id]/page.tsx
と同様にparams
を受け取る事ができます。
また、フェッチ(キャッシュ取得)も可能ですので、取得データを用いてページタイトルに設定しています。
最後に
作成したプロジェクトをGitHubに上げています。
今回はSSGで動くように作成しました。
フェッチの設定を変えることで簡単にSSR化できるため、今後別記事でまとめる予定です。