LoginSignup
24
10

Next.js公式チュートリアルで学ぶ:App Routerの導入と基本的な使い方

Last updated at Posted at 2023-06-24

どうもこんにちはたくびー(@takubii)です。
今回はNext.js 13から新しく利用できるようになったApp Routerについて書きたいと思います。
Next.jsの公式チュートリアルではPage Routerが使われているので、こちらをApp Routerに置き換えていきます。
公式チュートリアルに沿って置き換えていくので、参照しながら進めてください。

各章ごとにPage RouterとApp Routerでの実装方法の違いを簡単に述べながら進めていきます。
全体の完成コードはこちらです。

各章をブランチごとに実装し進めたので、詳細はそちらを参照しながらご覧ください。

install

インストールは以下のような設定で行いました。
バージョンが変わって手順が違う可能性もありますので、参考程度にしてください。

terminal
✔ 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.csspage.module.cssを移動してください。

layout.tsx

<Head>コンポーネントは使用できなくなっているので、layout.tsxmetadataに追加します。
faviconはsrc/app以下にあれば自動的に認識されるようです。
metadataに含まれていたdescriptionは削除してください。
また、next font関連のコードは削除しましょう。

page.module.css

Home.module.csspage.module.cssに変更してください。

global.css

<style jsx>, <style jsx global>内のCSSはglobal.cssに移動してください。(page.tsxに書いてあるとuse clientが要求されます。)
チュートリアルにあった元のglobal.cssの内容は使われていないので削除しても問題ありません。

page.tsx

index.jspage.tsxに変更します。

1章まとめ

チュートリアルの最初から大きく変わったところはpage.tsxの使用の部分です。
App Routerではフォルダがパスの役割を果たし、その配下のファイルには各々規定の名前を持ったファイルが配置されます。
詳しくはこちらでどういったファイルが配置されるのか確認できます。今回使用しているのはpage.tsxlayout.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

getStaticPathsgenerateStaticParamsに変更します。
getStaticPropsで行っていたデータ取得処理はコンポーネント内に直接記述してください。
コンポーネントの引数がparamsに変更されます。
getPostDataを非同期関数にした場合、Postコンポーネントを非同期コンポーネントに変更してください。(例:export default async function Post(...) {...}
next/head<Head>タグは使えないので、動的なメタデータの追加にgenerateMetadataを使用します。

5章まとめ

getStaticPathsgenerateStaticParamsに変わっていたり、メタデータを動的作成するためにgenerateMetadata関数を使ったりとこの章では新しい関数が多く出てきたと思います。
また、getStaticPathsgenerateStaticParamsでは返すオブジェクトの形が少し変わっていたりしたので、Page Routerと差異が大きい部分だなと感じました。

API Routes

Api Routeのフォルダ構成はpages/api/hello.jsapp/api/hello/route.tsに変更します。
内容もhandler関数ではなく、HTTPメソッドごとに関数を作成してください。(GET,POST,PUT,DELETEなど)
今回はGET関数を作成し、レスポンスはNextResponseで作成しています。

6章まとめ

App Routerで最も変更されている箇所だと思っているのがこのApi Routeです。
チュートリアルでは主題ではなかったので、Jsonを返すだけの簡単なAPIの作成で終わっていますが、機能としてはかなり大きく変わっています。
今回はGETメソッドの実装のためにapp/api/hello/route.tsGET()関数を作成しました。
必要に応じて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

各ソースコード一覧

src/app/layout.tsx
import '../styles/globals.css';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang='en'>
      <body>{children}</body>
    </html>
  );
}

src/app/page.tsx
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&apos;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>
  );
}

src/app/posts/[id]/page.tsx
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>
  );
}

src/app/components/layout.tsx
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>
  );
}

src/app/components/date.tsx
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>;
}

src/app/lib/posts.ts
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 }),
  };
}

src/app/api/hello/route.ts
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を使う方の参考になれば幸いです。

では、今回はこのあたりで締めようと思います。
ここまで読んでくださりありがとうございます。
また機会があればお会いしましょう。

24
10
0

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
24
10