Open spring1018 opened 6 months ago
npm run dev
next dev
を実行するnpm run build
npm run start
next start
を実行するnext dev
とは別物layout.tsx
<Header/>
<Nav/>
<Footer/>
children
: 子 Segmentapp/photos/[photoId]
useRouter
を使うと、Link を使用せずにナビゲーションを発火できるgetServerSideProps
などを使うことでSSRを提供できていた。ただし、このSSRはブラウザ・サーバー両方で実行されるものだったuse client
は Server・Client 間の境界を宣言するもの// training/packages/training-web-2/src/app/photos/[photoId]/LikeButton.tsx
"use client"; // ★: "use client" ディレクティブを追加する
export function LikeButton({ photoId }: { photoId: string }) {
// ★: onClick イベントハンドラーを追加したい
return (
<button
onClick={() => {
console.log(`photoId ${photoId} が「いいね」されました`);
}}
>
いいね
</button>
);
}
// Server Component の実装例
export default async function ServerComponent() {
const res = await fetch("https://example.com/posts");
const posts = await res.json();
return <div>{/* posts データを使用した表示 */}</div>;
}
getPhotos
のようなデータ取得関数を都度用意するようにしている
async function getPhotos() {
const data: { photos: Photo[] } = await fetch(
"http://localhost:8080/api/photos"
).then((res) => res.json());
return data.photos.map(({ id, title }) => ({ id, title }));
}
export default async function Page() {
const photos = await getPhotos(); // <- データを取得
return (
<div className={styles.container}>
<h1>トップ画面</h1>
<ul>
{photos.map(({ id, title }) => (
<li key={id}>
<Link href={`/photos/${id}`}>{title}</Link>
</li>
))}
</ul>
</div>
);
}
Promise.all
を使うfetch("https://..."); // デフォルトでは静的データ取得となる
fetch("https://...", { cache: "no-store" }); // 動的データ取得
通常画面以外の表示の機能も補助する。SCかCCで扱う。
<Sugpense fallback={<Spinner />}
<Comment />
</ Suspense>
loading.tsx
を定義すれば勝手に適用される。// training/training-web-4/src/app/loading.tsx
export default function Loading() {
return <>...loading</>;
}
// my-app/src/app/api/hello/route.ts
export async function GET(request: Request) {
return new Response("Hello, Next.js!");
}
app/blog/[slug]/page.tsx
app/shop/[...slug]/page.tsx
/shop/a/b
→ {slug: ['a', 'b']}
app/shop/[[...slug]]/page.tsx
/shop
のリクエストにも応じる()
で Path から除外@folder
という規約を使用children
と同じように Props として渡される以下は /api/hello
のリクエストを処理する。
function 名は HTTP リクエストのメソッドに対応するもの。
// my-app/src/app/api/hello/route.ts
export async function GET() {
return new NextResponse("Hello, Next.js!");
}
以下で、app/api/photos/[photoId]/like/route.ts
に届く。
https://github.com/practical-nextjs-book/applications/blob/main/packages/sns-web-2/src/app/api/photos/%5BphotoId%5D/like/route.ts
// training/packages/training-web-4/src/app/(site)/photos/[photoId]/LikeButton.tsx
"use client";
export function LikeButton({ photoId }: { photoId: string }) {
return (
<button
onClick={() => {
fetch(`/api/photos/${photoId}/like`, {
method: "POST",
});
}}
>
いいね
</button>
);
}
// training/packages/training-web-4/src/app/api/photos/[photoId]/like/route.ts
export async function POST(
_: Request,
{ params }: { params: { photoId: string } }
) {
console.log(`photoId ${params.photoId} が「いいね」されました`);
// TODO: 誰から送られたリクエストかを cookie から特定する処理
// TODO: DBサーバーなどに永続化するための処理
return Response.json({ liked: true });
}
docker desktop のインストールを行って、以下でサーバーが起動することを確認
docker compose up -d
/api/categories
の API
https://github.com/practical-nextjs-book/applications/blob/main/packages/sns-web-2/src/app/api/categories/route.ts
// applications/packages/sns-api-2/src/app/api/categories/route.ts
import { prisma } from "@/lib/prisma";
export async function GET() {
// ★: Category テーブルのレコードをすべて取得する
const categories = await prisma.category.findMany({
include: { _count: { select: { photos: true } } },
});
console.log(`GET: /api/categories ${new Date().toISOString()}`);
return Response.json({
categories: categories.map(({ _count, ...category }) => ({
...category,
totalPhotoCount: _count.photos,
})),
});
}
視覚的に扱えて便利。自分用の作りたい (企業では有料なので)
package.json
は以下 7 │ "workspaces": [
8 │ "packages/sns-api-1",
9 │ "packages/sns-shared-ui",
10 │ "packages/sns-web-1",
11 │ "packages/sns-web-2",
12 │ "packages/sns-web-3"
13 │ ],
sns-web-1
側の package.json
には dependencies で指定されている
2 │ "dependencies": {
23 │ "clsx": "^2.0.0",
24 │ "date-fns": "^2.30.0",
25 │ "next": "14.2.2",
26 │ "react": "^18",
27 │ "react-dom": "^18",
28 │ "react-dropzone": "^14.2.3",
29 │ "sns-shared-ui": "*"
30 │ },
モノレポにおける Next.js は、別の Next.js プロジェクトのコードを直接 import してトランスパイルできる
// applications/packages/sns-web-1/next.config.mjs
const nextConfig = {
// ...
transpilePackages: ["sns-shared-ui"],
};
これで、以下のように sns-shared-ui
からコンポーネントを import できる
import { HeadGroup } from "sns-shared-ui/src/components/HeadGroup";
fetch 関数をラップした関数を services
に整理。
https://github.com/practical-nextjs-book/applications/blob/main/packages/sns-web-2/src/services/getCategory/index.ts
import { handleFailed, handleSucceed, path } from "../";
import type { Category } from "../type";
import type { PaginationProps } from "sns-shared-ui/src/components/Pagination";
export async function getCategory({
categoryName,
page = "1",
take = "10",
}: {
categoryName: string;
page?: string;
take?: string;
}): Promise<{ category: Category; pagination: PaginationProps }> {
const searchParams = new URLSearchParams({ page, take });
return fetch(path(`/api/categories/${categoryName}?${searchParams}`), {
cache: "no-store",
next: { tags: ["categories"] }, // ★: 抽象的な tag
})
.then(handleSucceed)
.catch(handleFailed);
}
共通関数:
handleSucceed
で400以上のレスポンスを reject する。
https://github.com/practical-nextjs-book/applications/blob/main/packages/sns-web-2/src/services/index.ts
export const handleSucceed = async (res: Response) => {
const data = await res.json();
if (!res.ok) {
throw new FetchError(res.statusText, res.status);
}
return data;
};
path
でホストを共通管理
https://github.com/practical-nextjs-book/applications/blob/main/packages/sns-web-2/src/services/index.ts#L1C1-L3C57
export const host = process.env.API_HOST;
export const path = (path?: string) => `${host}${path}`;
画面を提供するRouteではSEO観点でページの「title」や「description」が重要になります。3.4節「Routeのメタデータ」で解説したように、これらの「メタデータ」を設定するためには、Next.jsのAPIであるgenerateMetadata関数を使用します。 https://github.com/practical-nextjs-book/applications/blob/main/packages/sns-web-2/src/app/(site)/categories/%5BcategoryName%5D/page.tsx
getCatgories
がパラメータ違いで2回実行され、実際にリクエストが2回発生している (メモ化されていない)
getCategory
のパラメータが共通化するように共通関数を作ることで解決できるasync function getCategoryFromProps({ params }: Props) {
const categoryName =
typeof params.segments[0] === "string" ? params.segments[0] : "1";
const page =
typeof params.segments[1] === "string" ? params.segments[1] : "1";
// ★: /categories/flower/1/2 などの場合は 404 を返す
if (params.segments.length > 2) {
notFound();
}
const data = await getCategory({
categoryName,
page,
take: `${take}`,
});
return { ...data, page: +page };
}
getProfileFromScreenName
getPhotos
// applications/packages/sns-web-2/src/app/(site)/users/[screenName]/page.tsx
// 【2】特定したユーザーのIDで、投稿写真一覧を取得
const { photos } = await getPhotos({
page: "1",
take: "15",
authorId: profile.user.id,
revalidate: 60 * 60, // ★: 1時間キャッシュする
});
データ取得方法が分かれている理由は、ユーザー情報を管理しているDB(ps-db-user)が、投稿写真を管理している Web APIサーバーのDB(ps-db-data)と異なるためです。つまり、ユーザー情報の取得や更新はfetch関数ではなく、Prisma Clientを使用するということです。
const users = await prisma.user.findMany({
select: {
id: true,
name: true,
image: true,
profile: { select: { screenName: true } },
},
});
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
profileId String?
profile Profile?
accounts Account[]
sessions Session[]
}
Next.js では環境変数の読み込みを標準でサポートしているので dotenv
などのライブラリは不要。
.env
:常時読み込まれる.env.$(NODE_ENV)
:該当のNODE_ENVが有効なときに読み込まれる.env.local
:ローカル開発で常時読み込まれる .env.$(NODE_ENV).local
:ローカル開発で、かつ該当のNODE_ENVが有効なときに読み込まれる$(NODE_ENV)
には以下が入る。
これらには読み込みの優先順位がある (.env.local
> .env
)
リポジトリで管理すべきではない環境変数は .env*.local
Private な環境変数 (NEXT_PUBLIC_
接頭辞のないもの) がブラウザ向けバンドルに含まれていることを見つけると、Next.jsは空文字列に置き換える
Google Cloud でやる、省略
導入手順:
NEXTAUTH_SECRET
の設定NextAuthOptions
の設定getServerSession
の利用"use server"
ディレクティブを宣言する<form>
要素の action 属性にこの関数を渡すのが代表的な使用例formData.get("id")
のように参照できる"use client";
import { myAction } from "./actions";
export default function ClientComponent({ id }: { id: string }) {
return (
<form action={myAction}>
<input type="hidden" name="id" value={id} />
<button type="submit">Add to Cart</button>
</form>
);
export default async function myAction(formData: FormData) {
const id = formData.get("id");
if (typeof id !== "string") {
throw new Error("Validation error");
}
// ...id を使用した更新処理
}
"use client";
import { myAction } from "./actions";
export default function ClientComponent({ id }: { id: string }) {
const action = myAction.bind(null, id);
return (
<form action={action}>
<button type="submit">Add to Cart</button>
</form>
}
export default async function myAction(id: string, formData: FormData) {
// ...id を使用した更新処理
}
onSubmit
イベントハンドラを使用していた
<form>
要素の action 属性に非同期関数を渡す方式は、Progressive Enhancement を有効にする
<form>
の本質的な機能を損なわないようにする実装方針のことgetStaticProps
や getServerSideProps
などで取得したデータを子 Component に渡していたexport default async function Page({ searchParams }: Props) {
const page = typeof searchParams.page === "string" ? searchParams.page : "1";
// 【1】最新投稿写真一覧に使用するデータ
const photosData = await getPhotos({ page });
// 【2】カテゴリー一覧に使用するデータ
const categoriesData = await getCategories();
// 【3】ユーザー一覧に使用するデータ
const users = await prisma.user.findMany({
select: {
id: true,
name: true,
image: true,
profile: { select: { screenName: true } },
},
});
...
export default async function Page({ searchParams }: Props) {
// 【1】【2】【3】のデータ取得がなくなっている
return (
<div className={styles.page}>
<div className={styles.photos}>
<TopPhotos searchParams={searchParams} />
</div>
<aside className={styles.aside}>
<TopCategories />
<TopUsers />
</aside>
</div>
);
}
データ取得が子コンポーネントで行われている https://github.com/practical-nextjs-book/applications/blob/main/packages/sns-web-3/src/app/(site)/_components/TopPhotos/index.tsx#L15
export async function TopPhotos({ searchParams }: Props) {
const page = typeof searchParams.page === "string" ? searchParams.page : "1";
// 【1】最新投稿写真一覧に使用するデータ
const { photos, pagination } = await getPhotos({ page });
return (
...
export async function TopCategories() {
// 【2】カテゴリー一覧に使用するデータ
const data = await getCategories();
return (
...
export async function TopUsers() {
// 【3】ユーザー一覧に使用するデータ
const users = await prisma.user.findMany({
select: {
id: true,
name: true,
image: true,
profile: { select: { screenName: true } },
},
});
return (
...
schema ファイルの書き方
package.json
npx prisma db seed
を実行する "prisma": {
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed/index.ts"
}
prisma/seed/index.ts
prisma/seed/<folder>/fixture.json
prisma/seed/<folder>/index.ts
以下にめっちゃはまった。第二引数で受けるのか・・・
PageファイルなどではPropsからparams参照をしていましたが、Route Handlerではexportする関数の第2引数を参照します。画面のRouteと同様Route Handlerでも、Dynamic Segment値を参照できます。 import { NextRequest, NextResponse }
from "next/server"; export async function GET( request: NextRequest, { params }: { params: { photoId: string } } ) {
page.tsx
https://github.com/practical-nextjs-book/applications/blob/main/packages/sns-web-3/src/app/(site)/page.tsx コンポーネントをSCにする。
...
<div className={styles.photos}>
<TopPhotos searchParams={searchParams} />
</div>
...
component
https://github.com/practical-nextjs-book/applications/blob/main/packages/sns-web-3/src/app/(site)/_components/TopPhotos/index.tsx#L15
query parameter を取得して関数に渡す。(関数を切り出す)
ここでは page
だけを対象にする。
import { getPhotos } from "@/services/getPhotos";
...
export async function TopPhotos({ searchParams }: Props) {
const page = typeof searchParams.page === "string" ? searchParams.page : "1";
const { photos } = await getPhotos({ page });
...
@/services/getPhotos
https://github.com/practical-nextjs-book/applications/blob/main/packages/sns-web-3/src/services/getPhotos/index.ts#L12 キャッシュ処理は一旦省く。
import { handleFailed, handleSucceed, path } from "../";
import type { Photo } from "../type";
import type { PaginationProps } from "sns-shared-ui/src/components/Pagination";
type Props = {
page?: string;
};
export function getPhotos({
page = "1",
}: Props): Promise<{ photos: Photo[]; pagination: PaginationProps }> {
const searchParams = new URLSearchParams({
page,
});
return fetch(path(`/api/photos?${searchParams}`))
.then(handleSucceed)
.catch(handleFailed);
}
route.ts
import { prisma } from "@/lib/prisma";
import type { NextRequest } from "next/server";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const page = Number(searchParams.get("page") || "1");
if (isNaN(page) {
return Response.json({ message: "Invalid Params" }, { status: 400 });
}
const photos = await prisma.photo.findMany();
console.log(
`GET: /api/photos?${searchParams.toString()} ${new Date().toISOString()}`,
);
return Response.json(photos)
}