😺

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 を試してみたかったので、アプリのコードとデザインを 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
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 を作成します。

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 にまとめ、設定の変更を簡単にします。

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
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 アプリの作成

/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

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
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 ファイル

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

スクリーンショット 2025-03-29 21.04.59.png

4.MySQLと接続する

MySQL コンテナに入る

コンテナを立ち上げた状態で別のターミナルを立ち上げ以下を実行する

ターミナル
docker compose exec db /bin/bash

MySQL に接続:

bash
mysql -D mydatabase -u user -p

パスワードを入力(.envMYSQL_PASSWORD)。

テーブルを作成

mysql
CREATE TABLE Test (
  id int AUTO_INCREMENT PRIMARY KEY,
  item VARCHAR(10)
);

5. CRUD API の作成

api/index.ts に CRUD 処理を実装します。
今回はCursorにお願いして書いてもらいました。

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すごい。

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

まとめ

  • Next.js(フロントエンド)+ Express(バックエンド)+ MySQL + Docker で Web アプリを構築

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

  • AI(Cursor)を活用して開発を高速化

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

Discussion