【Next.js×Prisma】サーバコンポーネントでデータ取得
目次
はじめに
前回の記事では、Vercel PostgresやPrismaの設定、テストデータの作成を行いました。
今回の章では、実際にデータを取得して画面に表示してみましょう。
学べること
- Prismaでデータベースから必要なデータを取得
- サーバコンポーネントでデータを取得して表示
注意事項
※認証機能を導入していないため、
user+1@example.com
のユーザでログインしていると仮定して進めていきます。
後の章で認証機能を導入する際に修正します。
チュートリアルの全体像
本チュートリアルを通じて学べる内容は以下のようになります。
- Next.jsの開発環境をセットアップ
- 画面デザインしながら基礎を学ぼう
- App Routerを理解して使ってみよう
- Next.js+Postgres+PrismaでDB設計
- サーバコンポーネントでデータ取得
- サーバアクションでデータ変更
- Vercel Blobで画像アップロード実装
- Suspenseでローディングをリッチに
- Zodを使ってバリデーション実装
- useActionStateでローディング実装
- Auth.js v5で認証機能導入
- Route HandlersでAPIを設計
動画で学びたい方はこちらから!
Prismaの基礎知識
PrismaはNode.js向けの人気のORMです。
TypeScriptで型安全にコードが定義できるのが人気の理由です。
スキーマ定義
まずは、前回の章で定義したスキーマを見てみましょう。
prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("POSTGRES_PRISMA_URL")
directUrl = env("POSTGRES_URL_NON_POOLING")
}
model Post {
id String @id @default(cuid())
image String
caption String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map(name: "posts")
}
model Comment {
id String @id @default(cuid())
text String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
postId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map(name: "comments")
}
model User {
id String @id @default(cuid())
name String
email String @unique
password String
image String?
description String?
posts Post[]
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map(name: "users")
}
schemaは、
- generator
- datasource
- model
というように、各ブロックに分けて記述します。
generatorブロック
ここでは、プロバイダとしてprisma-client-jsを指定しています。
datasourceブロック
ここでは、データベースの接続設定をしています。
本アプリの場合は、Vercel Postgresの接続設定を記述しています。
modelブロック
ここではアプリケーションのデータベースの構造を定義します。
本アプリでは、以下の3つのモデルを定義しています。
- Post
- Comment
- User
モデルは複数のフィールド定義で構成されます。
Postモデルを例に見ていきましょう。
model Post {
id String @id @default(cuid())
image String
caption String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map(name: "posts")
}
フィールド定義は、
- フィールド名
- 型定義
- 属性
の順に構成されます。
この定義を元にPrismaが自動でデータベース構造を作成してくれます。
Prisma Client
Prisma Clientは、データベースへのアクセスをわかりやすく記述する機能を提供しています。
Prismaのschemaファイルは、データベース定義を自動で行うだけでなく、Prisma Clientの設定も自動で行います。特に、各モデルの型情報を設定してくれるため、TypeScriptの型支援を受けながら記述することができて便利です。
それでは、Prisma Clientを用いたよく使うデータ操作を紹介します。
データ取得(複数)
import prisma from "@/lib/prisma";
const posts = await prisma.post.findMany();
データ取得(1件)
const post = await prisma.post.findFirst({ where: { id: "1" } });
データ作成
await prisma.user.create({
data: {
name: "user+1",
email: "user+1@example.com",
password: "password",
},
})
データ更新
await prisma.post.update({
where: { id: "1", userId: "2" },
data: {
caption: "ここに更新するキャプション",
},
});
データ削除
await prisma.post.delete({
where: { id: "1", userId: "2" },
});
サーバコンポーネントの基礎知識
Next.jsのApp Routerでは、サーバのみで実行されるReact Server Component(略称RSC)を基本としています。
非同期コンポーネントとして記述することができて、サーバのみで実行される特性を活かしたシンプルなデータ取得が可能になります。
データ表示
ダッシュボード
lib/apis.ts
import prisma from "@/lib/prisma";
export async function fetchDashboard() {
const email = "user+1@example.com";
try {
return await prisma.user.findFirstOrThrow({
where: { email },
select: {
id: true,
name: true,
image: true,
description: true,
posts: {
orderBy: {
createdAt: "desc",
},
},
},
});
} catch (error) {
console.error("Database Error:", error);
throw new Error("Failed to fetch.");
}
}
app/(main)/dashboard/page.tsx
async function Dashboard() {
const user = await fetchDashboard();
return (
<div className="mx-auto max-w-5xl">
<div className="mt-8 flex bg-white p-4">
{user.image && (
<Image
className="block aspect-[1/1] size-24 rounded-full object-cover"
src={user.image}
width={96}
height={96}
alt="user icon"
/>
)}
{!!user.image || <IconSkeleton />}
<div className="pl-4">
<p className="text-lg font-semibold text-black">{user.name}</p>
{user.description && (
<p className="whitespace-pre-wrap font-medium">
{user.description}
</p>
)}
{!!user.description || (
<p className="whitespace-pre-wrap text-sm opacity-20">
🐾🐾🐾 「プロフィールを編集」から
<br />
自己紹介を入力しましょう 🐾🐾🐾
</p>
)}
<div className="mt-4 flex">
<p className="text-sm font-semibold text-black">
投稿{user.posts.length}件
</p>
<Link
href="/profile"
className="ml-2 rounded border px-2 text-sm font-semibold text-black"
>
プロフィールを編集
</Link>
</div>
</div>
</div>
<div className="my-8 grid grid-cols-3 gap-1 bg-white">
{user.posts.map((post) => {
return (
<Link href={`/posts/${post.id}/edit`} key={post.id}>
<Image
className="aspect-[1/1] w-full object-cover"
src={post.image}
alt="post"
width={300}
height={300}
/>
</Link>
);
})}
</div>
</div>
);
新着投稿
lib/apis.ts
export async function fetchLatestPosts() {
try {
return await prisma.post.findMany({
select: {
id: true,
image: true,
caption: true,
createdAt: true,
user: {
select: {
id: true,
name: true,
image: true,
},
},
},
orderBy: { createdAt: "desc" },
});
} catch (error) {
console.error("Database Error:", error);
throw new Error("Failed to fetch posts");
}
}
app/(main)/posts/page.tsx
import { fetchLatestPosts } from "@/lib/apis";
// ...省略...
async function Posts() {
const posts = await fetchLatestPosts();
return (
<div className="mx-auto my-8 max-w-5xl bg-white">
<div className="grid grid-cols-3 gap-1">
{posts.map((post) => {
return (
<Link key={post.id} href={`/posts/${post.id}`}>
<Image
className="aspect-[1/1] w-full object-cover"
src={post.image}
alt="posts"
width={400}
height={400}
/>
<div className="flex items-center justify-between border p-1">
<div className="flex items-center">
{post.user.image && (
<Image
className="block aspect-square size-6 rounded-full object-cover"
src={post.user.image}
width={32}
height={32}
alt="user icon"
/>
)}
<p className="ml-2 text-sm font-semibold text-black">
{post.user.name}
</p>
</div>
<p className="hidden text-xs text-gray-500 md:block">
{post.createdAt.toLocaleString("ja-JP")}
</p>
</div>
</Link>
);
})}
</div>
</div>
);
}
投稿編集
lib/apis.ts
export async function fetchPost(id: string) {
try {
return await prisma.post.findFirstOrThrow({
where: { id },
select: {
id: true,
image: true,
caption: true,
createdAt: true,
user: {
select: {
id: true,
name: true,
image: true,
description: true,
},
},
},
});
} catch (error) {
console.error("Database Error:", error);
throw new Error("Failed to fetch post.");
}
}
app/pages/(main)/posts/[id]/edit/page.tsx
import BreadCrumbs from "@/components/layouts/bread-crumbs";
import { fetchPost } from "@/lib/apis";
import Image from "next/image";
import Link from "next/link";
export default async function Page({ params }: { params: { id: string } }) {
const id = params.id;
const post = await fetchPost(id);
return (
<>
<BreadCrumbs title="投稿編集 🐾" />
<div className="mx-auto max-w-5xl">
<div className="mt-8 grid grid-cols-1 gap-1 bg-white md:grid-cols-2">
<Image
className="aspect-[1/1] w-full object-cover"
src={post.image}
width={640}
height={640}
alt="post image"
/>
<div className="p-2">
<h3 className="mb-2 font-semibold">オーナー</h3>
<div className="flex rounded border bg-white p-2">
{post.user.image && (
<Image
className="block aspect-square size-12 rounded-full object-cover"
src={post.user.image}
width={96}
height={96}
alt="user icon"
/>
)}
<div className="pl-4">
<div>
<p className="text-lg font-semibold text-black">
{post.user.name}
</p>
<p className="whitespace-pre-wrap text-sm font-medium">
{post.user.description}
</p>
</div>
</div>
</div>
<form>
<h3 className="mt-2 font-semibold">キャプション</h3>
<textarea
name="caption"
rows={8}
className="mt-2 w-full rounded border border-gray-300 p-2.5 focus:border-blue-500 focus:ring-blue-500"
>
{post.caption}
</textarea>
<div className="mt-4 flex items-center">
<button
className="inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 active:bg-gray-900"
type="submit"
>
更新
</button>
<Link
href={`/posts/${post.id}`}
className="ml-4 rounded border p-2 text-xs"
>
投稿画面へ
</Link>
</div>
</form>
<form>
<button
type="submit"
className="mt-4 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-xs font-semibold uppercase tracking-widest text-gray-700 shadow-sm transition duration-150 ease-in-out hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-25"
>
削除
</button>
</form>
</div>
</div>
</div>
</>
);
}
投稿詳細
lib/apis.ts
export async function fetchPostwithComments(id: string) {
try {
return await prisma.post.findFirstOrThrow({
where: { id },
select: {
id: true,
image: true,
caption: true,
createdAt: true,
user: {
select: {
id: true,
name: true,
image: true,
description: true,
},
},
comments: {
select: {
id: true,
text: true,
createdAt: true,
user: {
select: {
name: true,
image: true,
description: true,
},
},
},
orderBy: {
createdAt: "desc",
},
},
},
});
} catch (error) {
console.error("Database Error:", error);
throw new Error("Failed to fetch post.");
}
}
app/(main)/posts/[id]/page.tsx
import BreadCrumbs from "@/components/layouts/bread-crumbs";
import { fetchPostwithComments } from "@/lib/apis";
import Image from "next/image";
import Link from "next/link";
export default async function Page({ params }: { params: { id: string } }) {
const id = params.id;
const post = await fetchPostwithComments(id);
return (
<>
<BreadCrumbs title="投稿詳細 🐾" />
<div className="mx-auto max-w-5xl">
<div className="mt-8 grid grid-cols-1 gap-1 bg-white md:grid-cols-2">
<Image
className="aspect-[1/1] w-full object-cover"
src={post.image}
width={640}
height={640}
alt="post"
/>
<div className="p-2">
<div className="flex items-center justify-between">
<h3 className="font-semibold">オーナー</h3>
<div className="text-xs text-gray-500">
公開日: {post.createdAt.toLocaleString("ja-JP")}
</div>
</div>
<Link href={`/users/${post.user.id}`}>
<div className="mt-2 flex rounded border bg-white p-2">
{post.user.image && (
<Image
className="block aspect-[1/1] size-12 rounded-full object-cover"
src={post.user.image}
width={96}
height={96}
alt="user icon"
/>
)}
<div className="pl-4">
<p className="text-lg font-semibold text-black">
{post.user.name}
</p>
<p className="whitespace-pre-wrap text-sm font-medium">
{post.user.description}
</p>
</div>
</div>
</Link>
<h3 className="mt-2 font-semibold">キャプション</h3>
<p className="whitespace-pre-wrap py-2">{post.caption}</p>
<h3 className="mt-2 font-semibold">
コメント{post.comments.length}件
</h3>
<div className="max-h-96 overflow-y-scroll">
{post.comments.map((comment) => {
return (
<div key={comment.id} className="border-b py-2">
<div className="mb-1 flex items-center">
{comment.user.image && (
<Image
src={comment.user.image}
className="size-6 rounded-full"
width={48}
height={48}
alt="user icon"
/>
)}
<div className="ml-1 text-sm font-medium text-gray-800">
{comment.user.name}
</div>
<div className="ml-1 text-xs text-gray-500">
{comment.createdAt.toLocaleString("ja-JP")}
</div>
</div>
<p className="text-sm">{comment.text}</p>
</div>
);
})}
</div>
<form>
<div className="mt-2 flex">
<input
name="text"
className="mr-2 grow rounded-md border border-gray-300 pl-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
placeholder="コメントを追加..."
/>
<button
type="submit"
className="inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 active:bg-gray-900"
>
送信
</button>
</div>
</form>
</div>
</div>
</div>
</>
);
}
オーナー一覧
lib/apis.ts
export async function fetchLatestUsers() {
try {
return await prisma.user.findMany({
orderBy: { createdAt: "desc" },
select: {
id: true,
name: true,
image: true,
description: true,
_count: {
select: { posts: true },
},
},
});
} catch (error) {
console.error("Database Error:", error);
throw new Error("Failed to fetch users.");
}
}
app/(main)/users/page.tsx
import IconSkeleton from "@/components/skeletons/icon-skeleton";
import { fetchLatestUsers } from "@/lib/apis";
async function Users() {
const users = await fetchLatestUsers();
return (
<div className="mx-auto my-8 max-w-5xl bg-white shadow-sm">
<div className="grid grid-cols-1 gap-1 lg:grid-cols-2">
{users.map((user) => {
return (
<Link href={`/users/${user.id}`} key={user.id}>
<div className="flex bg-white p-4">
{user.image && (
<Image
src={user.image}
className="block aspect-[1/1] rounded-full object-cover"
width={96}
height={96}
alt="user icon"
/>
)}
{!!user.image || <IconSkeleton />}
<div className="pl-4">
<div>
<p className="text-lg font-semibold text-black">
{user.name}
</p>
<p className="whitespace-pre-wrap font-medium">
{user.description}
</p>
<div className="mt-4 flex">
<p className="text-sm font-semibold text-black">
投稿{user._count.posts}件
</p>
</div>
</div>
</div>
</div>
</Link>
);
})}
</div>
</div>
);
}
オーナー詳細
lib/apis.ts
export async function fetchUser(id: string) {
try {
return await prisma.user.findFirstOrThrow({
select: {
id: true,
name: true,
image: true,
description: true,
posts: {
orderBy: {
createdAt: "desc",
},
},
},
where: { id },
});
} catch (error) {
console.error("Database Error:", error);
throw new Error("Failed to fetch user.");
}
}
app/(main)/users/[id]/page.tsx
import BreadCrumbs from "@/components/layouts/bread-crumbs";
import IconSkeleton from "@/components/skeletons/icon-skeleton";
import { fetchUser } from "@/lib/apis";
import Image from "next/image";
import Link from "next/link";
export default function Page({ params }: { params: { id: string } }) {
return (
<>
<BreadCrumbs title="オーナー 🐾" />
<UserDetail params={params} />
</>
);
}
async function UserDetail({ params }: { params: { id: string } }) {
const id = params.id;
const user = await fetchUser(id);
return (
<div className="mx-auto max-w-5xl">
<div className="mt-8 flex bg-white p-4">
{user.image && (
<Image
className="block aspect-[1/1] rounded-full object-cover"
src={user.image}
width={96}
height={96}
alt="user icon"
/>
)}
{!!user.image || <IconSkeleton />}
<div className="pl-4">
<p className="text-lg font-semibold text-black">{user.name}</p>
<p className="whitespace-pre-wrap font-medium">{user.description}</p>
<div className="mt-4 flex">
<p className="text-sm font-semibold text-black">
投稿{user.posts.length}件
</p>
</div>
</div>
</div>
<div className="my-8 bg-white">
<div className="grid grid-cols-3 gap-1">
{user.posts.map((post) => {
return (
<Link href={`/posts/${post.id}`} key={post.id}>
<Image
className="aspect-[1/1] w-full object-cover"
src={post.image}
width={400}
height={400}
alt="user icon"
/>
</Link>
);
})}
</div>
</div>
</div>
);
}
プロフィール
libs/api.ts
export async function fetchMe() {
const email = "user+1@example.com";
try {
return await prisma.user.findFirstOrThrow({
select: {
name: true,
email: true,
image: true,
description: true,
},
where: { email },
});
} catch (error) {
console.error("Database Error:", error);
throw new Error("Failed to fetch posts.");
}
}
app/(main)/profile/page.tsx
import BreadCrumbs from "@/components/layouts/bread-crumbs";
import ProfileEditForm from "@/components/pages/profile/profile-form";
import { fetchMe } from "@/lib/apis";
export default async function Page() {
const user = await fetchMe();
return (
<>
<BreadCrumbs title="プロフィール編集 🐾" />
<div className="mx-auto mt-8 max-w-5xl bg-white p-4">
<header>
<h2 className="text-lg font-medium text-gray-900">アカウント情報</h2>
<p className="mt-1 text-sm text-gray-600">
アカウント情報やメールアドレスの更新
</p>
</header>
<ProfileEditForm user={user} />
</div>
</>
);
}
まとめ
以上、Prismaでデータ取得して、Next.jsで画面表示する方法を学びました。
次回は、Server Actionsを使ってデータを変更してみましょう。
この記事へのコメントはありません。