

ในบทความนี้เราจะสร้าง เว็บจัดการสินค้า ด้วย Next.js ที่เพิ่ม แสดง แก้ไข และลบข้อมูลในฐานข้อมูล MySQL บนเครื่องของเราได้ เริ่มจากเตรียมฐานข้อมูลด้วย CAMPP และ phpMyAdmin จากนั้นเขียน REST API เพื่อเชื่อมหน้าเว็บเข้ากับฐานข้อมูล และปิดท้ายด้วยการตกแต่งหน้าเว็บให้รองรับทั้งคอมพิวเตอร์และมือถือ
การจัดการข้อมูลทั้ง 4 แบบนี้เรียกรวมกันว่า CRUD ในตัวอย่างนี้ เราจะใช้ HTTP method ต่อไปนี้เพื่อบอก API ว่าต้องการทำอะไรกับข้อมูล:
| ตัวย่อ | ความหมาย | HTTP method |
|---|---|---|
| Create | เพิ่มข้อมูลใหม่ | POST |
| Read | อ่าน/แสดงข้อมูล | GET |
| Update | แก้ไขข้อมูล | PUT |
| Delete | ลบข้อมูล | DELETE |
เราจะใช้ตาราง products เก็บข้อมูลสินค้าตลอดบทความ และค่อย ๆ เพิ่มความสามารถให้เว็บตามลำดับนี้:
- เตรียม CAMPP และสร้างฐานข้อมูล MySQL
- เตรียมเครื่องมือและสร้างโปรเจกต์ Next.js ใหม่
- เชื่อม Next.js เข้ากับ MySQL ด้วย
mysql2 - Read — อ่านข้อมูลมาแสดง
- Create — เพิ่มข้อมูลใหม่
- Update และ Delete — แก้ไขและลบข้อมูล
- ตกแต่งด้วย CSS — ธีมร้านกาแฟแบบ responsive

พื้นฐานที่ควรมีก่อนเริ่ม: ควรคุ้นเคยกับ HTML, CSS, JavaScript, React และโครงสร้างโปรเจกต์ Next.js โดยตัวอย่างจะใช้ component,
useState,useEffect,fetch, dynamic route และuseRouterหากยังไม่คุ้นเคย แนะนำให้อ่านบทความก่อนหน้านี้ตามลำดับ:
การทำงานของเว็บแบ่งเป็น 3 ส่วน: หน้า React รับข้อมูลจากผู้ใช้และเรียก API, API route ใน Next.js ส่งคำสั่งไปยัง MySQL ผ่าน mysql2, แล้วส่งผลลัพธ์กลับเป็น JSON ให้หน้าเว็บนำไปแสดง
REST API เบื้องต้น#
ก่อนเขียน API มาทำความเข้าใจคำที่เราจะใช้ตลอดบทความกันก่อน
API และ REST — API (Application Programming Interface) คือช่องทางที่โปรแกรมใช้สื่อสารกัน ในบทความนี้เราจะออกแบบ API ในรูปแบบ REST โดยใช้ URL ระบุข้อมูลที่ต้องการจัดการ และใช้ HTTP method ระบุการทำงาน หน้าเว็บหรือ client ส่งคำขอ (request) ไปยัง server แล้วรับคำตอบ (response) กลับมาเป็น JSON
Endpoint — คือ URL ของ API เช่น /api/products เราใช้ URL เดียวกันได้ทั้งอ่านรายการสินค้าและเพิ่มสินค้า โดยแยกการทำงานด้วย GET และ POST
HTTP method — บอกชนิดของการกระทำกับข้อมูล ตรงกับตาราง CRUD ด้านบน: GET อ่าน, POST เพิ่ม, PUT แก้ไข, DELETE ลบ
Status code — ตัวเลขที่แนบมากับ response เพื่อบอกผลลัพธ์ กลุ่มที่ขึ้นต้นด้วย 2 คือสำเร็จ (200 OK, 201 Created) ส่วนกลุ่มที่ขึ้นต้นด้วย 4 คือ request มีปัญหา (400 Bad Request, 404 Not Found)
1. เตรียม CAMPP และสร้างฐานข้อมูล#
เริ่มจากเตรียมฐานข้อมูลสำหรับเก็บสินค้า เพื่อให้ข้อมูลยังอยู่หลังปิดหน้าเว็บ ในตัวอย่างนี้เราใช้ CAMPP เพื่อรันบริการฐานข้อมูลบนเครื่อง และใช้ phpMyAdmin สร้างตารางและตรวจสอบข้อมูลผ่านเบราว์เซอร์ เมื่อส่วนนี้พร้อมแล้วจึงสร้างโปรเจกต์ Next.js มาเชื่อมต่อ
1.1 ติดตั้งและเปิด CAMPP#
ดาวน์โหลดและติดตั้ง CAMPP จาก https://campp.melivecode.com/ ↗
ระหว่างติดตั้ง ให้ ตั้งรหัสผ่าน MySQL สำหรับผู้ใช้ root โดยต้องระบุรหัสผ่านที่ไม่ใช่ค่าว่าง เก็บรหัสผ่านนี้ไว้สำหรับตั้งค่าการเชื่อมต่อในหัวข้อ 3.1

เมื่อติดตั้งแล้ว เปิด CAMPP แล้วกด Start เพื่อเริ่มบริการทั้งหมด (Web Server, PHP, Database)

จากนั้นเปิด phpMyAdmin โดยคลิกปุ่ม phpMyAdmin บนแอป CAMPP หรือเข้าที่ http://127.0.0.1:8080/phpmyadmin/ ↗ โดยตรง เพื่อจัดการฐานข้อมูลผ่านส่วนติดต่อผู้ใช้แบบกราฟิก (GUI)


