Open19

Next.js 13.4 で React Server Component を試す

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

このスクラップについて

2023 年 5 月5 日(金)に Next.js 13.4 がリリースされた。

Next.js 13.4 では App Router が stable になり、今後本格的に普及していくと思われる。

このスクラップでは実際に App Router を使って色々試してみてその過程を記録していきたい。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

create-next-app ヘルプ確認

コマンド
npx create-next-app --help
実行結果
Usage: create-next-app <project-directory> [options]

Options:
  -V, --version                        output the version number
  --ts, --typescript                   
  
    Initialize as a TypeScript project. (default)
  
  --js, --javascript                   
  
    Initialize as a JavaScript project.
  
  --tailwind                           
  
    Initialize with Tailwind CSS config. (default)
  
  --eslint                             
  
    Initialize with eslint config.
  
  --app                                
  
    Initialize as an App Router project.
  
  --src-dir                            
  
    Initialize inside a `src/` directory.
  
  --import-alias <alias-to-configure>  
  
    Specify import alias to use (default "@/*").
  
  --use-npm                            
  
    Explicitly tell the CLI to bootstrap the application using npm
  
  --use-pnpm                           
  
    Explicitly tell the CLI to bootstrap the application using pnpm
  
  -e, --example [name]|[github-url]    
  
    An example to bootstrap the app with. You can use an example name
    from the official Next.js repo or a GitHub URL. The URL can use
    any branch and/or subdirectory
  
  --example-path <path-to-example>     
  
    In a rare case, your GitHub URL might contain a branch name with
    a slash (e.g. bug/fix-1) and the path to the example (e.g. foo/bar).
    In this case, you must specify the path to the example separately:
    --example-path foo/bar
  
  --reset-preferences                  
  
    Explicitly tell the CLI to reset any stored preferences
  
  -h, --help                           output usage information

--app オプションから前まであった experimental が無くなっている。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

プロジェクト作成

コマンド
npx create-next-app hello-app-router \
  --typescript \
  --tailwind \
  --eslint \
  --app \
  --src-dir \
  --import-alias "@/*" \
  --use-npm
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

layout.tsx を眺める

src/app/layout.tsx
import './globals.css'
import { Inter } from 'next/font/google'

const inter = Inter({ subsets: ['latin'] })

export const metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body className={inter.className}>{children}</body>
    </html>
  )
}


http://localhost:3000/ の HTML コード

export const metadata を使うと title タグや meta タグが良い感じに出力されるようだ。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

page.tsx を書き換えてみる

タイマーを使ってサーバー側でのデータ取得を模倣してみる。

src/app/page.tsx
type Topic = {
  id: number;
  title: string;
  publishedAt: Date;
};

export default async function Home() {
  const topics = await new Promise<Topic[]>((resolve) => {
    setTimeout(() => {
      resolve([
        { id: 1, title: "Topic title 1", publishedAt: new Date() },
        { id: 2, title: "Topic title 2", publishedAt: new Date() },
        { id: 3, title: "Topic title 3", publishedAt: new Date() },
      ]);
    }, 500);
  });

  return (
    <main className="container mx-auto px-4">
      <h1 className="text-4xl mt-3 mb-3">Home</h1>
      <section>
        <h2 className="text-2xl mb-3">Topics</h2>
        <ul>
          {topics.map((topic) => (
            <li key={topic.id}>
              {topic.title} / {topic.publishedAt.toDateString()}
            </li>
          ))}
        </ul>
      </section>
    </main>
  );
}

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

地味に嬉しいこと

Date オブジェクトをそのまま渡せる。

getServerSideProps だと文字列に変換するか superjson を使う必要があるのでこれは便利だ。

データを取得するコードと HTML を近くに書けるのも複数ファイルを切り替える必要がなくて良い。

まあこの点については getServerSideProps を使った場合と同じだが。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

useState() を使おうとしてみる

src/app/page.tsx
import { useState } from "react";

type Topic = {
  id: number;
  title: string;
  publishedAt: Date;
};

export default async function Home() {
  const [counter, setCounter] = useState(0);

  const topics = await new Promise<Topic[]>((resolve) => {
    setTimeout(() => {
      resolve([
        { id: 1, title: "Topic title 1", publishedAt: new Date() },
        { id: 2, title: "Topic title 2", publishedAt: new Date() },
        { id: 3, title: "Topic title 3", publishedAt: new Date() },
      ]);
    }, 500);
  });

  return (
    <main className="container mx-auto px-4">
      <h1 className="text-4xl mt-3 mb-3">Home</h1>
      <section>
        <h2 className="text-2xl mb-3">Topics</h2>
        <ul>
          {topics.map((topic) => (
            <li key={topic.id}>
              {topic.title} / {topic.publishedAt.toDateString()}
            </li>
          ))}
        </ul>
      </section>
    </main>
  );
}

