1. HOME
  2. ブログ
  3. エンジニアリング
  4. 【Next.js×Prisma】サーバコンポーネントでデータ取得

BLOG

ブログ

エンジニアリング

【Next.js×Prisma】サーバコンポーネントでデータ取得

はじめに

前回の記事では、Vercel PostgresやPrismaの設定、テストデータの作成を行いました。

今回の章では、実際にデータを取得して画面に表示してみましょう。

学べること

  • Prismaでデータベースから必要なデータを取得
  • サーバコンポーネントでデータを取得して表示

注意事項

※認証機能を導入していないため、

user+1@example.com

のユーザでログインしていると仮定して進めていきます。

後の章で認証機能を導入する際に修正します。

チュートリアルの全体像

本チュートリアルを通じて学べる内容は以下のようになります。

  1. Next.jsの開発環境をセットアップ
  2. 画面デザインしながら基礎を学ぼう
  3. App Routerを理解して使ってみよう
  4. Next.js+Postgres+PrismaでDB設計
  5. サーバコンポーネントでデータ取得
  6. サーバアクションでデータ変更
  7. Vercel Blobで画像アップロード実装
  8. Suspenseでローディングをリッチに
  9. Zodを使ってバリデーション実装
  10. useActionStateでローディング実装
  11. Auth.js v5で認証機能導入
  12. 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を使ってデータを変更してみましょう。

  1. この記事へのコメントはありません。

  1. この記事へのトラックバックはありません。

関連記事