記事概要
以下のチュートリアルの各章の概要と出てくる機能や説明していることのまとめ。App Routerの機能を改めて見つめたい人。
Vercelにデプロイする前提だが、DBをdockerで用意してローカルで行ったのでVercelの登録が面倒な人にも参考になるかも。
1. Getting Started
サンプルアプリをインストールする。特筆すべき点なし。サンプルアプリに機能追加をしていく、というチュートリアル
2. CSS Styling
- CSSプロパティの設定にtailwindcssを使っている。
決められた値を組み合わせて簡単にスタイリングできるというライブラリ。
例えば<div className="flex p-4 md:h-52">
とした場合display:flex
padding: 1rem
- ウィンドウサイズがmedium以上の場合は
height: 13rem
という意味になる。
※0.25remが基本単位らしい。一応0.25remが基本単位というのはここが根拠かも?https://tailwindcss.com/docs/theme
--spacing: 0.25rem;
...
--spacing-* Spacing and sizing utilities like px-4, max-h-16, and many more
- CSSスタイリングの条件分岐はclsx
statusがXXならYYというスタイルを適用する、というわかりやすいライブラリ。
export default function InvoiceStatus({ status }: { status: string }) {
return (
<span
className={clsx(
'inline-flex items-center rounded-full px-2 py-1 text-sm',
{
'bg-gray-100 text-gray-500': status === 'pending',
'bg-green-500 text-white': status === 'paid',
},
)}
>
3. Optimizing Fonts and Images
フォントを変えたり画像をレスポンシブにしている。
画像についてはtailwindcssの書き方で、以下はモバイルサイズのウィンドウだとhidden, mediumサイズ以上だとblockとして表示されるように設定している。
<Image
src="/hero-desktop.png"
width={1000}
height={760}
className="hidden md:block"
alt="Screenshots of the dashboard project showing desktop version"
/>
4. Creating Layouts and Pages
App Routerでダミーページを作っておき、layout.tsx
でサイドバーを表示するのがメイン。
layout.tsx
を配置すると、その配下のpage.tsx
全部に影響する。
layout.tsx
はchildrenを必須引数としてとり、これは配下のページを指す。
5. Navigating Between Pages
サイドバーにある、各ページへのリンクを表示するNavLinksコンポーネントを作る。
-
<Link>
は<a>
タグと同じだがbackgroundでprefecthしてくれていたり色々やってくれている -
usePathname
で現在のURLのパスを取得できる - サイドバーの現在表示しているページだけハイライトするのもclsxでやっている
import { usePathname } from 'next/navigation';
import clsx from 'clsx';
// ...
//※linksはハードコードでした
export default function NavLinks() {
const pathname = usePathname();
return (
<>
{links.map((link) => {
const LinkIcon = link.icon;
return (
<Link
key={link.name}
href={link.href}
className={clsx(
'flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3',
{
'bg-sky-100 text-blue-600': pathname === link.href,
},
)}
>
<LinkIcon className="w-6" />
<p className="hidden md:block">{link.name}</p>
</Link>
);
})}
</>
);
}
6. Setting Up Your Database
DBを使うためにVercelへデプロイしようという内容。
ローカルでやりたかったのでdockerでDBを起動した。
おまけ〜ローカル環境での工夫
- bcryptは環境の問題で動かなかったのでbycryptjsをインストールしてそれを使った。import文を変えるのみでコードは変えないでも動いた。
- dockerでpostgres
.env
ファイルの書き方がvercelのものしか書いておらず困ったが、以下でいけた。ユーザー名とDB名が'postgres'の場合
POSTGRES_URL="postgres://postgres:password@localhost:5432/postgres"
POSTGRES_PRISMA_URL="postgres://postgres:password@localhost:5432/postgres"
POSTGRES_URL_NON_POOLING="postgres://postgres:password@localhost:5432/postgres"
POSTGRES_USER="postgres"
POSTGRES_HOST="localhost"
POSTGRES_PASSWORD="password"
POSTGRES_DATABASE="postgres"
以降でDB接続する箇所はTLSを使わないので、全て以下のようにsslオプションを削除して使うことになる。
- const sql = postgres(process.env.POSTGRES_URL!, { ssl: 'require' });
+ const sql = postgres(process.env.POSTGRES_URL!);
7. Fetching Data
-
DBからデータをfetchするにはいくつかの方法がある
-
Route Handlerを使ってAPIを作る。
- クライアントコードからクエリする場合にはセキュリティ上必要になる。
-
app/api/route.ts
にexport async function GET(request: Request) {}
とかのメソッドを用意するだけ。 - 実はChapter6でDBにデータをseedするときにさらっと使った。
- サーバーコンポーネントを使う。チュートリアルではこの方法。
-
Route Handlerを使ってAPIを作る。
-
request waterfallsにならないように並列でリクエストしよう
以下みたいな順次処理をpromise.all()
を使って書こう、というような内容。
しかしこれだと一つのリクエストだけ遅かったら全部処理止まるけどどうする?という疑問提起をして次のChapterに続く。
const revenue = await fetchRevenue();
const latestInvoices = await fetchLatestInvoices(); // wait for fetchRevenue() to finish
const {
numberOfInvoices,
numberOfCustomers,
totalPaidInvoices,
totalPendingInvoices,
} = await fetchCardData(); // wait for fetchLatestInvoices() to finish
8. Static and Dynamic Rendering
- Static RenderingとDynamic Rendering
最近のNext.jsではSSR, SSGという言葉ではなくSSG, ISRあたりをDynamic, SSRをStaticと表現しているらしい。 - Dynamic Renderingでは遅いリクエストがあると表示が遅い。
前Chapterからの続き。これをどう解消するか?
9. Streaming
読み込みが完了するまで別のページやコンポーネントを表示することができる機能。
- page単位では
loading.tsx
ファイルを用意する
ページのレンダリングが完了するまではloading.tsx
ファイルの内容が表示され、レンダリングが終わると自動で本来のページに切り替わる。
loading.tsx
は配下のページ全てに影響してしまうため、Route Groupsを使って影響範囲を/dashboard/page.tsx
だけに変更する。
以下のように(overview)を作成するとこの部分はアクセス時のパスに影響しないため、/dashboard
にアクセスすれば変わらず/dashboard/page.tsx
が表示されるまま影響範囲を分離することができる。
app/
- └── dashboard/
- ├── page.tsx
- ├── loading.tsx
├── Invoices/
│ └── page.tsx
└── Customers/
└── page.tsx
app/
+ └── dashboard/
+ ├── (overview)/
+ │ ├── page.tsx
+ │ └── loading.tsx
├── Invoices/
│ └── page.tsx
└── Customers/
└── page.tsx
- コンポーネント単位ではReactの
<Suspense>
機能を利用する
時間のかかる処理をコンポーネント(以下ではRevenueChart
)内の処理に含め、処理待ち中に表示したいコンポーネントをfallbackに指定する。コンポーネントの処理が終わったら非同期に本来のコンポーネントに切り替わってくれる。
import { Suspense } from 'react';
import { RevenueChartSkeleton } from '@/app/ui/skeletons';
//...
return (
// ...
<Suspense fallback={<RevenueChartSkeleton />}>
<RevenueChart />
</Suspense>
10. Partial Prerendering
Partial PrerenderingはNext15の時点でもまだexperimentalな機能だが、これからデフォルトのレンダリングモデルになっていくと思っているとのこと。
設定ファイルで有効化するだけで、必要な場合に自動で実施されてくれるようになる。
<Suspense>
単体とPartial Prerenderingの違い
<Suspense>
をすることでそのコンポーネントだけ非同期的に後から表示させられるが、これは"リクエスト時に"ページの静的な部分のレンダリングが行われている。
一方Partial Prerenderingを有効にすると、<Suspense>
で囲ったコンポーネントだけが欠けた(holeとした)静的ページを"ビルド時に"作成するようだ。
イメージ的にはSSR+fallbackか、SSG+fallbackかという感じか。
公式の説明も動画かどこかにあるのかな?という気がするが、誰かが答えてくれているdiscussionがあったのでそちらを貼る。
11. Adding Search and Pagination
検索用のコンポーネントとページネーション用のコンポーネントを作成する。URLのクエリパラメータを使ってページやコンポーネント間でのパラメータをやりとりしているため、そのための手段が多数使われている。
next/navigation
<Link>
以外で以下の3つの機能が登場する
-
useSearchParams
- 現在のURLからパラメータを取得できる。/dashboard/invoices?page=1&query=pending
なら{page: '1', query: 'pending'}
-
usePathname
- 現在のURLからパスが取得できる。/dashboard/invoices
なら/dashboard/invoices
-
useRouter
- URL操作を行うメソッドたちを返す。今回は遷移するreplace
しか使わないが、push
やback
,refresh
なども使える。
Debounce
以下だと入力するたびにハンドラが走って鬱陶しい。これを一定期間止まった場合にのみイベントが走るようにすること。
onChange={(e) => {
handleSearch(e.target.value);
}}
ライブラリを使うと簡単。
具体的なコードは次のSearchコンポーネントを参照。
コード例〜Searchコンポーネント
next/navigationのメソッドたちとDebounceはSearch
コンポーネントで使われている。
// 入力された値termをクエリパラメータに足したURLに遷移する
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams, usePathname, useRouter } from 'next/navigation';
import { useDebouncedCallback } from 'use-debounce';
export default function Search() {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
// 0.3秒入力がない場合にのみイベントが発生するようにdebounceするライブラリでラップ
const handleSearch = useDebouncedCallback((term) => {
const params = new URLSearchParams(searchParams);
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
replace(`${pathname}?${params.toString()}`);
}, 300);
.....
{/* URLとsyncするようにdefaultValueを設定 */}
<input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
placeholder={placeholder}
onChange={(e) => {
handleSearch(e.target.value);
}}
defaultValue={searchParams.get('query')?.toString()}
/>
...
}
searchParams
Page.tsx
の予約されたパラメータとしてsearchParams
がある
/dashboard/invoices/page.tsx
で以下のように使われ、URLパラメータを取得してTableコンポーネントに渡している。
export default async function Page(props: {
searchParams?: Promise<{
query?: string;
page?: string;
}>;
}) {
const searchParams = await props.searchParams;
const query = searchParams?.query || '';
const currentPage = Number(searchParams?.page) || 1;
...
<Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
<Table query={query} currentPage={currentPage} />
</Suspense>
...
searchParamsとuseSearchParamsの違い
先述のSearchコンポーネントではnext/navigationのuseSearchParams
を使ってURLパラメータを取得した。
クライアントコンポーネントだとフックを使ってuseSearchParams
を使い、サーバーコンポーネントの場合はsearchParams
を使うようだ。
<Search> is a Client Component, so you used the useSearchParams() hook to access the params from the client.
<Table> is a Server Component that fetches its own data, so you can pass the searchParams prop from the page to the component.
As a general rule, if you want to read the params from the client, use the useSearchParams() hook as this avoids having to go back to the server.
Pagenation
完成系ではないが、useSearchParams
から現在のページを取得したらあとは以下のように地道に計算したり分岐して表示している。
https://github.com/vercel/next-learn/blob/main/dashboard/starter-example/app/ui/invoices/pagination.tsx
12. Mutating Data
Server Actions
formから直接指定できてサーバーサイドで実行されるメソッド。
// Server Component
export default function Page() {
// Action
async function create(formData: FormData) {
'use server';
// Logic to mutate data...
}
// Invoke the action using the "action" attribute
return <form action={create}>...</form>;
}
メリットの一つとしてprogressive enhancementが挙げられている。
JavaScriptをクライアント側で読み込む必要がないため、formを含んだhtmlだけ最低限の機能としてまず提供できる、ということのよう。
An advantage of invoking a Server Action within a Server Component is progressive enhancement - forms work even if JavaScript has not yet loaded on the client. For example, without slower internet connections.
なお、POSTエンドポイントが自動で作られてくれているため、自分でAPIエンドポイントを作る必要もない。
Behind the scenes, Server Actions create a POST API endpoint
型チェックライブラリ zod
コンパイル時のみではなく、実行時にも型チェックが行える。外部APIを呼び出す時とかは一応やっておくとよさそう。
'use server';
import { z } from 'zod';
const FormSchema = z.object({
id: z.string(),
customerId: z.string(),
amount: z.coerce.number(),
status: z.enum(['pending', 'paid']),
date: z.string(),
});
const CreateInvoice = FormSchema.omit({ id: true, date: true });
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
// ...
}
あとは動的ルート(/dashboard/invoices/[id]/edit/page.tsx
みたいなパス)と、データ更新したらキャッシュをrevalidateしましょう、ということを言っていた。
bind
Server Actionにform以外の変数を渡したいときはbindする。
/app/ui/invoices/edit-form.tsx
で、formの入力値にないidをupdateInvoiceアクションに渡す場合には以下のように書く。
import { updateInvoice } from '@/app/lib/actions';
const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);
return <form action={updateInvoiceWithId}>{/* ... */}</form>;
/app/lib/actions.ts
のServer Actionでは以下のように受け取れる。
export async function updateInvoice(id: string, formData: FormData) {
...
13. Handling Errors
error.tsx
: Server Actionでuncaught exceptionが発生した場合に備えて、エラーページを用意できる。
not-found.tsx
: 404エラーを出したいタイミングでnotFound();
すると表示されるページ
14. Improving Accessibility
アクセシビリティの話。
適切なHTMLタグを使ったり、altタグによる説明をしたりするとSEO上がったり、スクリーンリーダーで(画像が見えないユーザーに対しても)読み上げてもらえる、と言った点を説明している。
そこから入力値のvalidationやErrorなど、ユーザー体験的な話になっていく。
- eslint-plugin-jsx-a11yというプラグインを使うとアクセシビリティの観点からlintしてくれる(適切なaltプロパティがない、とか)。
- Server Actionでzodの型チェックのエラー時にカスタムメッセージを追加
-
useActionState
を使ったエラーメッセージの表示。
useActionState
を使うと、state, isLoading(いずれも変数名は任意)を管理できる。クライアントコンポーネント側で以下のようなことが手軽に行える新しい機能のよう。- Server Actionの結果(成功/失敗)をstateにすることでエラーハンドリング(stateで条件分岐してエラーメッセージの表示)
- (チュートリアルでは使っていないが)処理中であることを示すisLoadingによって表示コンテンツを変える
useActionState
は公式の例がわかりやすいかも。
15. Adding Authentication
-
next-authライブラリを使ったログインフォーム作成
-
middleware.ts
で定義したパスにアクセスしたときにauth.config.ts
のcallbacksで定義した認可チェックが行われ、未ログインだった場合はauth.config.ts
のpagesで定義した/loginページにリダイレクトする。 -
next-authではsignOut, SignInなどは
use server
で行う必要がある。クライアント側で使用したい場合はnext-auth/reactを使う必要がある。
ただし、ログインなどの処理はサーバー側でやる方がセキュアで推奨と公式に書いてある。
https://nextjs.org/docs/app/guides/authentication#auth-js-server-actions
Since Server Actions always execute on the server, they provide a secure environment for handling authentication logic.
use clientとuse server
use clientとuse serverは対になるものではない。
use client: クライアントで動作する
use server: サーバーサイドの関数をクライアントコードから呼び出せるようにマークする。server actionsを作るときによく使う。
無印: サーバーで動作するコンポーネントになる。use serverとは違う