期待通りエラーが発生した。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ここで Client Component の出番

コマンド
touch src/app/counter.tsx
src/app/counter.tsx
"use client";

import { useState } from "react";

export default function Counter() {
  const [counter, setCounter] = useState(0);

  return (
    <>
      <button
        className="border p-1 mb-3"
        type="button"
        onClick={() => setCounter(counter + 1)}
      >
        Increment
      </button>
      <dl>
        <dt>Counter</dt>
        <dd>{counter}</dd>
      </dl>
    </>
  );
}
src/app/page.tsx
import Counter from "./counter";

type Topic = {
  id: number;
  title: string;
  publishedAt: Date;
};

export default async function Home() {
  const topics = await new Promise<Topic[]>((resolve) => {
    setTimeout(() => {
      resolve([
        { id: 1, title: "Topic title 1", publishedAt: new Date() },
        { id: 2, title: "Topic title 2", publishedAt: new Date() },
        { id: 3, title: "Topic title 3", publishedAt: new Date() },
      ]);
    }, 500);
  });

  return (
    <main className="container mx-auto px-4">
      <h1 className="text-4xl mt-3 mb-3">Home</h1>
      <section className="mb-3">
        <h2 className="text-2xl mb-3">Topics</h2>
        <ul>
          {topics.map((topic) => (
            <li key={topic.id}>
              {topic.title} / {topic.publishedAt.toDateString()}
            </li>
          ))}
        </ul>
      </section>
      <section className="mb-3">
        <h2 className="text-2xl mb-3">Countere</h2>
        <Counter></Counter>
      </section>
    </main>
  );
}


実行結果

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Layout コーディング

src/app/layout.tsx
import Link from "next/link";
import "./globals.css";
import { Inter } from "next/font/google";

const inter = Inter({ subsets: ["latin"] });

export const metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <header>
          <nav aria-label="Header" className="p-4 border-b">
            <Link href="/">Hello App Router</Link>
          </nav>
        </header>
        {children}
      </body>
    </html>
  );
}


実行結果

Layout とは関係ないが Inter って何だろう?

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

可変フォント

https://developer.mozilla.org/ja/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide

可変フォント (Variable fonts) は幅、太さ、スタイルごとに個別のフォントファイルを用意するのではなく、書体のさまざまなバリエーションを 1 つのファイルに組み込むことができる OpenType フォント仕様の進化版です。

可変フォントの場合は weight を指定する必要がある。

コード例
import { Roboto } from 'next/font/google';
 
const roboto = Roboto({
  weight: '400',
  subsets: ['latin'],
});

Google Fonts の可変フォントは下記から調べられる。

https://fonts.google.com/variablefonts

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

下層ページを作る

コマンド
mkdir src/app/about
touch src/app/about/page.tsx
src/app/about/page.tsx
import Link from "next/link";

export default function About() {
  return (
    <main className="container mx-auto p-4">
      <h1 className="mb-3">About</h1>
      <Link href="/" className="border p-2 inline-block">
        &larr; Home
      </Link>
    </main>
  );
}


http://localhost:3000/about

下層ページにも src/app/layout.tsx がしっかり適用されている。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

トップページにリンクを設ける

src/app/page.tsx
import Link from "next/link";
import Counter from "./counter";

type Topic = {
  id: number;
  title: string;
  publishedAt: Date;
};

export default async function Home() {
  const topics = await new Promise<Topic[]>((resolve) => {
    setTimeout(() => {
      resolve([
        { id: 1, title: "Topic title 1", publishedAt: new Date() },
        { id: 2, title: "Topic title 2", publishedAt: new Date() },
        { id: 3, title: "Topic title 3", publishedAt: new Date() },
      ]);
    }, 500);
  });

  return (
    <main className="container mx-auto px-4">
      <h1 className="text-4xl mt-3 mb-3">Home</h1>
      <section className="mb-3">
        <h2 className="text-2xl mb-3">Topics</h2>
        <ul>
          {topics.map((topic) => (
            <li key={topic.id}>
              {topic.title} / {topic.publishedAt.toDateString()}
            </li>
          ))}
        </ul>
      </section>
      <section className="mb-3">
        <h2 className="text-2xl mb-3">Countere</h2>
        <Counter></Counter>
      </section>
      <section className="mb-3">
        <h2 className="text-2xl mb-3">Navigation</h2>
        <Link href="/about" className="border p-2 inline-block">
          About &rarr;
        </Link>
      </section>
    </main>
  );
}


http://localhost:3000/