1.2 สร้างฐานข้อมูลและตาราง products#
ในหน้า phpMyAdmin คลิกแท็บ SQL ด้านบน วางคำสั่งด้านล่างนี้แล้วคลิก Go เพื่อสร้างฐานข้อมูล crud_db ตาราง products และเพิ่มข้อมูลตัวอย่าง:
-- create the database
CREATE DATABASE IF NOT EXISTS crud_db;
USE crud_db;
-- create the products table
CREATE TABLE products (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
price DECIMAL(10, 2) NOT NULL,
stock INT DEFAULT 0,
detail TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- insert some sample rows
INSERT INTO products (name, price, stock, detail) VALUES
('Green Tea', 120.00, 50, 'Japanese green tea'),
('Coffee', 90.00, 30, 'Freshly roasted coffee beans'),
('Chocolate', 150.00, 20, 'Dark chocolate bar');sql
แต่ละคอลัมน์ในตาราง products มีหน้าที่ดังนี้:
| คอลัมน์ | ชนิดข้อมูล | คำอธิบาย |
|---|---|---|
id | INT AUTO_INCREMENT | รหัสประจำสินค้า (Primary Key) เพิ่มอัตโนมัติ |
name | VARCHAR(255) | ชื่อสินค้า |
price | DECIMAL(10,2) | ราคา เก็บทศนิยม 2 ตำแหน่ง |
stock | INT | จำนวนคงเหลือในสต็อก |
detail | TEXT | รายละเอียดสินค้า |
created_at | TIMESTAMP | วันเวลาที่เพิ่มข้อมูล ตั้งค่าอัตโนมัติ |

1.3 ตรวจสอบข้อมูลด้วย Browse และ Designer#
คลิกชื่อฐานข้อมูล crud_db ที่แผงด้านซ้าย แล้วคลิกตาราง products จากนั้นคลิกแท็บ Browse เพื่อดูข้อมูลตัวอย่างที่เพิ่งเพิ่มเข้าไป

เมื่อเห็นสินค้าตัวอย่างครบ 3 รายการ แสดงว่าฐานข้อมูลพร้อมสำหรับขั้นตอนถัดไปแล้ว หากต้องการดูโครงสร้างตารางในรูปแบบแผนภาพ สามารถเปิดแท็บ Designer ได้ ตัวอย่างนี้มีตารางเดียว จึงยังไม่มีเส้นแสดงความสัมพันธ์ระหว่างตาราง
2. เตรียมเครื่องมือและสร้างโปรเจกต์#
เมื่อฐานข้อมูลพร้อมแล้ว ขั้นต่อไปคือเตรียมเครื่องมือและสร้างโปรเจกต์ Next.js สำหรับเขียน API และหน้าเว็บ
2.1 VS Code และ Node.js#
หากยังไม่ได้ติดตั้งเครื่องมือ ให้ติดตั้ง 2 โปรแกรมนี้ก่อน:
- VS Code — โปรแกรมสำหรับเขียนโค้ด ติดตั้งจาก code.visualstudio.com ↗
- Node.js (LTS) — สภาพแวดล้อมสำหรับรัน JavaScript นอกเบราว์เซอร์ ติดตั้งจาก nodejs.org ↗ (บน Ubuntu สามารถติดตั้งผ่าน nvm)
ตรวจสอบว่าติดตั้งสำเร็จด้วยคำสั่งใน Terminal หรือ Command Prompt:
node --version
npm --versionbash2.2 สร้างโปรเจกต์ Next.js#
สร้างโปรเจกต์ชื่อ crud_mysql ด้วยคำสั่งต่อไปนี้ โดย --js ระบุให้ใช้ JavaScript เพื่อให้ตรงกับโค้ดในบทความ:
# create a new Next.js project
npx create-next-app@16 crud_mysql --js
# enter the project, then open VS Code
cd crud_mysql
code .bashเมื่อเปิด VS Code ด้วย code . แล้ว การเริ่ม development server ครั้งแรกทำตามขั้นตอนนี้
1. เปิด Terminal ใน VS Code — จากเมนู Terminal → New Terminal (หรือกดคีย์ลัด Ctrl+Shift+`)

2. เลือก Command Prompt (สำหรับ Windows) — หาก Terminal เปิดขึ้นมาเป็น PowerShell ให้กดลูกศร ∨ ข้างปุ่ม + มุมขวาบนของ Terminal แล้วเลือก Command Prompt เพื่อให้คำสั่งทำงานตรงกับตัวอย่างในบทความ

3. รัน npm run dev — พิมพ์คำสั่งต่อไปนี้แล้วกด Enter เพื่อตรวจสอบว่าโปรเจกต์ทำงาน
npm run devbashเมื่อเซิร์ฟเวอร์พร้อมใช้งาน Terminal จะแสดง Local: http://localhost:3000 เปิด URL นี้ในเบราว์เซอร์เพื่อตรวจสอบหน้าเริ่มต้นของแอป หรือกด Ctrl ค้างแล้วคลิกลิงก์ใน Terminal

2.3 เตรียมไฟล์ style.css#
ก่อนสร้างหน้ารายการสินค้า มาเตรียมสไตล์พื้นฐานให้ฟอร์มและการ์ดอ่านง่ายก่อน สร้างไฟล์ app/style.css เพื่อรวมสไตล์ไว้ที่เดียว เราจะใช้ไฟล์นี้ต่อในหัวข้อ 7 เมื่อตกแต่งธีมและปรับการแสดงผลบนมือถือ:
/* app/style.css — minimal shared styles for the products pages */
body {
font-family: sans-serif;
padding: 20px;
}
form {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-width: 500px;
margin-bottom: 16px;
}
/* edit page: stack the fields vertically */
.edit-form {
flex-direction: column;
align-items: flex-start;
}
/* responsive grid: columns wrap automatically as the window narrows */
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
}
.product-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
border: 1px solid #ddd;
border-radius: 8px;
}
img {
width: 100%;
max-width: 300px;
aspect-ratio: 1 / 1;
object-fit: cover;
}cssไฟล์ CSS ที่ import ใน app/layout.js จะมีผลกับทุกหน้า (global) เพราะ layout.js ครอบทุกหน้าในแอป แก้ไฟล์ app/layout.js เปลี่ยนบรรทัด import "./globals.css"; ที่ create-next-app สร้างไว้ ให้ import style.css แทน:
import "./style.css"; // replaces the default import "./globals.css";
/* ...existing layout code... */javascriptลบไฟล์ app/globals.css ที่ create-next-app สร้างมาให้ทิ้งได้เลย เพราะบทความนี้ใช้ style.css เป็นไฟล์สไตล์หลักเพียงไฟล์เดียว
สไตล์ส่วนใหญ่เลือก element โดยตรง (body, form, img) จึงมีผลทันทีโดยไม่ต้องใส่ class ส่วน element ที่ต้องการสไตล์เฉพาะจะอ้างถึง class ผ่าน className (เช่น className="product-grid") แทนการเขียน style={{ ... }}
3. เชื่อม Next.js กับ MySQL#
เมื่อมีฐานข้อมูลและโปรเจกต์แล้ว ขั้นต่อไปคือเชื่อมทั้งสองส่วนเข้าด้วยกัน เราจะใช้ mysql2 เป็นไลบรารีสำหรับส่งคำสั่ง SQL จากฝั่งเซิร์ฟเวอร์ของ Next.js ไปยัง MySQL
3.1 ติดตั้ง mysql2 และตั้งค่า .env.local#
หยุด development server ชั่วคราว (กด Ctrl+C ใน Terminal) แล้วติดตั้ง mysql2:
npm install mysql2bashข้อมูลการเชื่อมต่อฐานข้อมูล (host, port, user, password) ไม่ควรเขียนแบบตายตัวในโค้ด แต่เก็บไว้ในไฟล์ .env.local ที่ Next.js อ่านให้อัตโนมัติ สร้างไฟล์ .env.local ที่รากของโปรเจกต์:
DB_HOST=localhost
DB_PORT=3307
DB_USERNAME=root
DB_PASSWORD=your-campp-password
DB_DATABASE=crud_dbbash| ค่า | คำอธิบาย |
|---|---|
DB_HOST | ที่อยู่ของเซิร์ฟเวอร์ฐานข้อมูล — เครื่องเดียวกันคือ localhost |
DB_PORT | CAMPP ใช้ port 3307 สำหรับ MySQL (ไม่ใช่ 3306) |
DB_USERNAME | ชื่อผู้ใช้ฐานข้อมูล ค่าเริ่มต้นคือ root |
DB_PASSWORD | รหัสผ่านที่ตั้งไว้ตอนติดตั้ง CAMPP (หัวข้อ 1.1) |
DB_DATABASE | ชื่อฐานข้อมูลที่สร้างไว้ คือ crud_db |
ก่อนเริ่มเซิร์ฟเวอร์อีกครั้ง: แทนที่
your-campp-passwordด้วยรหัสผ่านจากหัวข้อ 1.1 แล้วรันnpm run devหลังแก้ค่าการเชื่อมต่อให้รีสตาร์ตเซิร์ฟเวอร์เพื่อให้ใช้ค่าที่ตั้งไว้ ตรวจสอบด้วยว่า.gitignoreครอบคลุมไฟล์.env.localเพื่อไม่ให้ข้อมูลการเชื่อมต่อถูก commit ไปกับโปรเจกต์
3.2 สร้าง connection pool (lib/db.js)#
Connection pool ช่วยจัดการและนำการเชื่อมต่อฐานข้อมูลกลับมาใช้ซ้ำ API แต่ละไฟล์จึงใช้ pool เดียวกันได้ โดยไม่ต้องเขียนการตั้งค่าการเชื่อมต่อซ้ำ
สร้างไฟล์ lib/db.js ที่รากของโปรเจกต์:
import mysql from "mysql2/promise";
// a connection pool is reused across requests instead of opening a new connection each time
export const pool = mysql.createPool({
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT),
user: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
});javascriptต่อจากนี้ไฟล์ API route ทุกไฟล์จะ import { pool } จาก @/lib/db (สัญลักษณ์ @/ ชี้ไปที่รากของโปรเจกต์ ตั้งค่ามาให้แล้วโดย create-next-app)
4. Read — อ่านข้อมูลจากฐานข้อมูล#
เริ่มจากการอ่านข้อมูล เพราะเป็นพื้นฐานของหน้ารายการสินค้า เราจะสร้าง API ที่ส่งรายการสินค้าเป็น JSON แล้วให้หน้า React เรียกข้อมูลนั้นมาสร้างการ์ด

4.1 API route: GET /api/products#
ใน Next.js App Router ไฟล์ชื่อ route.js ภายใต้โฟลเดอร์ app/api/ จะกลายเป็น API endpoint โดยแต่ละ HTTP method เขียนเป็นฟังก์ชันที่ export ออกไป
สร้างไฟล์ app/api/products/route.js:
import { NextResponse } from "next/server";
import { pool } from "@/lib/db";
// GET /api/products — return all products
export async function GET() {
const [rows] = await pool.query("SELECT * FROM products ORDER BY id DESC");
return NextResponse.json(rows);
}javascriptทดสอบโดยเปิด http://localhost:3000/api/products ↗ บนเบราว์เซอร์ จะเห็นข้อมูลสินค้าเป็น JSON ที่ดึงมาจากฐานข้อมูลจริง
| ส่วน | คำอธิบาย |
|---|---|
route.js | ไฟล์ที่ทำให้โฟลเดอร์กลายเป็น API endpoint |
export async function GET() | จัดการ request แบบ GET ที่เข้ามาที่ endpoint นี้ |
pool.query(...) | ส่งคำสั่ง SQL ไปยังฐานข้อมูล คืนผลเป็น [rows, fields] |
NextResponse.json(rows) | ส่งข้อมูลกลับเป็น JSON |
JSON (JavaScript Object Notation) คือรูปแบบข้อความสำหรับแลกเปลี่ยนข้อมูล โครงสร้างที่เราจะพบในตัวอย่างคือ object ซึ่งใช้ { } เก็บข้อมูลเป็นคู่ "key": value และ array ซึ่งใช้ [ ] เก็บรายการข้อมูล
/api/products ส่งกลับเป็น array ของ object โดยหนึ่ง object แทนสินค้าหนึ่งรายการ ตัวอย่างด้านล่างแสดงบางรายการจากผลลัพธ์ ส่วนค่า created_at จะขึ้นอยู่กับเวลาที่เพิ่มข้อมูลจริง:
[
{
"id": 3,
"name": "Chocolate",
"price": "150.00",
"stock": 20,
"detail": "Dark chocolate bar",
"created_at": "2026-09-22T03:00:00.000Z"
},
{
"id": 2,
"name": "Coffee",
"price": "90.00",
"stock": 30,
"detail": "Freshly roasted coffee beans",
"created_at": "2026-09-22T03:00:00.000Z"
}
]jsonค่า (value) ใน JSON มีหลายชนิด — string อยู่ในเครื่องหมายคำพูด "...", number เขียนเป็นตัวเลขไม่มีเครื่องหมายคำพูด (เช่น 20), และยังมี boolean (true/false) กับ null ได้ด้วย ฝั่งหน้าเว็บใช้ response.json() แปลงข้อความ JSON นี้ให้กลายเป็น array/object ของ JavaScript เพื่อนำไปใช้ต่อ เช่น .map() วนสร้างการ์ด
สังเกตว่า price ส่งกลับมาเป็น string ("150.00") ไม่ใช่ number เพราะคอลัมน์ชนิด DECIMAL ถูกแปลงเป็น string โดย mysql2 เพื่อคงความแม่นยำของทศนิยม หากนำไปคำนวณต้องแปลงเป็นตัวเลขด้วย Number(...) ก่อน

4.2 แสดงผลด้วยการ์ด (card) ในหน้าเว็บ#
เมื่อ API อ่านข้อมูลได้แล้ว เราจะนำสินค้าแต่ละรายการมาแสดงเป็น การ์ด ที่มีรูปภาพ ชื่อ ราคา และจำนวนคงเหลือ โดยแบ่งหน้าที่ของ React และ CSS ดังนี้:
| แนวคิด | ในการ์ดสินค้า |
|---|---|
| Component | แยกการ์ดหนึ่งใบเป็น component ชื่อ ProductCard ที่นำกลับมาใช้ซ้ำได้ |
| Props | หน้ารายการส่งข้อมูลสินค้าแต่ละตัวให้การ์ดผ่าน prop product |
| Responsiveness | จัดการ์ดด้วย CSS grid ที่ตัดขึ้นบรรทัดใหม่อัตโนมัติเมื่อจอแคบลง |
สร้าง component การ์ด — สร้างไฟล์ app/components/ProductCard.js การ์ดรับข้อมูลสินค้าหนึ่งตัวมาทาง prop product แล้วแสดงผล รูปภาพอ่านจาก /products/{id}.png ในโฟลเดอร์ public:
// ProductCard — a reusable component that receives one product via props
export default function ProductCard({ product }) {
return (
<div className="product-card">
<img
src={`/products/${product.id}.png`}
alt={product.name}
// hide the image if the file for this id does not exist
onError={(event) => (event.currentTarget.style.visibility = "hidden")}
/>
<span>ID: {product.id}</span>
<strong>{product.name}</strong>
<span>฿{product.price} · Stock: {product.stock}</span>
</div>
);
}javascriptรูปภาพสินค้า: วางไฟล์รูปไว้ที่
public/products/โดยตั้งชื่อตามidของสินค้า (เช่น1.png,2.png,3.png) ไฟล์ในโฟลเดอร์publicเข้าถึงได้ตรงจาก URL ราก —public/products/1.pngเปิดได้ที่/products/1.pngหากสินค้าใดยังไม่มีรูปonErrorจะซ่อนรูปที่โหลดไม่ได้ไว้
ดาวน์โหลดรูปตัวอย่างทั้ง 3 ไฟล์:
นำไปวางในโฟลเดอร์ public/products/ ของโปรเจกต์ ให้โครงสร้างไฟล์เป็นดังภาพ:

สร้างหน้ารายการ — สร้างไฟล์ app/products/page.js โดยใช้ fetch เรียก API เมื่อหน้าเว็บเริ่มทำงานผ่าน useEffect แล้วเก็บผลลัพธ์ใน state จากนั้นใช้ .map() สร้าง ProductCard สำหรับสินค้าแต่ละรายการ หน้านี้เปิดได้ที่ http://localhost:3000/products ↗
"use client";
import { useState, useEffect } from "react";
import ProductCard from "@/app/components/ProductCard";
export default function ProductsPage() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
// load the product list from the API
const loadProducts = () => {
fetch("/api/products")
.then((response) => response.json())
.then((data) => {
setProducts(data);
setLoading(false);
});
};
useEffect(() => {
loadProducts();
}, []);
if (loading) {
return <p>Loading...</p>;
}
return (
<div>
<h1>Products</h1>
{/* one ProductCard per product, laid out in a responsive grid */}
<div className="product-grid">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
);
}javascriptการส่ง product={product} คือการส่ง props ให้ component ลูก ส่วน key={product.id} ช่วยให้ React ติดตามแต่ละการ์ดในลิสต์ได้ถูกต้องเมื่อใช้ .map()
เราแยกการโหลดข้อมูลไว้ใน loadProducts เพื่อเรียกซ้ำได้ หลังเพิ่มหรือลบสินค้าในหัวข้อถัดไป ฟังก์ชันนี้จะช่วยอัปเดตรายการบนหน้าเว็บให้ตรงกับฐานข้อมูล
เปิด http://localhost:3000/products ↗ จะเห็นการ์ดสินค้าที่ดึงมาจากฐานข้อมูล เรียงต่อกันในกริดที่ปรับตามความกว้างของจอ:

4.3 ตรวจสอบผลการเรียก API ด้วย console.log และ DevTools#
ระหว่างพัฒนา การมองเห็นข้อมูลที่ API ส่งกลับมาช่วยไล่หาปัญหาได้เร็วขึ้น วิธีแรกคือใช้ console.log พิมพ์ข้อมูลออกมาดูในโค้ด เพิ่มบรรทัด console.log ในฟังก์ชัน loadProducts เพื่อดูข้อมูลที่ได้จาก fetch:
const loadProducts = () => {
fetch("/api/products")
.then((response) => response.json())
.then((data) => {
console.log("products from API:", data); // inspect the API result
setProducts(data);
setLoading(false);
});
};javascriptเปิดหน้า http://localhost:3000/products ↗ แล้วเปิด DevTools ของเบราว์เซอร์ (กด F12 หรือคลิกขวา → Inspect) จะเห็นเครื่องมือสำหรับตรวจสอบการทำงานของหน้าเว็บ 2 แท็บที่ใช้บ่อยคือ:
| แท็บ | ใช้ดูอะไร |
|---|---|
| Console | ข้อความจาก console.log — เห็น array ของสินค้าที่ API ส่งกลับมา คลิกขยายดูค่าแต่ละ object ได้ |
| Network | รายการ request ทั้งหมด คลิกที่ products แล้วดูแท็บ Response เพื่อดู JSON ดิบที่ API ตอบกลับ และดู Status (เช่น 200 = สำเร็จ ดูความหมายที่หัวข้อ REST API เบื้องต้น) กับ Headers |
ที่แท็บ Network หากกรองด้วย Fetch/XHR จะเห็นเฉพาะ request ที่เกิดจาก fetch และพิมพ์คำว่า api ในช่อง Filter เพื่อกรองให้เหลือเฉพาะ request ที่ยิงไปที่ /api/... ทำให้ตรวจได้ว่า /api/products ถูกเรียกจริง ส่งข้อมูลอะไรกลับมา และใช้เวลานานเท่าไร วิธีนี้ใช้ตรวจสอบได้ทั้ง POST, PUT, DELETE ในหัวข้อถัดไปด้วย

5. Create — เพิ่มข้อมูล#
ตอนนี้หน้าเว็บแสดงสินค้าจากฐานข้อมูลได้แล้ว ต่อไปเราจะเพิ่มฟอร์มรับชื่อและราคา ส่งข้อมูลไปยัง API ด้วย POST แล้วโหลดรายการใหม่หลังบันทึก
5.1 API route: POST /api/products#
เพิ่มฟังก์ชัน POST ในไฟล์ app/api/products/route.js เดิม (วางต่อจากฟังก์ชัน GET) เพื่อรับข้อมูลใหม่แล้วบันทึกลงฐานข้อมูล:
// POST /api/products — create a new product
export async function POST(request) {
const { name, price, stock, detail } = await request.json();
if (!name || price == null) {
return NextResponse.json(
{ status: "error", message: "name and price are required" },
{ status: 400 }
);
}
const [result] = await pool.query(
"INSERT INTO products (name, price, stock, detail) VALUES (?, ?, ?, ?)",
[name, price, stock ?? 0, detail ?? ""]
);
return NextResponse.json({ status: "ok", id: result.insertId }, { status: 201 });
}javascriptสำคัญ — parameterized query: สังเกตว่าค่าที่รับจากผู้ใช้ไม่ได้ต่อสตริงเข้าไปในคำสั่ง SQL โดยตรง แต่ใช้เครื่องหมาย
?เป็นตัวแทน แล้วส่งค่าจริงมาทาง array ตัวที่สอง วิธีนี้ป้องกัน SQL injection ซึ่งเป็นช่องโหว่ที่ผู้ไม่หวังดีแทรกคำสั่ง SQL ผ่านช่องกรอกข้อมูล ให้ใช้?เสมอเมื่อมีค่าจากผู้ใช้
5.2 ฟอร์มเพิ่มสินค้า#
ในไฟล์ app/products/page.js จากหัวข้อ 4.2 ให้เพิ่ม state สำหรับช่องกรอกและฟังก์ชัน handleCreate ภายใน ProductsPage แล้ววาง <form> ในส่วน return เหนือกริดสินค้า โค้ดด้านล่างแสดงเฉพาะส่วนที่เพิ่ม ให้นำไปประกอบกับโค้ดเดิมตามตำแหน่งที่ระบุ:
"use client";
import { useState, useEffect } from "react";
export default function ProductsPage() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [name, setName] = useState("");
const [price, setPrice] = useState("");
/* ...existing loadProducts and useEffect... */
// send a POST request to create a product, then reload the list
const handleCreate = async (event) => {
event.preventDefault();
await fetch("/api/products", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, price: Number(price) }),
});
setName("");
setPrice("");
loadProducts();
};
/* ...existing loading check... */
return (
<div>
<h1>Products</h1>
{/* form to add a new product */}
<form onSubmit={handleCreate}>
<input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Product name"
required
/>
<input
type="number"
value={price}
onChange={(event) => setPrice(event.target.value)}
placeholder="Price"
required
/>
<button type="submit">Add</button>
</form>
{/* ...existing product grid... */}
</div>
);
}javascript| ส่วน | คำอธิบาย |
|---|---|
method: "POST" | บอกให้ fetch ส่งแบบ POST แทน GET |
headers: { "Content-Type": "application/json" } | บอกเซิร์ฟเวอร์ว่าข้อมูลที่ส่งเป็น JSON |
body: JSON.stringify(...) | แปลงออบเจกต์เป็นสตริง JSON เพื่อส่งไปกับ request |
loadProducts() | โหลดรายการใหม่หลังบันทึกสำเร็จ เพื่อให้เห็นรายการล่าสุด |
เปิด http://localhost:3000/products ↗ กรอกชื่อและราคา แล้วกด Add จะเห็นสินค้าใหม่เพิ่มขึ้นในกริดทันที
ลองเพิ่มสินค้า Milk Tea ราคา 60 หากเริ่มจากข้อมูลตัวอย่างและยังไม่เคยเพิ่มสินค้าอื่น รายการใหม่นี้จะได้ id เป็น 4 ดาวน์โหลดรูปด้านล่างไปวางที่ public/products/4.png แล้วรีเฟรชหน้า หากได้ id ต่างจากนี้ ให้ตั้งชื่อไฟล์รูปตาม id ที่ปรากฏบนการ์ด:

กลับไปที่ phpMyAdmin แล้วคลิกแท็บ Browse ของตาราง products จะเห็นแถวใหม่ที่เพิ่งเพิ่มถูกบันทึกลงฐานข้อมูลจริง — ยืนยันว่าฟอร์มบนหน้าเว็บเขียนข้อมูลลง MySQL ผ่าน API สำเร็จ

6. Update และ Delete#
การอ่านรายการใช้ /api/products แต่เมื่อจะอ่าน แก้ไข หรือลบสินค้าเพียงรายการเดียว เราต้องระบุ id ด้วย จึงสร้าง dynamic route เช่น /api/products/1 เพื่อจัดการสินค้ารหัส 1
6.1 API route: PUT และ DELETE#
สร้างไฟล์ app/api/products/[id]/route.js — endpoint นี้รับ id จาก URL ผ่านพารามิเตอร์ params และมีทั้ง GET (อ่านรายตัว) PUT (แก้ไข) และ DELETE (ลบ):
import { NextResponse } from "next/server";
import { pool } from "@/lib/db";
// GET /api/products/{id} — read a single product
export async function GET(request, { params }) {
const { id } = await params;
const [rows] = await pool.query("SELECT * FROM products WHERE id = ?", [id]);
if (rows.length === 0) {
return NextResponse.json(
{ status: "error", message: "Product not found" },
{ status: 404 }
);
}
return NextResponse.json(rows[0]);
}
// PUT /api/products/{id} — update a product
export async function PUT(request, { params }) {
const { id } = await params;
const { name, price, stock, detail } = await request.json();
await pool.query(
"UPDATE products SET name = ?, price = ?, stock = ?, detail = ? WHERE id = ?",
[name, price, stock, detail, id]
);
return NextResponse.json({ status: "ok" });
}
// DELETE /api/products/{id} — delete a product
export async function DELETE(request, { params }) {
const { id } = await params;
await pool.query("DELETE FROM products WHERE id = ?", [id]);
return NextResponse.json({ status: "ok" });
}javascriptสังเกตในตัวอย่าง: โค้ดใช้
await paramsเพื่อรับค่าidก่อนนำไปใช้ในคำสั่ง SQL
6.2 ปุ่มลบและแก้ไขในการ์ด#
แก้ไฟล์ app/products/page.js — เพิ่มฟังก์ชัน handleDelete (แสดงเฉพาะส่วนที่เพิ่ม):
// send a DELETE request, then reload the list
const handleDelete = async (id) => {
if (!confirm("Delete this product?")) return; // ask before deleting
await fetch(`/api/products/${id}`, { method: "DELETE" });
loadProducts();
};javascriptconfirm(...) แสดงกล่องยืนยันก่อนส่งคำขอลบ หากกด Cancel ฟังก์ชันจะหยุดที่ return หากยืนยันจึงส่ง DELETE แล้วเรียก loadProducts() เพื่ออัปเดตรายการ
ส่ง handleDelete ให้การ์ดแต่ละใบผ่าน prop onDelete:
/* ...inside the .map(), pass onDelete to each card: */
<ProductCard key={product.id} product={product} onDelete={handleDelete} />javascriptจากนั้นแก้ component การ์ด app/components/ProductCard.js — รับ prop onDelete เพิ่ม แล้วเพิ่มแถวปุ่มท้ายการ์ด โดยครอบลิงก์ไปหน้าแก้ไขด้วย Link:
import Link from "next/link";
// add the onDelete prop, then an actions row at the bottom of the card
export default function ProductCard({ product, onDelete }) {
return (
<div className="product-card">
{/* ...existing image, id, name, price, stock... */}
<div>
<Link href={`/products/${product.id}/edit`}>Edit</Link>{" "}
<button onClick={() => onDelete(product.id)}>Delete</button>
</div>
</div>
);
}javascript6.3 หน้าแก้ไขสินค้า#
สร้างไฟล์ app/products/[id]/edit/page.js — หน้านี้ดึงข้อมูลสินค้าปัจจุบันมาใส่ในฟอร์ม (prefill) แล้วส่ง PUT เมื่อบันทึก จากนั้นใช้ useRouter พากลับไปหน้ารายการ:
"use client";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
export default function EditProductPage() {
const { id } = useParams();
const router = useRouter();
const [name, setName] = useState("");
const [price, setPrice] = useState("");
const [stock, setStock] = useState("");
const [detail, setDetail] = useState("");
// load the current product values into the form
useEffect(() => {
fetch(`/api/products/${id}`)
.then((response) => response.json())
.then((data) => {
setName(data.name);
setPrice(data.price);
setStock(data.stock);
setDetail(data.detail ?? "");
});
}, [id]);
// send a PUT request to save the changes, then go back to the list
const handleUpdate = async (event) => {
event.preventDefault();
await fetch(`/api/products/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
price: Number(price),
stock: Number(stock),
detail,
}),
});
router.push("/products");
};
return (
<div>
<h1>Edit Product</h1>
<img
src={`/products/${id}.png`}
alt={name}
onError={(event) => (event.currentTarget.style.visibility = "hidden")}
/>
<form onSubmit={handleUpdate} className="edit-form">
<label>
Name
<input value={name} onChange={(event) => setName(event.target.value)} />
</label>
<label>
Price
<input type="number" value={price} onChange={(event) => setPrice(event.target.value)} />
</label>
<label>
Stock
<input type="number" value={stock} onChange={(event) => setStock(event.target.value)} />
</label>
<label>
Detail
<input value={detail} onChange={(event) => setDetail(event.target.value)} />
</label>
<button type="submit">Save</button>
</form>
</div>
);
}javascriptหน้าแก้ไขแสดงรูปสินค้า (/products/{id}.png) ไว้เหนือฟอร์ม และ class edit-form เปลี่ยนฟอร์มให้เรียงช่องกรอกจากบนลงล่าง
ทดสอบที่ http://localhost:3000/products ↗ โดยคลิก Edit บนการ์ด หน้าแก้ไขจะแสดงข้อมูลเดิมให้ปรับค่า เมื่อกด Save จะกลับไปยังหน้ารายการพร้อมข้อมูลที่อัปเดตแล้ว จากนั้นลองกด Delete และยืนยันการลบ สินค้ารายการนั้นจะหายไปจากกริด:

