どうもこんにちはたくびー(@takubii)です。
今回はNext.js 13から新しく利用できるようになったApp Routerについて書きたいと思います。
Next.jsの公式チュートリアルではPage Routerが使われているので、こちらをApp Routerに置き換えていきます。
公式チュートリアルに沿って置き換えていくので、参照しながら進めてください。
各章ごとにPage RouterとApp Routerでの実装方法の違いを簡単に述べながら進めていきます。
全体の完成コードはこちらです。
各章をブランチごとに実装し進めたので、詳細はそちらを参照しながらご覧ください。
install
インストールは以下のような設定で行いました。
バージョンが変わって手順が違う可能性もありますので、参考程度にしてください。
✔ Would you like to use TypeScript with this project? … No / Yes → Yes
✔ Would you like to use ESLint with this project? … No / Yes → Yes
✔ Would you like to use Tailwind CSS with this project? … No / Yes → No
✔ Would you like to use `src/` directory with this project? … No / Yes → Yes
✔ Use App Router (recommended)? … No / Yes → Yes
✔ Would you like to customize the default import alias? … No / Yes → No
Create a Next.js App
今回はインストール時にsrc
フォルダを使用する設定にしたので、以降プロジェクト直下に作成されるファイル、フォルダはsrc
フォルダ内に作成してください。
stylesフォルダ
src
直下にstyles
フォルダを作成し、global.css
、page.module.css
を移動してください。
layout.tsx
<Head>
コンポーネントは使用できなくなっているので、layout.tsx
のmetadata
に追加します。
faviconはsrc/app
以下にあれば自動的に認識されるようです。
metadataに含まれていたdescriptionは削除してください。
また、next font関連のコードは削除しましょう。
page.module.css
Home.module.css
はpage.module.css
に変更してください。
global.css
<style jsx>, <style jsx global>
内のCSSはglobal.cssに移動してください。(page.tsx
に書いてあるとuse client
が要求されます。)
チュートリアルにあった元のglobal.css
の内容は使われていないので削除しても問題ありません。
page.tsx
index.js
はpage.tsx
に変更します。
1章まとめ
チュートリアルの最初から大きく変わったところはpage.tsx
の使用の部分です。
App Routerではフォルダがパスの役割を果たし、その配下のファイルには各々規定の名前を持ったファイルが配置されます。
詳しくはこちらでどういったファイルが配置されるのか確認できます。今回使用しているのはpage.tsx
とlayout.tsx
の2種類です。
Navigate between Pages
posts/first-post/page.tsx
pages/posts/first-post.js
の代わりに作成しています。
先述の通り、App Routerではフォルダ構成がAPIのルートとなり、その配下にpage.tsxでページ、layout.tsxで共通レイアウトを作成します。
2章まとめ
ファイルを作成し、ページのルーティングを確認しているだけの章なので、App RouterとPage Routerの違いが一番分かる章なのではないでしょうか。
App Routerでは基本的にフォルダ数が多くなる傾向ですが、慣れてくるとファイルの規則などで分かりやすくなります。
Assets, Metadata, and CSS
components/layout.tsx
next/head
は使えないので、export const metaData ...
というオブジェクトを作り、トップページにmetadataを追加しています。
※metadataは後々app/page.tsx
に移動しました。とりあえずチュートリアル通りに進め最後にmetadataをリファクタします。
public/next.svg, public/vercel.svg, styles/page.module.css
これらの使わないものは削除します。
3章まとめ
こちらで分かりにくいなと思った点は画像のパスがどこにあるのかです。
public
がルートとなり、画像の指定は次のように/images/profile.jpg
となります。
faviconに関してはsrc/app
配下にfavicon.ico
ファイルがあれば自動的に認識されるようです。
Pre-rendering and Data Fetching
lib/posts.ts
postsDirectory
のパスはpath.join(process.cwd(), '/src/posts')
にする必要があります。(srcディレクトリを使用している場合)
Typescriptで記述する際にallPostsData
の戻り値return { id, ...matterResult.data }
だと型が適用されず、ソートの際にエラーが出るので、こちらをreturn { id, ...(matterResult as { title: string; date: string }) }
とすることで解決しました。
app/page.tsx
getStaticProps
はPage Routerの機能なので使用できません。
App Routerでは直接コンポーネントの中にデータ取得メソッドを書くだけで問題ないです。
4章まとめ
App RouterにすることでgetStaticProps
などのPage Routerで使われていた特定の関数を書く必要がなくなりました。
このあたりが特にApp Routerが初心者向けになった部分なのかなと感じています。個人的にはとても分かりやすくなっていると思います。
Dynamic Routes
lib/posts.ts
getAllPostIds
関数の戻り値は、チュートリアルでは[{ params: { id: '1' }, ... }]
となっていましたが、generateStaticParams
ではparams
オブジェクトではなく、[{ id: '1' }, ...]
となるため、変更してください。
app/posts/[id]/page.tsx
getStaticPaths
をgenerateStaticParams
に変更します。
getStaticProps
で行っていたデータ取得処理はコンポーネント内に直接記述してください。
コンポーネントの引数がparams
に変更されます。
getPostData
を非同期関数にした場合、Post
コンポーネントを非同期コンポーネントに変更してください。(例:export default async function Post(...) {...}
)
next/head
の<Head>
タグは使えないので、動的なメタデータの追加にgenerateMetadata
を使用します。
5章まとめ
getStaticPaths
がgenerateStaticParams
に変わっていたり、メタデータを動的作成するためにgenerateMetadata
関数を使ったりとこの章では新しい関数が多く出てきたと思います。
また、getStaticPaths
とgenerateStaticParams
では返すオブジェクトの形が少し変わっていたりしたので、Page Routerと差異が大きい部分だなと感じました。
API Routes
Api Routeのフォルダ構成はpages/api/hello.js
をapp/api/hello/route.ts
に変更します。
内容もhandler
関数ではなく、HTTPメソッドごとに関数を作成してください。(GET,POST,PUT,DELETEなど)
今回はGET
関数を作成し、レスポンスはNextResponse
で作成しています。
6章まとめ
App Routerで最も変更されている箇所だと思っているのがこのApi Routeです。
チュートリアルでは主題ではなかったので、Jsonを返すだけの簡単なAPIの作成で終わっていますが、機能としてはかなり大きく変わっています。
今回はGETメソッドの実装のためにapp/api/hello/route.ts
にGET()
関数を作成しました。
必要に応じてPOSTやDELETEメソッドも作成できるので、興味のある方はこちらを詳しくご覧ください。
ソースコード
今回のチュートリアルのApp Router対応で使ったソースコードを記載します。
CSSファイルやMDファイルは一緒なので、Typescriptファイルを載せますので、参考にしてください。
ディレクトリ構成
nextjs-blog-tutorial
├── README.md
├── next-env.d.ts
├── next.config.js
├── package-lock.json
├── package.json
├── public
│ └── images
│ └── profile.jpg
├── src
│ ├── app
│ │ ├── api
│ │ │ └── hello
│ │ │ └── route.ts
│ │ ├── favicon.ico
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── posts
│ │ └── [id]
│ │ └── page.tsx
│ ├── components
│ │ ├── date.tsx
│ │ ├── layout.module.css
│ │ └── layout.tsx
│ ├── lib
│ │ └── posts.ts
│ ├── posts
│ │ ├── pre-rendering.md
│ │ └── ssg-ssr.md
│ └── styles
│ ├── globals.css
│ └── utils.module.css
└── tsconfig.json
各ソースコード一覧
import '../styles/globals.css';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang='en'>
<body>{children}</body>
</html>
);
}
import Link from 'next/link';
import { getSortedPostsData } from '@/lib/posts';
import Date from '@/components/date';
import Layout from '@/components/layout';
import utilStyles from '@/styles/utils.module.css';
export const siteTitle = 'Next.js Sample Website';
export const metadata = {
title: siteTitle,
description: 'Learn how to build a personal website using Next.js',
openGraph: {
images: [
{
url: `https://og-image.vercel.app/${encodeURI(
siteTitle
)}.png?theme=light&md=0&fontSize=75px&images=https%3A%2F%2Fassets.vercel.com%2Fimage%2Fupload%2Ffront%2Fassets%2Fdesign%2Fnextjs-black-logo.svg`,
},
],
title: siteTitle,
},
twitter: {
card: 'summary_large_image',
},
};
export default function Home() {
const allPostsData = getSortedPostsData();
return (
<Layout home>
<section className={utilStyles.headingMd}>
<p>[Your Self Introduction]</p>
<p>
(This is a sample website - you'll be building a site like this on{' '}
<a href='https://nextjs.org/learn'>our Next.js tutorial</a>.)
</p>
</section>
<section>
<h2 className={utilStyles.headingLg}>Blog</h2>
<ul className={utilStyles.list}>
{allPostsData.map(({ id, date, title }) => (
<li className={utilStyles.listItem} key={id}>
<Link href={`/posts/${id}`}>{title}</Link>
<br />
<small className={utilStyles.lightText}>
<Date dateString={date} />
</small>
</li>
))}
</ul>
</section>
</Layout>
);
}
import { Metadata } from 'next';
import Date from '@/components/date';
import Layout from '@/components/layout';
import { getAllPostIds, getPostData } from '@/lib/posts';
import utilStyles from '@/styles/utils.module.css';
// getStaticPathsのfallback: falseの代替手段
export const dynamicParams = false;
export async function generateStaticParams() {
return getAllPostIds();
}
export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
const postData = await getPostData(params.id);
return { title: postData.title };
}
export default async function Post({ params }: { params: { id: string } }) {
const postData = await getPostData(params.id);
return (
<Layout>
<article>
<h1 className={utilStyles.headingXl}>{postData.title}</h1>
<div className={utilStyles.lightText}>
<Date dateString={postData.date} />
</div>
<div dangerouslySetInnerHTML={{ __html: postData.contentHtml }} />
</article>
</Layout>
);
}
import Image from 'next/image';
import Link from 'next/link';
import styles from './layout.module.css';
import utilStyles from '../styles/utils.module.css';
const name = 'Your Name';
export default function Layout({ children, home }: { children: React.ReactNode; home?: boolean }) {
return (
<div className={styles.container}>
<header className={styles.header}>
{home ? (
<>
<Image
priority
src='/images/profile.jpg'
className={utilStyles.borderCircle}
height={144}
width={144}
alt=''
/>
<h1 className={utilStyles.heading2Xl}>{name}</h1>
</>
) : (
<>
<Link href='/'>
<Image
priority
src='/images/profile.jpg'
className={utilStyles.borderCircle}
height={108}
width={108}
alt=''
/>
</Link>
<h2 className={utilStyles.headingLg}>
<Link href='/' className={utilStyles.colorInherit}>
{name}
</Link>
</h2>
</>
)}
</header>
<main>{children}</main>
{!home && (
<div className={styles.backToHome}>
<Link href='/'>← Back to home</Link>
</div>
)}
</div>
);
}
import { parseISO, format } from 'date-fns';
export default function Date({ dateString }: { dateString: string }) {
const date = parseISO(dateString);
return <time dateTime={dateString}>{format(date, 'LLLL d, yyyy')}</time>;
}
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { remark } from 'remark';
import html from 'remark-html';
const postsDirectory = path.join(process.cwd(), '/src/posts');
export function getSortedPostsData() {
// Get file names under /src/posts
const fileNames = fs.readdirSync(postsDirectory);
const allPostsData = fileNames.map((fileName) => {
// Remove ".md" from file name to get id
const id = fileName.replace(/\.md$/, '');
// Read markdown file as string
const fullPath = path.join(postsDirectory, fileName);
const fileContents = fs.readFileSync(fullPath, 'utf8');
// Use gray-matter to parse the post metadata section
const matterResult = matter(fileContents);
// Combine the data with the id
return {
id,
...(matterResult.data as { title: string; date: string }),
};
});
// Sort posts by date
return allPostsData.sort((a, b) => {
if (a.date < b.date) {
return 1;
} else {
return -1;
}
});
}
export function getAllPostIds() {
const fileNames = fs.readdirSync(postsDirectory);
// Returns an array that looks like this:
// [{ id: 'ssg-ssr' }, { id: 'pre-rendering' },]
return fileNames.map((fileName) => {
return {
id: fileName.replace(/\.md$/, ''),
};
});
}
export async function getPostData(id: string) {
const fullPath = path.join(postsDirectory, `${id}.md`);
const fileContents = fs.readFileSync(fullPath, 'utf8');
// Use gray-matter to parse the post metadata section
const matterResult = matter(fileContents);
// Use remark to convert markdown into HTML string
const processedContent = await remark().use(html).process(matterResult.content);
const contentHtml = processedContent.toString();
// Combine the data with the id and contentHtml
return {
id,
contentHtml,
...(matterResult.data as { title: string; date: string }),
};
}
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({ text: 'Hello' });
}
終わりに
Next.js13からApp Routerが使えるようになり、とあるサイトのハンズオンで作ってみてから何か作ってみたいなと思いました。
Next.jsの公式チュートリアルを見てみるとまだPage Routerで書かれていたので、いい機会なのでPage RouterからApp Routerに置き換えるとどう言った部分で違いが出てくるのかといった点を実験してみました。
こちらの記事が新しくApp Routerを使う方の参考になれば幸いです。
では、今回はこのあたりで締めようと思います。
ここまで読んでくださりありがとうございます。
また機会があればお会いしましょう。