😉

Next.js(Approuter) × Prisma × Postgres × Dockerで環境構築(CRUD処理ができるところまで)

2024/08/18に公開

自己紹介

情報系の学部の大学4年生のtakumi0616と申します!
フロントエンド、機械学習、生成AIに興味があり、大学で絶賛勉強中です!

Twitter↓
https://x.com/takumi79977718

ポートフォリオ↓
https://takumi-portfolio.vercel.app/

後輩たちと作っているWebアプリ「GPT-logprobs」↓
https://gpt-logprobs.vercel.app/

はじめに

私は現在、長岡技術科学大学のNUTMEGという団体に所属しておりプログラミング等を勉強しています。今回は後輩たちと作成しているWebアプリにバックエンドを導入することになり、自分が慣れているPrismaを導入することになりました。しかし、後輩たちは全員フロントエンジニアということもあり不慣れなことから自分が勉強用のドキュメントを作成しました!

そのドキュメントをZennの記事にさせていただきました!
後輩たちにはこれを使って説明しながら行う予定なので、こっちの記事にはあまり解説など載せられていなくて申し訳ありません。

こんな記事ですが、みなさんの参考になれば幸いです。

こんな方向け

下記の技術スタックで開発してみたいけど、ネットにはドキュメントが少ないし難しそうだから今まで手が出しずらかった人!

  • Next
  • Prisma
  • Typescirpt
  • Docker
  • Postgres
  • 環境構築
  • 簡単なCRUD処理

スキップしたい方向け

cd next
npm install
npm run build
docker compose up -d
npm run migrate:init
npm run postinstall
npm run seed
npm run dev

前提

  • Windows11(macでも同じ感じ)
  • Docker Desktop
  • Ubuntus
  • github
  • git
  • Node.js

上記についてはあらかじめ環境構築をお願いします。

Githubでリポジトリの作成


自分のアカウントページのRpositoriesの、画像右上のNewボタンから新しくリポジトリを作成します。
Repository Nameなどは自由に決めてください。
Add a README fileにチェックを入れてCreate repositoryを押して完了です。

Next.js、Prisma、およびDockerを使用した開発環境の構築

このセクションでは、Next.js、Prisma、およびDockerを使用して開発環境を構築する方法について説明します。これには、アプリケーションのセットアップ、データベースの統合、およびコンテナ化のステップが含まれます。

1. Git clone

作成したリポジトリを、任意の場所にgit cloneする。

2. Next.jsアプリケーションの作成

Next.js アプリケーションを作成するために create-next-app コマンドを使用します。このコマンドは、TypeScriptのサポート、ESLintの設定、Tailwind CSSの統合、src/ ディレクトリの使用、App Routerの設定、およびデフォルトのインポートエイリアスのカスタマイズが提供されていますが、以下のようにYes/Noを選択していってください。

C:\Users\takum\Develop\studyspace\template-next-prisma-docker>npx create-next-app next --typescript
Need to install the following packages:
create-next-app@14.2.5
Ok to proceed? (y) y