หลังแก้ไขหรือลบ กลับไปดูที่แท็บ Browse ใน phpMyAdmin อีกครั้ง จะเห็นว่าแถวข้อมูลถูกอัปเดตหรือถูกลบตามไปด้วย — ครบวงจร CRUD ที่เชื่อมจากหน้าเว็บถึงฐานข้อมูลจริง

7. ตกแต่งด้วย CSS — ธีมร้านกาแฟ (Coffee Shop Theme)#
เมื่อเพิ่ม แสดง แก้ไข และลบสินค้าได้ครบแล้ว เราจะตกแต่งหน้าเว็บผ่าน app/style.css ให้เป็นธีมร้านกาแฟ ใช้พื้นสีครีม ปุ่มสีน้ำตาล และการ์ดมุมโค้ง พร้อมปรับฟอร์มและกริดให้เหมาะกับหน้าจอขนาดเล็ก
ให้นำโค้ดในหัวข้อ 7.1–7.3 วางต่อท้ายไฟล์ app/style.css เดิมตามลำดับ กฎที่เพิ่มจะปรับสไตล์พื้นฐานจากหัวข้อ 2.3 โดยเก็บโค้ดเดิมไว้
7.1 กำหนดชุดสีด้วย CSS Variables (ตัวแปร CSS)#
เริ่มจากกำหนดสีของธีมไว้ที่ :root เป็น CSS variables แล้วเรียกใช้ด้วย var(...) หากต้องการเปลี่ยนโทนสีภายหลังก็แก้เพียงที่เดียว เพิ่มโค้ดต่อท้ายไฟล์ app/style.css:
/* ...existing styles... */
/* ---------- coffee shop theme ---------- */
/* theme colors in one place, reused with var(...) */
:root {
--coffee: #6f4e37;
--coffee-dark: #4a3426;
--bg: #faf7f2;
--surface: #ffffff;
--border: #ece4da;
--text: #2b2118;
--muted: #8a7a6b;
}
body {
margin: 0;
padding: 32px;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
h1 {
font-size: 28px;
font-weight: 600;
letter-spacing: -0.5px;
}css7.2 ตกแต่งปุ่มและการ์ด#
เพิ่มโค้ดต่อไปนี้ท้ายไฟล์เดิมเพื่อปรับช่องกรอกและปุ่มให้มีมุมมน ปุ่ม Add, Save และ Edit ใช้พื้นสีน้ำตาล ส่วน Delete ใช้เส้นขอบ การ์ดจะแสดงเงาเบา ๆ เมื่อเลื่อนเมาส์ผ่าน และหน้าแก้ไขจะจัดรูปกับฟอร์มไว้ข้างกัน:
/* ...existing styles... */
/* rounded pill inputs and buttons */
input,
button,
.product-card a {
padding: 10px 16px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
font: inherit;
color: inherit;
}
input:focus {
border-color: var(--coffee);
outline: none;
}
/* let the inputs shrink so the Add button fits on the same row */
form input {
min-width: 0;
}
/* add form: name, price and Add on one row (wider name column) */
form:not(.edit-form) {
display: grid;
grid-template-columns: 2fr 1fr auto;
max-width: 560px;
}
/* primary buttons: Add, Save, Edit */
button,
.product-card a {
background: var(--coffee);
border-color: var(--coffee);
color: #fff;
text-decoration: none;
cursor: pointer;
transition: background 0.2s;
}
button:hover,
.product-card a:hover {
background: var(--coffee-dark);
}
/* secondary button: Delete is outlined */
.product-card button {
background: transparent;
color: var(--coffee);
}
.product-card button:hover {
background: var(--bg);
}
.product-card {
padding: 16px;
background: var(--surface);
border-color: var(--border);
border-radius: 16px;
transition: box-shadow 0.2s;
}
.product-card:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);
}
.product-card img {
max-width: none;
border-radius: 12px;
background: var(--bg);
}
/* the small "ID: 1" label */
.product-card span:first-of-type {
font-size: 12px;
color: var(--muted);
}
/* Edit and Delete share the bottom row evenly */
.product-card div {
display: flex;
gap: 8px;
margin-top: auto;
}
.product-card div > * {
flex: 1;
text-align: center;
}
/* edit page: image on the left, form on the right
(:has() selects the div that directly contains the edit form) */
div:has(> .edit-form) {
display: grid;
grid-template-columns: 240px 1fr;
gap: 24px;
align-items: start;
max-width: 680px;
}
div:has(> .edit-form) h1 {
grid-column: 1 / -1;
}
div:has(> .edit-form) img {
border-radius: 16px;
background: var(--surface);
}
/* the form sits in an outlined card, like the product cards */
.edit-form {
align-items: stretch;
margin: 0;
padding: 24px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
}
.edit-form label {
display: grid;
gap: 6px;
color: var(--muted);
}
.edit-form button {
align-self: flex-start;
}cssmargin-top: auto ดันแถวปุ่มลงไปชิดขอบล่างของการ์ด ทำให้ปุ่มของทุกการ์ดในแถวเดียวกันอยู่ระดับเดียวกัน แม้ชื่อสินค้าจะยาวไม่เท่ากัน
หน้าแก้ไขจัดรูปไว้ซ้ายและฟอร์มไว้ขวาด้วย CSS ล้วน โดยไม่แก้ JSX — selector div:has(> .edit-form) เลือก <div> ที่มีฟอร์ม edit-form เป็นลูกโดยตรง แล้วทำให้เป็น grid สองคอลัมน์ รูปกว้าง 240px อยู่ซ้าย ฟอร์มใช้พื้นที่ที่เหลือ (1fr) อยู่ขวา ส่วน h1 ใช้ grid-column: 1 / -1 เพื่อให้หัวข้อกินเต็มแถวด้านบน
7.3 Responsive ด้วย Media Query#
กริดจากหัวข้อ 2.3 ใช้ auto-fill เพื่อปรับจำนวนคอลัมน์ตามพื้นที่ที่มี ในขั้นนี้เราจะใช้ media query กำหนดให้หน้าจอกว้างไม่เกิน 600px แสดงการ์ด 2 คอลัมน์ เรียงช่องกรอกของฟอร์มเพิ่มสินค้าในแนวตั้ง และย้ายรูปในหน้าแก้ไขมาไว้เหนือฟอร์ม เพิ่มโค้ดนี้ต่อท้ายไฟล์:
/* ...existing styles... */
/* responsive: phones get 2 smaller columns and a full-width add form */
@media (max-width: 600px) {
body {
padding: 16px;
}
.product-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.product-card {
padding: 10px;
font-size: 14px;
}
/* add form: one column, so inputs and Add are full width */
form:not(.edit-form) {
grid-template-columns: 1fr;
max-width: none;
}
/* edit page: stack the image above the form */
div:has(> .edit-form) {
grid-template-columns: 1fr;
}
}css| ขนาดจอ | ผลลัพธ์ |
|---|---|
| จอกว้าง (desktop) | การ์ดเรียงหลายคอลัมน์ตามความกว้าง ช่องกรอกและปุ่ม Add อยู่แถวเดียวกัน หน้าแก้ไขแสดงรูปซ้าย ฟอร์มขวา |
| จอแคบ ≤ 600px (มือถือ) | การ์ดเรียง 2 คอลัมน์ ขนาดตัวอักษรเล็กลง ช่องกรอกและปุ่ม Add ขยายเต็มความกว้าง หน้าแก้ไขย้ายรูปขึ้นไปไว้เหนือฟอร์ม |
เปิด http://localhost:3000/products ↗ จะเห็นหน้าเว็บในธีมร้านกาแฟ จากนั้นทดสอบ responsive โดยเปิด DevTools (F12) แล้วกด Toggle device toolbar (Ctrl+Shift+M) เลือกอุปกรณ์มือถือ เช่น iPhone จะเห็นการ์ดเปลี่ยนเป็น 2 คอลัมน์


