Next.js + Express.js + MySQL + Docker で簡単な Web アプリを作ろう！

# この記事の対象読者
このチュートリアルは、以下のような人に向けたものです。
+ Webアプリを作ってみたい初学者
+ Node.jsを使ってみたい人
+ Next.jsを使ってみたい人
+ Dockerあんまりわかんない人
# この記事のゴール
Next.js（フロントエンド）＋ Express.js（バックエンド）＋ MySQL（データベース）＋ Docker（開発環境） を使って、超シンプルな Web アプリを作ります！
# 前提条件
+ ホストOS: MacOS
+ Dockerインストール済み
+ エディタ:Cursor
+ プログラミングの基礎はわかる
+ データベースも少しわかる

Dockerについて学びたい人は以下の無料の書籍がおすすめです。

https://zenn.dev/suzuki_hoge/books/2022-03-docker-practice-8ae36c33424b59

# 今回の背景
Webアプリエンジニアとして半年が経ち、何かアウトプットを残したいと思い記事を書きました。今回は、AIコードエディタ [Cursor](https://www.cursor.com/ja) を試してみたかったので、アプリのコードとデザインを AI に実装してもらいました。さらに、Node.js をローカルにインストールしなくても、Docker さえあれば Web アプリを作れる環境 を整えました。
## 今回の成果物

https://github.com/Yuta-Yamazaki1708/Next-App-Practice

## 1.必要ファイルの用意
まず、プロジェクトのディレクトリ構成を作成します。
```
my-app/
├── api/          # Express (バックエンド)
│
├── web/         # Next (フロントエンド)
│
├── Dockerfile 
│
├── docker-compose.yml
│
└──.env
```
### Dockerfileの作成
最小限の `Dockerfile` を作成し、コンテナ内で Node.js を動かせるようにします。
```Dockerfile:Dockerfile
FROM node:jod-slim

WORKDIR /home

RUN apt-get update && apt-get install -y bash 

COPY . .
```
+ `FROM node:jod-slim` → 軽量な Node.js の公式 Docker イメージを使用

+ `WORKDIR /home` → `/home` ディレクトリを作業ディレクトリに設定

+ `RUN apt-get update && apt-get install -y bash` → 必要なパッケージをインストール
### docker-compose.yml の作成
コンテナの構成を管理する `docker-compose.yml` を作成します。
```yml:docker-compose.yml
services:
  db:
    image: mysql:9.2.0
    ports:
      - ${MYSQL_PORT}:${MYSQL_PORT}
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}" #rootユーザーのパスワード
      MYSQL_DATABASE: "${MYSQL_DATABASE}" #自動作成されるDB
      MYSQL_USER: "${MYSQL_USER}" #自動作成される一般ユーザー
      MYSQL_PASSWORD: "${MYSQL_PASSWORD}" #ユーザーのパスワード
    volumes:
      - db-data:/var/lib/mysql
  api:
    build: .
    ports:
      - ${API_PORT}:${API_PORT}
    environment:
      NODE_ENV: "${NODE_ENV}"
    volumes:
      - ./api:/home/api
    depends_on:
      - db
  web:
    build: .
    ports:
      - ${WEB_PORT}:${WEB_PORT}
    volumes:
      - ./web:/home/web
    depends_on:
      - api

volumes:
  db-data:

```
+ `db` → MySQL のコンテナ

+ `api` → Express のコンテナ（バックエンド）

+ `web` → Next.js のコンテナ（フロントエンド）
### 環境変数の設定（`.env`）
環境変数を `.env` にまとめ、設定の変更を簡単にします。
```.env
DB_HOST=db
MYSQL_ROOT_PASSWORD=password
MYSQL_DATABASE=mydatabase
MYSQL_USER=user
MYSQL_PASSWORD=passw0rd
MYSQL_PORT=3306
NODE_ENV=development
API_PORT=8000
WEB_PORT=3000

```
⚠️ `.env` は公開しないように注意！（`.gitignore` に追加）
## 2.必要パッケージのインストール
### API コンテナ（Express）
コンテナを起動して Express の必要なパッケージをインストールします。

準備完了したので以下のコマンドを実行してapiコンテナを実行
```.:ターミナル
docker compose run -it api /bin/bash
```
コンテナ内で以下を実行：
```:bash
cd api                          
npm init -y                    
npm install express            
npm install - D nodemon        
npm install mysql2              
npm install typescript          
npm install dotenv
npm install cors
npm install ts-node @types/node @types/express @types/cors
npx tsc --init
exit
```
### WEB コンテナ（Next.js）

```:ターミナル
docker compose run -it web /bin/bash
```
Next.jsは今回はTypeSciptで書きます。
```:bash
cd web                        
npx create-next-app@latest .  
```

## 3.アプリを立ち上げる
### フォルダの整理
```
project-root/
├── api/ 
│    ├── Dockerfile     #追加
│    ├── index.ts       #追加
│    └── package.json   #変更
│
├── web/
│    └── Dockerfile     #追加
│
├── Dockerfile 　       #削除
│
├── docker-compose.yml  #変更
│
└──.env
```
### API の `Dockerfile`
```Dockerfile:Dockerfile
FROM node:jod-slim

WORKDIR /home/api

RUN apt-get update && apt-get install -y \
  bash \
  curl \
  vim

COPY . .

RUN npm install

CMD ["npm", "run", "dev"]

EXPOSE 8000

```
### Express アプリの作成
```ts:/api/index.ts
import express, { Request, Response } from 'express';

const app = express();
const port = 8000;

app.get('/', (req: Request, res: Response) => {
  res.send('Hello TypeScript + Express!');
});

app.listen(port, () => {
  console.log(`Server running at port:${port}`);
});

```
### APIの `package.json`
```json:package.json
{
  "name": "api",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "dev": "nodemon --exec ts-node index.ts",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "description": "",
  "dependencies": {
    "@types/cors": "^2.8.17",
    "@types/express": "^5.0.1",
    "@types/node": "^22.13.14",
    "cors": "^2.8.5",
    "dotenv": "^16.4.7",
    "express": "^4.21.2",
    "mysql2": "^3.14.0",
    "ts-node": "^10.9.2",
    "typescript": "^5.8.2"
  },
  "devDependencies": {
    "nodemon": "^3.1.9"
  },
  "nodemonConfig": {
    "exec": "ts-node ./index.ts",
    "ext": "ts",
    "delay": 1
  }
}

```

### Webの `Dockerfile`
```Dockerfile:Dockerfile
FROM node:jod-slim

WORKDIR /home/web 

RUN apt-get update && apt-get install -y \
  bash \
  curl \
  vim

COPY . .

RUN npm install

CMD ["npm", "run", "dev"]

EXPOSE 3000
```
### `docker-compose.yml` ファイル
```yml:docker-compose.yml
services:
  db:
    image: mysql:9.2.0
    ports:
      - ${MYSQL_PORT}:${MYSQL_PORT}
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}" #rootユーザーのパスワード
      MYSQL_DATABASE: "${MYSQL_DATABASE}" #自動作成されるDB
      MYSQL_USER: "${MYSQL_USER}" #自動作成される一般ユーザー
      MYSQL_PASSWORD: "${MYSQL_PASSWORD}" #ユーザーのパスワード
    volumes:
      - db-data:/var/lib/mysql
  api:
    build: ./api
    ports:
      - ${API_PORT}:${API_PORT}
    env_file:
      - .env
    volumes:
      - ./api:/home/api
    depends_on:
      - db
  web:
    build: ./web
    ports:
      - ${WEB_PORT}:${WEB_PORT}
    volumes:
      - ./web:/home/web
    depends_on:
      - api

volumes:
  db-data:

```
### アプリの起動
```:ターミナル
docker compose up --build 
```
+ `localhost:3000` → Next.js の初期画面

+ `localhost:8000` → Express の `Hello TypeScript + Express!`

![スクリーンショット 2025-03-29 21.04.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/935631/ed4f9c5a-8619-4bb6-a587-248ecaeea281.png)

![スクリーンショット 2025-03-29 21.04.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/935631/1ab6b91a-f709-49d3-bc2d-56d7a567ffa3.png)

## 4.MySQLと接続する
### MySQL コンテナに入る
コンテナを立ち上げた状態で別のターミナルを立ち上げ以下を実行する
```:ターミナル
docker compose exec db /bin/bash
```
MySQL に接続：
```sh:bash
mysql -D mydatabase -u user -p
```
パスワードを入力（`.env` の `MYSQL_PASSWORD`）。

### テーブルを作成
```sql:mysql
CREATE TABLE Test (
  id int AUTO_INCREMENT PRIMARY KEY,
  item VARCHAR(10)
);
```
## 5. CRUD API の作成
api/index.ts に CRUD 処理を実装します。
今回はCursorにお願いして書いてもらいました。
```ts:index.ts
import express, { Request, Response } from 'express';
import mysql, { PoolOptions } from 'mysql2/promise';
import 'dotenv/config';
import cors from 'cors';


const app = express();
const port = 8000;

// CORSの設定
app.use(cors({
  origin: 'http://localhost:3000', // Next.jsのフロントエンドのURL
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  credentials: true
}));

app.use(express.json()); // JSON ミドルウェア追加

const access: PoolOptions = {
  host: process.env.DB_HOST || 'db',
  user: process.env.MYSQL_USER,
  password: process.env.MYSQL_PASSWORD,
  database: process.env.MYSQL_DATABASE,
  waitForConnections: true,
  connectionLimit: 10,
  maxIdle: 10, 
  idleTimeout: 60000,
  queueLimit: 0,
  enableKeepAlive: true,
  keepAliveInitialDelay: 0,
};
console.log(access)

// グローバルでコネクションプールを作成
const pool = mysql.createPool(access);

app.get('/', (req: Request, res: Response) => {
  res.send('Hello TypeScript + Express!');
});

// データを取得
app.get('/get-items', async (req: Request, res: Response) => {
  try {
    const [items] = await pool.execute(`SELECT * FROM Test`);
    res.json(items);
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'データの取得に失敗しました' });
  }
});

// データを追加
app.post('/create', async (req: Request, res: Response) => {
  try {
    const { item } = req.body;

    if (typeof item === 'string' && item.trim() !== '') {
      await pool.execute(`INSERT INTO Test (item) VALUES (?)`, [item]);
      res.json({ message: 'データが追加されました' });
    } else {
      res.status(400).json({ error: '有効な文字列を入力してください' });
    }
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'データの追加に失敗しました' });
  }
});

app.post('/update', async (req: Request, res: Response) => {
  try {
    const { id, item } = req.body;
    await pool.execute(`UPDATE Test SET item = ? WHERE id = ?`, [item, id]);
    res.json({ message: 'データが更新されました' });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'データの更新に失敗しました' });
  }
});

app.post('/delete', async (req: Request, res: Response) => {
  try {
    const { id } = req.body;
    await pool.execute(`DELETE FROM Test WHERE id = ?`, [id]);
    res.json({ message: 'データが削除されました' });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'データの削除に失敗しました' });
  }
});

app.listen(port, () => {
  console.log(`Server running at port:${port}`);
});

```
## 6. フロントエンドの実装
Next.js で CRUD を実装します。
フロントエンドもCursorに書いてもらいます。AIすごい。
```tsx:page.tsx
'use client';

import { useEffect, useState } from 'react';

interface Item {
  id: number;
  item: string;
}

export default function Home() {
  const [items, setItems] = useState<Item[]>([]);
  const [editingId, setEditingId] = useState<number | null>(null);
  const [editText, setEditText] = useState('');

  useEffect(() => {
    const fetchItems = async () => {
      try {
        const response = await fetch('http://localhost:8000/get-items');
        const data = await response.json();
        setItems(data);
      } catch (error) {
        console.error('アイテムの取得に失敗しました:', error);
      }
    };

    fetchItems();
  }, [items]);

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const form = e.target as HTMLFormElement;
    const formData = new FormData(form);
    const item = formData.get('item') as string;

    try {
      if (item === '') {
        throw new Error('文字を入力してください');
      } else {
        const response = await fetch('http://localhost:8000/create', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({ item }),
        });
  
        if (!response.ok) {
          throw new Error('データの追加に失敗しました');
        }
  
        const fetchResponse = await fetch('http://localhost:8000/get-items');
        const updatedItems = await fetchResponse.json();
        setItems(updatedItems);
        form.reset();
      }
    } catch (error) {
      console.error('エラー:', error);
    }
  }

  const handleDelete = async (id: number) => {
    try {
      const response = await fetch('http://localhost:8000/delete', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ id }),
      });

      if (!response.ok) {
        throw new Error('データの削除に失敗しました');
      }

      setItems(items.filter((item) => item.id !== id));
    } catch (error) {
      console.error('エラー:', error);
    }
  }

  const handleEdit = (item: Item) => {
    setEditingId(item.id);
    setEditText(item.item);
  };

  const handleUpdate = async (id: number) => {
    try {
      const response = await fetch('http://localhost:8000/update', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ id, item: editText }),
      });

      if (!response.ok) {
        throw new Error('データの更新に失敗しました');
      }

      setItems(items.map(item => 
        item.id === id ? { ...item, item: editText } : item
      ));
      setEditingId(null);
    } catch (error) {
      console.error('エラー:', error);
    }
  };

  return (
    <div className="max-w-2xl mx-auto p-6">
      <h1 className="text-3xl font-bold mb-8 text-center">Todoリスト</h1>
      
      <form onSubmit={handleSubmit} className="mb-8">
        <div className="flex gap-2">
          <input 
            type="text" 
            name="item" 
            className="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
            placeholder="新しいタスクを入力"
          />
          <button 
            type="submit"
            className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
          >
            追加
          </button>
        </div>
      </form>

      <div>
        <h2 className="text-xl font-semibold mb-4">タスク一覧</h2>
        <ul className="space-y-3">
          {items.map((item) => (
            <li 
              key={item.id}
              className="flex items-center gap-2 p-3 bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow"
            >
              {editingId === item.id ? (
                <>
                  <input
                    type="text"
                    value={editText}
                    onChange={(e) => setEditText(e.target.value)}
                    className="flex-1 px-3 py-1 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
                  />
                  <button 
                    onClick={() => handleUpdate(item.id)}
                    className="px-4 py-1 bg-green-500 text-white rounded hover:bg-green-600 transition-colors"
                  >
                    保存
                  </button>
                  <button 
                    onClick={() => setEditingId(null)}
                    className="px-4 py-1 bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors"
                  >
                    キャンセル
                  </button>
                </>
              ) : (
                <>
                  <span className="flex-1">{item.item}</span>
                  <button 
                    onClick={() => handleEdit(item)}
                    className="px-4 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600 transition-colors"
                  >
                    編集
                  </button>
                </>
              )}
              <button 
                onClick={() => handleDelete(item.id)}
                className="px-4 py-1 bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
              >
                削除
              </button>
            </li>
          ))}
        </ul>
      </div>
    </div>
  )
}

```
かなり高速にWebアプリができてびっくり
![スクリーンショット 2025-03-30 13.09.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/935631/d58edf7c-0bc6-49c7-bc25-809600454a72.png)

## まとめ
+ Next.js（フロントエンド）+ Express（バックエンド）+ MySQL + Docker で Web アプリを構築

+ API とデータベースを Docker で管理

+ AI（Cursor）を活用して開発を高速化

簡単に Web アプリを作れるので、ぜひ試してみてください！ 🚀