√ Would you like to use ESLint? ... No / Yes
√ Would you like to use Tailwind CSS? ... No / Yes
√ Would you like to use `src/` directory? ... No / Yes
√ Would you like to use App Router? (recommended) ... No / Yes
√ Would you like to customize the default import alias (@/*)? ... No / Yes
Creating a new Next.js app in C:\Users\takum\Develop\studyspace\template-next-prisma-docker\next.

この時点でディレクトリは以下のようになっていると思います。

  • リポジトリ名(root)/Next(上記のコマンドで設定した名前)/生成された各種ファイル(appやpublicなど)

わかりやすいようにNextプロジェクトのファイル名はNextとしています。今後、基本的にはNextディレクトリ内でのみ作業やコマンドを打ちます。今後は説明省略。

3. Nextの立ち上げを確認

ターミナルで以下のコマンド群を実行してNextが立ち上がるのを確認してください。

npm install
npm run build
npm run dev

http://localhost:3000/
上記にアクセスして画面が映るのを確認してください。

4. プロジェクト内の整理・各種設定

今回のプロジェクトではeslintなどを導入していますが、お好きに設定してください。また、不要なREADME.mdやsvgも消しておくとよいです。

5. Prismaのインストール

Nextプロジェクト内にprismaをインストールしていきます。ターミナル上でディレクトリをNextプロジェクト内に移動して以下のコマンドを順次実行してください。

Prismaの追加

Prismaを開発依存関係としてインストールします。

npm install prisma --save-dev

Prismaの初期化

Prismaの設定ファイルとデータベース接続ファイルを生成します。

npx prisma init

これにより prisma/schema.prisma と .env ファイルが作成されます。

このとき、.gitignore内に.envを追加してください。
また.envの中身に以下の行があるのを確認してください。

DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"

6. Dockerを使用したPostgreSQLデータベースのセットアップ

Docker Compose ファイルの作成

PostgreSQLサーバーをコンテナとして起動するために、docker-compose.yml ファイルを作成します。
nextディレクトリ内に作成してください。(next/docker-compose.yml)

  • docker-compose.yml
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: johndoe
      POSTGRES_PASSWORD: randompassword
      POSTGRES_DB: mydb
    volumes:
      - pgdata:/var/lib/postgresql/data
    ports:
      - '5432:5432'
volumes:
  pgdata:

7. Dokcerコンテナの起動を確認

作成した docker-compose.yml ファイルを使用して、Dockerコンテナを起動します。
Docker Desktopを立ち上げた状態で以下のコマンドをターミナルで実行してください。

docker compose up -d

以下の画像のようにきちんと立ち上がっていたら環境構築自体は成功です。

データベースとPrismaの統合

PrismaがpostgreDBにアクセスしてデータに対して干渉できることを確認していきます。

1. コマンドの管理

next/package.jsonのscriptsを以下のように編集する。
lintの部分は関係ないです。

"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint --ext .js,.jsx,.ts,.tsx .",
"lint:fix": "eslint --ext .js,.jsx,.ts,.tsx --fix .",
"migrate:init": "prisma migrate dev --name init",
"postinstall": "prisma generate",
"seed": "node --loader ts-node/esm prisma/seed.ts",
"db:reset": "prisma migrate reset --force",
"studio": "prisma studio"

2. Prisma スキーマの編集

Prisma スキーマファイル (prisma/schema.prisma) にモデルを定義し、データベースの接続設定を更新します。
以下のファイルを更新してください。

  • next/prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id    Int     @id @default(autoincrement())
  email String  @unique
  name  String?
}

3. データベースマイグレーションの実行

データベースに変更を適用するために、Prismaマイグレーションを実行します。
以下のコマンドをターミナルで実行してください。

npm run migrate:init

4. Prisma Clientの生成

データベース操作のためのPrisma Clientを生成します。
以下のコマンドをターミナルで実行してください。

npm run postinstall

5. Seedデータの追加

next/prisma/seed.tsを作成する。

  • next/prisma/seed.ts
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

async function seed() {
  const user = await prisma.user.create({
    data: {
      name: 'Takahashi',
      email: 'sample@email.com',
    },
  })
  console.log(`User created: ${user.name} (ID: ${user.id})`)
}

seed()
  .catch((e) => {
    console.error(e)
    process.exit(1)
  })
  .finally(async () => {
    await prisma.$disconnect()
  })

その後、以下のコマンドをターミナルで実行してください。

npm run seed

6. Prisma Studioの使用

データベースの内容を視覚的に確認、管理するために、Prisma Studioを起動します。
以下のコマンドをターミナルで実行してください。

npm run studio

その後、以下にアクセスしてデータの追加や消去がPrisma Studio上で行えることを確認する。

http://localhost:5555

簡単なCRUD処理の実装(prismaとnextを接続)

プログラムの説明は省きます。
以下の参考URLなどをぜひ見てみてください。

1. 必要なファイルの作成

以下のファイルを作成、編集していってください。

  • next/app/tsconfig.json
{
  "compilerOptions": {
    "target": "esnext",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "baseUrl": ".",
    "paths": {
      "@/*": ["./app/*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}
  • next/app/page.tsx
'use client'

import { useState, useEffect } from 'react'
import styles from './page.module.css'
import { User } from '@/types'

function Home() {
  const [users, setUsers] = useState<User[]>([])
  const [email, setEmail] = useState('')
  const [name, setName] = useState('')
  const [selectedId, setSelectedId] = useState<number | null>(null)

  useEffect(() => {
    fetchUsers()
  }, [])

  async function fetchUsers() {
    const res = await fetch('/api/users')
    const data = (await res.json()) as User[]
    setUsers(data)
  }

  async function createUser() {
    const response = await fetch('/api/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, name }),
    })
    if (response.ok) {
      fetchUsers()
      resetForm()
    }
  }

  async function updateUser() {
    if (selectedId === null) return
    const response = await fetch(`/api/users/${selectedId}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ newEmail: email, newName: name }),
    })
    if (response.ok) {
      fetchUsers()
      resetForm()
    }
  }

  async function deleteUser(userId: number) {
    await fetch(`/api/users/${userId}`, {
      method: 'DELETE',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ userId }),
    })
    fetchUsers()
  }

  function resetForm() {
    setEmail('')
    setName('')
    setSelectedId(null)
  }

  return (
    <div className={styles.container}>
      <div className={styles.header}>
        <input
          className={styles.inputField}
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          placeholder="Email"
        />
        <input
          className={styles.inputField}
          value={name}
          onChange={(e) => setName(e.target.value)}
          placeholder="Name"
        />
        <button className={styles.button} onClick={createUser}>
          Create User
        </button>
        {selectedId && (
          <button className={styles.button} onClick={updateUser}>
            Update User
          </button>
        )}
      </div>
      <div className={styles.userList}>
        {users.map((user) => (
          <div key={user.id} className={styles.userItem}>
            ID: {user.id}, {user.email} - {user.name}
            <button
              className={styles.button}
              onClick={() => {
                setSelectedId(user.id)
                setEmail(user.email)
                setName(user.name || '')
              }}
            >
              Select
            </button>
            <button
              className={styles.button}
              onClick={() => deleteUser(user.id)}
            >
              Delete
            </button>
          </div>
        ))}
      </div>
    </div>
  )
}

export default Home

  • next/app/page.module.css
.container {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}

.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
}

.userList {
  margin-top: 20px;
}

.userItem {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 10px;
  padding: 10px;
  background: #f9f9f9;
  border-radius: 4px;
}

.inputField {
  margin-right: 10px;
  padding: 8px 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
}

.button {
  padding: 8px 12px;
  background-color: #0070f3;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.button:hover {
  background-color: #0056b3;
}

  • next/app/globals.css
body {
  background-color: #f0f7ff;
}

  • next/app/types/index.tsx
export interface User {
  id: number
  email: string
  name: string | null
}

  • next/app/lib/prisma.ts
import { PrismaClient } from '@prisma/client'

let prisma: PrismaClient

const globalForPrisma = global as unknown as {
  prisma: PrismaClient | undefined
}

if (!globalForPrisma.prisma) {
  globalForPrisma.prisma = new PrismaClient()
}
prisma = globalForPrisma.prisma

export default prisma

  • next/app/api/users/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import prisma from '@/lib/prisma'

export async function GET() {
  const users = await prisma.user.findMany()
  return new NextResponse(JSON.stringify(users), {
    headers: {
      'Content-Type': 'application/json',
      'x-total-count': String(users.length),
    },
  })
}

export async function POST(request: NextRequest) {
  const data = await request.json()
  const newUser = await prisma.user.create({
    data: {
      email: data.email,
      name: data.name,
    },
  })
  return new NextResponse(JSON.stringify(newUser), {
    status: 201,
    headers: {
      'Content-Type': 'application/json',
    },
  })
}

  • next/app/api/users/[id]/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import prisma from '@/lib/prisma'

interface Props {
  params: {
    id: string
  }
}

export async function GET(request: NextRequest, { params: { id } }: Props) {
  const user = await prisma.user.findUnique({
    where: { id: parseInt(id) },
  })
  return new NextResponse(JSON.stringify(user), {
    headers: { 'Content-Type': 'application/json' },
  })
}

export async function PUT(request: NextRequest, { params: { id } }: Props) {
  const data = await request.json()
  try {
    const updatedUser = await prisma.user.update({
      where: { id: parseInt(id) },
      data: {
        email: data.newEmail,
        name: data.newName,
      },
    })
    return new NextResponse(JSON.stringify(updatedUser), {
      headers: { 'Content-Type': 'application/json' },
    })
  } catch (error) {
    console.error(error)
    return new NextResponse(
      JSON.stringify({ error: 'User not found or update failed.' }),
      {
        status: 404,
        headers: { 'Content-Type': 'application/json' },
      },
    )
  }
}

export async function DELETE(request: NextRequest, { params: { id } }: Props) {
  await prisma.user.delete({
    where: { id: parseInt(id) },
  })
  return new NextResponse(null, { status: 204 })
}

コピペして全選択貼り付けで問題ないと思います。
すべてのファイルが作成、編集し終わったら以下のコマンドでNextを立ち上げます。
(dockerが立ち上がり続けているのを確認してから行ってください。)

npm run build
npm run dev

これで下記のような画像が以下のURLで表示されていたら成功です。
ボタンやフォームを触るとDBの中身にアクセスできると思います。

http://localhost:3000/

最後に

拙い文章で分かりにくいところも多いと思いますが最後まで見てくれて嬉しいです!

自分もかなり時間をかけてここまで作ったので、詳しい解説や動作は追記していこうと思います。もし要望や修正などありましたらコメントいただけると嬉しいです。

自分はフロントエンドが好きであり、今まで学んできたTypeScriptのみでバックもフロントもあるWebアプリを作ることのできる上記の技術スタックがお気に入りで、趣味開発ではこればかり使っています。

今後も継続して学んでいこうと思っているので、ほかの記事も見てくれると嬉しいです!

追記

フロントエンドや研究に関する記事も別であげているので、ぜひ見てみてください!

Discussion