ลองเปลี่ยนค่าสีใน :root เป็นโทนอื่น เช่น สีเขียวสำหรับร้านชา แล้วดูว่าปุ่ม พื้นหลัง และการ์ดเปลี่ยนตามอย่างไร การรวมสีไว้ในตัวแปรช่วยให้ปรับธีมได้จากจุดเดียว
สรุป#
ตอนนี้เราได้เว็บจัดการสินค้าที่เชื่อมตั้งแต่หน้า React ผ่าน API route ของ Next.js ไปจนถึง MySQL แล้ว แต่ละการทำงานใช้ endpoint และคำสั่ง SQL ดังนี้:
| CRUD | HTTP method | API route | SQL |
|---|---|---|---|
| Read | GET | /api/products | SELECT |
| Create | POST | /api/products | INSERT |
| Read one | GET | /api/products/[id] | SELECT ... WHERE id |
| Update | PUT | /api/products/[id] | UPDATE |
| Delete | DELETE | /api/products/[id] | DELETE |

แนวคิดหลักที่ได้เรียนรู้#
- CAMPP + phpMyAdmin สำหรับสร้างและจัดการฐานข้อมูล MySQL บนเครื่อง
.env.localเก็บข้อมูลการเชื่อมต่อฐานข้อมูลแยกจากโค้ด- API route (
route.js) ใน App Router — เขียนGET,POST,PUT,DELETEเป็นฟังก์ชัน mysql2connection pool และ parameterized query (?) เพื่อป้องกัน SQL injection- เชื่อมฝั่งหน้าเว็บด้วย
fetchพร้อมmethod,headersและbody - แยก CSS ไว้ไฟล์เดียว ใช้ CSS variables และ media query เปลี่ยนธีมและทำ responsive โดยไม่แตะ logic
ฝึกต่อ: ลองเพิ่มการตรวจสอบข้อมูลก่อนบันทึก เช่น ชื่อสินค้าต้องไม่ว่างและราคาต้องไม่ติดลบ จากนั้นเพิ่มข้อความแจ้งผลเมื่อบันทึกสำเร็จหรือเรียก API ไม่สำเร็จ เพื่อให้ผู้ใช้เข้าใจว่าเกิดอะไรขึ้นในแต่ละขั้นตอน



