ระบบ Login และหน้าสมาชิกด้วย Next.js และ MySQL
สร้างระบบสมัครสมาชิกและ Login ด้วย Next.js เก็บผู้ใช้ใน MySQL เข้ารหัสผ่านด้วย bcrypt ออก JWT ผ่าน cookie และป้องกันหน้าสมาชิกด้วย proxy
จาก สร้างเว็บ CRUD ด้วย Next.js และ MySQL ผ่าน REST API ที่ใช้ API จัดการข้อมูลสินค้า บทความนี้จะนำแนวทางเดิมมาสร้าง ระบบสมาชิก ผู้ใช้สมัครด้วยอีเมลและรหัสผ่าน จากนั้นเข้าสู่ระบบ (Login) เพื่อเปิดหน้า Dashboard หากยังไม่ได้ Login ระบบจะพาไปหน้า Login โดยอัตโนมัติ
หากเตรียมเครื่องมือไว้แล้ว สามารถข้ามขั้นตอนติดตั้งที่ทำไปแล้วได้ ส่วนผู้ที่ยังไม่ได้เตรียมเครื่อง หัวข้อ 1 และ 2 จะอธิบายตั้งแต่ติดตั้ง CAMPP, VS Code และ Node.js ไปจนถึงสร้างโปรเจกต์ Next.js
ระบบที่สร้างในบทความนี้ประกอบด้วย 4 ส่วน:
| ส่วน | หน้าที่ | API route |
|---|---|---|
| Register | สมัครสมาชิก บันทึกผู้ใช้ใหม่ลงตาราง users | POST /api/register |
| Login | ตรวจอีเมลและรหัสผ่าน แล้วออก token เก็บใน cookie | POST /api/login |
| Protected Area | หน้า Dashboard ที่เปิดได้เฉพาะผู้ที่ Login แล้ว | GET /api/me |
| Logout | ลบ cookie เพื่อออกจากระบบ | POST /api/logout |
ลำดับการสร้างในบทความ:
- เตรียม CAMPP และสร้างตาราง
users - เตรียมเครื่องมือและสร้างโปรเจกต์ Next.js ใหม่
- เชื่อม MySQL และเตรียมฟังก์ชันจัดการ token
- Register — สมัครสมาชิก
- Login — เข้าสู่ระบบ
- Protected Area — หน้าสำหรับสมาชิก
- Logout — ออกจากระบบ

พื้นฐานที่ควรมีก่อนเริ่ม: ควรคุ้นเคยกับ HTML, CSS, JavaScript, React, API route ใน Next.js และการใช้
mysql2หากยังไม่คุ้นเคย แนะนำให้อ่านบทความก่อนหน้านี้ตามลำดับ:
Authentication เบื้องต้น (พื้นฐานการยืนยันตัวตน)#
ก่อนลงมือเขียนโค้ด มาทำความเข้าใจแนวคิดที่เชื่อมการสมัครสมาชิก การ Login และการป้องกันหน้าเว็บเข้าด้วยกัน
Authentication และ Authorization — Authentication คือการยืนยันตัวตน เช่น ตรวจอีเมลและรหัสผ่านตอน Login ส่วน Authorization คือการตรวจสิทธิ์ว่าผู้ใช้นั้นเข้าถึงข้อมูลหรือทำสิ่งใดได้บ้าง บทความนี้เริ่มจากกฎพื้นฐาน คือเปิดหน้า Dashboard ได้เมื่อ Login แล้วเท่านั้น

Password hashing — ห้ามเก็บรหัสผ่านจริง (plain text) ลงฐานข้อมูล หากฐานข้อมูลรั่ว รหัสผ่านของผู้ใช้ทุกคนจะหลุดทันที จึงเก็บเป็น hash ซึ่งเป็นค่าที่คำนวณทางเดียว แปลงกลับเป็นรหัสผ่านเดิมไม่ได้ บทความนี้ใช้ bcrypt ซึ่งออกแบบมาให้คำนวณช้าโดยตั้งใจ และใส่ค่าสุ่ม (salt) ให้ทุก hash ทำให้รหัสผ่านเดียวกันได้ hash ต่างกันทุกครั้ง ตอน Login ใช้ bcrypt.compare เทียบรหัสผ่านที่กรอกกับ hash ที่เก็บไว้

JWT (JSON Web Token) — เมื่อ Login สำเร็จ เซิร์ฟเวอร์ออก token ที่บรรจุข้อมูลผู้ใช้ (เช่น id, email) พร้อม ลายเซ็น (signature) ที่สร้างจากรหัสลับ (JWT_SECRET) ที่รู้เฉพาะเซิร์ฟเวอร์ หากมีผู้แก้ข้อมูลใน token ลายเซ็นจะไม่ตรง เซิร์ฟเวอร์จึงตรวจได้ว่า token ถูกปลอมหรือไม่ token ยังกำหนด วันหมดอายุ ได้ด้วย

Cookie แบบ httpOnly — token ถูกเก็บใน cookie ซึ่งเบราว์เซอร์แนบไปกับทุก request ไปยังเว็บเดิมโดยอัตโนมัติ การตั้ง httpOnly ทำให้ JavaScript บนหน้าเว็บอ่าน cookie นี้ไม่ได้ ลดความเสี่ยงที่ token จะถูกขโมยผ่านสคริปต์แปลกปลอม (XSS)

Proxy — ไฟล์ proxy.js ของ Next.js ทำงาน ก่อน request จะถึงหน้าเว็บ จึงใช้เป็นด่านตรวจ token ได้ หากไม่มี token หรือ token ไม่ถูกต้อง ก็ redirect ไปหน้า Login ได้ทันที (Next.js 15 และก่อนหน้าเรียกไฟล์นี้ว่า middleware.js)

Status code ที่ใช้เพิ่มเติม — นอกจาก 200, 201, 400 จากบทความก่อน บทความนี้ใช้ 401 Unauthorized เมื่อยังไม่ได้ยืนยันตัวตนหรือรหัสผ่านผิด และ 409 Conflict เมื่อสมัครด้วยอีเมลที่มีอยู่แล้ว
1. เตรียม CAMPP และสร้างตาราง users#
เริ่มจากสร้างที่เก็บข้อมูลสมาชิก โดยใช้ CAMPP รันฐานข้อมูลบนเครื่อง แล้วใช้ phpMyAdmin สร้างฐานข้อมูลและตารางผ่านเบราว์เซอร์
1.1 ติดตั้งและเปิด CAMPP#
หากติดตั้ง CAMPP และตั้งรหัสผ่าน MySQL ไว้แล้วจากบทความก่อนหน้า ให้เปิด CAMPP กด Start แล้วข้ามไปหัวข้อ 1.2 ได้เลย
ดาวน์โหลดและติดตั้ง CAMPP จาก https://campp.melivecode.com/ ↗
ระหว่างติดตั้ง ให้ ตั้งรหัสผ่าน MySQL สำหรับผู้ใช้ root โดยต้องระบุรหัสผ่านที่ไม่ใช่ค่าว่าง เก็บรหัสผ่านนี้ไว้สำหรับตั้งค่าการเชื่อมต่อในหัวข้อ 3.2

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

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


1.2 สร้างฐานข้อมูลและตาราง users#
ในหน้า phpMyAdmin คลิกแท็บ SQL ด้านบน วางคำสั่งด้านล่างนี้แล้วคลิก Go เพื่อสร้างตาราง users ในฐานข้อมูล crud_db (หากฐานข้อมูลนี้มีอยู่แล้วจากบทความก่อนหน้า คำสั่ง IF NOT EXISTS จะใช้ฐานข้อมูลเดิมโดยไม่ลบตาราง products):
-- สร้างฐานข้อมูล (ใช้ของเดิมหากมีอยู่แล้ว)
CREATE DATABASE IF NOT EXISTS crud_db;
USE crud_db;
-- สร้างตาราง users
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
fullname VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);sql
แต่ละคอลัมน์ในตาราง users มีหน้าที่ดังนี้:
| คอลัมน์ | ชนิดข้อมูล | คำอธิบาย |
|---|---|---|
id | INT AUTO_INCREMENT | รหัสประจำผู้ใช้ (Primary Key) เพิ่มอัตโนมัติ |
email | VARCHAR(255) UNIQUE | อีเมลสำหรับ Login — UNIQUE ห้ามมีอีเมลซ้ำในตาราง |
password | VARCHAR(255) | hash ของรหัสผ่าน ไม่ใช่รหัสผ่านจริง |
fullname | VARCHAR(255) | ชื่อที่แสดงในหน้า Dashboard |
created_at | TIMESTAMP | วันเวลาที่สมัครสมาชิก ตั้งค่าอัตโนมัติ |
เมื่อสร้างตารางแล้ว ให้เว้นข้อมูลไว้ก่อน เพราะคอลัมน์ password ต้องเก็บ hash ที่สร้างด้วย bcrypt ผู้ใช้คนแรกจะถูกเพิ่มผ่านหน้า Register ในหัวข้อ 4 เพื่อให้ข้อมูลผ่านขั้นตอนเดียวกับการสมัครสมาชิกจริง

คลิกชื่อฐานข้อมูล crud_db ที่แผงด้านซ้าย แล้วคลิกตาราง users จากนั้นคลิกแท็บ Structure เพื่อตรวจว่าคอลัมน์ครบทั้ง 5 คอลัมน์
2. เตรียมเครื่องมือและสร้างโปรเจกต์#
เมื่อมีตาราง users แล้ว ขั้นต่อไปคือสร้างโปรเจกต์ 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#
สร้างโปรเจกต์ชื่อ auth_mysql ด้วยคำสั่งต่อไปนี้ โดย --js ระบุให้ใช้ JavaScript เพื่อให้ตรงกับโค้ดในบทความ:
# สร้างโปรเจกต์ Next.js ใหม่
npx create-next-app@16 auth_mysql --js
# เข้าไปในโปรเจกต์ แล้วเปิด VS Code
cd auth_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 สำหรับจัดฟอร์ม Register, Login และกล่อง Dashboard ให้อยู่กลางจอ:
/* app/style.css — สไตล์พื้นฐานที่ใช้ร่วมกันในหน้า auth */
body {
font-family: sans-serif;
padding: 20px;
background: #f5f6f8;
}
/* กล่องสีขาวกลางจอ ใช้ในทุกหน้า */
.auth-box {
max-width: 360px;
margin: 40px auto;
padding: 24px;
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
}
form {
display: flex;
flex-direction: column;
gap: 10px;
}
input,
button {
padding: 10px;
font: inherit;
border: 1px solid #ccc;
border-radius: 6px;
}
button {
background: #00758f;
border-color: #00758f;
color: #fff;
cursor: pointer;
}
/* ข้อความแจ้ง error ใต้ฟอร์ม */
.error {
color: #c0392b;
}cssแก้ไฟล์ app/layout.js เปลี่ยนบรรทัด import "./globals.css"; ที่ create-next-app สร้างไว้ ให้ import style.css แทน แล้วลบไฟล์ app/globals.css ทิ้ง:
import "./style.css"; // ใช้แทน import "./globals.css"; เดิม
/* ...โค้ด layout เดิม... */javascriptจากนั้นแทนที่เนื้อหาทั้งหมดในไฟล์ app/page.js (หน้าแรกที่ create-next-app สร้างไว้) ด้วยลิงก์ไปยังหน้าที่จะสร้างในบทความ:
import Link from "next/link";
// หน้าแรก: ลิงก์ไปยังทุกหน้าในโปรเจกต์
export default function Home() {
return (
<div className="auth-box">
<h1>Auth Demo</h1>
<ul>
<li><Link href="/register">Register</Link></li>
<li><Link href="/login">Login</Link></li>
<li><Link href="/dashboard">Dashboard (members only)</Link></li>
</ul>
</div>
);
}javascript3. เชื่อม MySQL และเตรียมฟังก์ชันจัดการ token#
ก่อนสร้างหน้า Register และ Login ต้องเตรียมส่วนที่ทั้งระบบใช้ร่วมกัน ได้แก่ การเชื่อมต่อ MySQL การตั้งค่าลับ และฟังก์ชันสำหรับสร้างและตรวจ token
3.1 ติดตั้ง mysql2, bcryptjs และ jose#
หยุด development server ชั่วคราว (กด Ctrl+C ใน Terminal) แล้วติดตั้งไลบรารี 3 ตัว:
npm install mysql2 bcryptjs josebash| ไลบรารี | ใช้ทำอะไร |
|---|---|
mysql2 | ส่งคำสั่ง SQL ไปยัง MySQL (ใช้แบบเดียวกับบทความก่อนหน้า) |
bcryptjs | สร้าง hash ของรหัสผ่าน และเทียบรหัสผ่านกับ hash ตอน Login |
jose | สร้างและตรวจ JWT — ทำงานได้ทั้งใน API route และใน proxy.js |
3.2 ตั้งค่า .env.local#
สร้างไฟล์ .env.local ที่รากของโปรเจกต์ เก็บข้อมูลการเชื่อมต่อฐานข้อมูลและรหัสลับสำหรับเซ็น JWT:
DB_HOST=localhost
DB_PORT=3307
DB_USERNAME=root
DB_PASSWORD=your-campp-password
DB_DATABASE=crud_db
JWT_SECRET=replace-with-a-long-random-stringbash| ค่า | คำอธิบาย |
|---|---|
DB_HOST | ที่อยู่ของเซิร์ฟเวอร์ฐานข้อมูล — เครื่องเดียวกันคือ localhost |
DB_PORT | CAMPP ใช้ port 3307 สำหรับ MySQL (ไม่ใช่ 3306) |
DB_USERNAME | ชื่อผู้ใช้ฐานข้อมูล ค่าเริ่มต้นคือ root |
DB_PASSWORD | รหัสผ่านที่ตั้งไว้ตอนติดตั้ง CAMPP (หัวข้อ 1.1) |
DB_DATABASE | ชื่อฐานข้อมูลที่สร้างไว้ คือ crud_db |
JWT_SECRET | รหัสลับสำหรับเซ็นและตรวจ token ห้ามเผยแพร่ |
JWT_SECRET ควรเป็นข้อความสุ่มที่ยาวพอ สร้างได้ด้วยคำสั่งนี้ แล้วนำผลลัพธ์ไปวางแทน replace-with-a-long-random-string:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"bashก่อนเริ่มเซิร์ฟเวอร์อีกครั้ง: แทนที่
your-campp-passwordด้วยรหัสผ่านจากหัวข้อ 1.1 หลังแก้.env.localทุกครั้งต้องรีสตาร์ต development server เพื่อให้ใช้ค่าใหม่ ตรวจสอบด้วยว่า.gitignoreครอบคลุมไฟล์.env.localเพื่อไม่ให้รหัสผ่านและJWT_SECRETถูก commit ไปกับโปรเจกต์ หากJWT_SECRETรั่ว ผู้อื่นจะปลอม token ของผู้ใช้คนใดก็ได้
3.3 สร้าง connection pool (lib/db.js)#
สร้างโฟลเดอร์ lib ที่รากของโปรเจกต์ แล้วสร้างไฟล์ lib/db.js ภายใน เพื่อรวมการเชื่อมต่อฐานข้อมูลไว้ให้ API route เรียกใช้:
import mysql from "mysql2/promise";
// connection pool นำการเชื่อมต่อกลับมาใช้ซ้ำในทุก request แทนการเปิดการเชื่อมต่อใหม่ทุกครั้ง
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,
});javascript3.4 ฟังก์ชันสร้างและตรวจ token (lib/auth.js)#
สร้างไฟล์ lib/auth.js รวมฟังก์ชันจัดการ JWT ไว้ที่เดียว API route Login, API route /api/me และ proxy.js จะเรียกใช้ไฟล์นี้:
import { SignJWT, jwtVerify } from "jose";
// jose ต้องการ secret ในรูปแบบ bytes ไม่ใช่ string ธรรมดา
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
// สร้าง token ที่เซ็นแล้ว หมดอายุใน 1 ชั่วโมง
export function signToken(payload) {
return new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1h")
.sign(secret);
}
// คืน payload หาก token ถูกต้อง หากไม่ถูกต้องคืน null
export async function verifyToken(token) {
try {
const { payload } = await jwtVerify(token, secret);
return payload;
} catch {
return null; // ไม่มี token, token ถูกแก้ไข หรือหมดอายุ
}
}javascript| ส่วน | คำอธิบาย |
|---|---|
new SignJWT(payload) | เริ่มสร้าง token โดยใส่ข้อมูลผู้ใช้ใน payload |
setProtectedHeader({ alg: "HS256" }) | เลือกวิธีเซ็นลายเซ็นด้วย JWT_SECRET |
setExpirationTime("1h") | token หมดอายุภายใน 1 ชั่วโมง |
jwtVerify(token, secret) | ตรวจลายเซ็นและวันหมดอายุ หากไม่ผ่านจะ throw error |
verifyToken คืนค่า null ในทุกกรณีที่ token ใช้ไม่ได้ ไม่ว่าจะไม่มี token, token ถูกแก้ไข หรือหมดอายุ ผู้เรียกจึงตรวจเพียงว่าผลลัพธ์เป็น null หรือไม่
4. Register — สมัครสมาชิก#
เริ่มสร้างระบบสมาชิกจากการสมัครใช้งาน โดย API จะรับชื่อ อีเมล และรหัสผ่าน ตรวจข้อมูลและอีเมลซ้ำ แล้วสร้าง hash ของรหัสผ่านก่อนบันทึกลงตาราง users

4.1 API route: POST /api/register#
สร้างไฟล์ app/api/register/route.js:
import { NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { pool } from "@/lib/db";
// POST /api/register — สร้างผู้ใช้ใหม่
export async function POST(request) {
const { email, password, fullname } = await request.json();
if (!email || !password || !fullname) {
return NextResponse.json(
{ status: "error", message: "email, password and fullname are required" },
{ status: 400 }
);
}
// ปฏิเสธอีเมลที่สมัครไว้แล้ว
const [rows] = await pool.query("SELECT id FROM users WHERE email = ?", [email]);
if (rows.length > 0) {
return NextResponse.json(
{ status: "error", message: "Email already registered" },
{ status: 409 }
);
}
// ห้ามเก็บรหัสผ่านจริง เก็บเฉพาะ bcrypt hash
const hash = await bcrypt.hash(password, 10);
const [result] = await pool.query(
"INSERT INTO users (email, password, fullname) VALUES (?, ?, ?)",
[email, hash, fullname]
);
return NextResponse.json({ status: "ok", id: result.insertId }, { status: 201 });
}javascript| ส่วน | คำอธิบาย |
|---|---|
SELECT id FROM users WHERE email = ? | ตรวจว่ามีอีเมลนี้แล้วหรือยัง หากมีตอบ 409 Conflict |
bcrypt.hash(password, 10) | สร้าง hash ของรหัสผ่าน เลข 10 คือ cost (จำนวนรอบการคำนวณ) ยิ่งมากยิ่งปลอดภัยแต่ช้าลง |
INSERT ... VALUES (?, ?, ?) | บันทึก hash ลงคอลัมน์ password แทนรหัสผ่านจริง โดยใช้ parameterized query (?) ป้องกัน SQL injection |
หมายเหตุ: คอลัมน์
UNIQUEไว้ในหัวข้อ 1.2 แม้มี request สมัครด้วยอีเมลเดียวกันพร้อมกันสองครั้ง ฐานข้อมูลก็จะไม่ยอมให้มีอีเมลซ้ำ การSELECTก่อนจึงมีไว้เพื่อตอบข้อความที่อ่านเข้าใจได้
4.2 หน้าสมัครสมาชิก#
เมื่อ API พร้อมแล้ว ให้สร้างไฟล์ app/register/page.js เป็นฟอร์มรับชื่อ อีเมล และรหัสผ่าน เมื่อกดส่ง ฟอร์มจะเรียก /api/register หากสมัครสำเร็จจะพาไปหน้า Login หากเกิดข้อผิดพลาดจะแสดงข้อความที่ API ส่งกลับมา:
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
export default function RegisterPage() {
const router = useRouter();
const [fullname, setFullname] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [message, setMessage] = useState("");
// ส่งข้อมูลฟอร์มไปยัง API หากสำเร็จไปหน้า login
const handleRegister = async (event) => {
event.preventDefault();
const response = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fullname, email, password }),
});
const data = await response.json();
if (response.ok) {
router.push("/login");
} else {
setMessage(data.message); // เช่น "Email already registered"
}
};
return (
<div className="auth-box">
<h1>Register</h1>
<form onSubmit={handleRegister}>
<input
value={fullname}
onChange={(event) => setFullname(event.target.value)}
placeholder="Full name"
required
/>
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="Email"
required
/>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="Password"
minLength={6}
required
/>
<button type="submit">Register</button>
</form>
{message && <p className="error">{message}</p>}
<p>
Already have an account? <Link href="/login">Login</Link>
</p>
</div>
);
}javascriptresponse.ok เป็น true เมื่อ status code อยู่ในกลุ่ม 2xx จึงใช้แยกกรณีสำเร็จกับไม่สำเร็จได้ ส่วน type="email", minLength และ required ให้เบราว์เซอร์ตรวจข้อมูลเบื้องต้นก่อนส่ง
เริ่ม development server อีกครั้ง:
npm run devbashจากนั้นเปิด http://localhost:3000/register ↗ กรอกข้อมูลแล้วกด Register จะถูกพาไปหน้า /login (ซึ่งยังไม่ได้สร้าง จึงแสดง 404 ไปก่อน)

กลับไปที่ phpMyAdmin คลิกตาราง users แล้วคลิกแท็บ Browse จะเห็นผู้ใช้ใหม่ โดยคอลัมน์ password เป็นข้อความยาวที่ขึ้นต้นด้วย $2b$10$ ซึ่งคือ bcrypt hash ไม่ใช่รหัสผ่านที่กรอก ลองสมัครด้วยอีเมลเดิมอีกครั้ง จะเห็นข้อความ Email already registered

5. Login — เข้าสู่ระบบ#
เมื่อสมัครสมาชิกได้แล้ว ขั้นต่อไปคือสร้างขั้นตอน Login เพื่อตรวจอีเมลและรหัสผ่านกับข้อมูลในฐานข้อมูล หากถูกต้อง API จะสร้าง JWT และส่งกลับใน cookie จากนั้นเบราว์เซอร์จะแนบ cookie นี้มากับ request ถัดไปโดยอัตโนมัติ

5.1 API route: POST /api/login#
สร้างไฟล์ app/api/login/route.js:
import { NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { pool } from "@/lib/db";
import { signToken } from "@/lib/auth";
// POST /api/login — ตรวจรหัสผ่านแล้วตั้ง cookie token
export async function POST(request) {
const { email, password } = await request.json();
if (!email || !password) {
return NextResponse.json(
{ status: "error", message: "email and password are required" },
{ status: 400 }
);
}
const [rows] = await pool.query("SELECT * FROM users WHERE email = ?", [email]);
const user = rows[0];
// ใช้ข้อความเดียวกันทั้งกรณีไม่พบอีเมลและรหัสผ่านผิด
// เพื่อไม่ให้ใช้ฟอร์มนี้ตรวจได้ว่าอีเมลใดมีในระบบ
if (!user || !(await bcrypt.compare(password, user.password))) {
return NextResponse.json(
{ status: "error", message: "Invalid email or password" },
{ status: 401 }
);
}
const token = await signToken({
id: user.id,
email: user.email,
fullname: user.fullname,
});
const response = NextResponse.json({ status: "ok" });
// httpOnly: JavaScript บนหน้าเว็บอ่าน cookie นี้ไม่ได้
response.cookies.set("token", token, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 60 * 60, // 1 ชั่วโมง เท่ากับอายุของ token
});
return response;
}javascript| ส่วน | คำอธิบาย |
|---|---|
bcrypt.compare(password, user.password) | เทียบรหัสผ่านที่กรอกกับ hash ในฐานข้อมูล คืนค่า true หรือ false |
"Invalid email or password" | ใช้ข้อความเดียวกันทั้งกรณีไม่พบอีเมลและรหัสผ่านผิด เพื่อไม่บอกใบ้ว่าอีเมลใดมีในระบบ |
signToken({ id, email, fullname }) | ใส่เฉพาะข้อมูลที่จำเป็นใน token — ห้ามใส่รหัสผ่านหรือ hash เพราะ payload ของ JWT ไม่ได้ถูกซ่อน (ไม่ใช่ encryption) |
response.cookies.set("token", ...) | แนบ cookie ชื่อ token ไปกับ response |
ตัวเลือกของ cookie มีความหมายดังนี้:
| ตัวเลือก | ความหมาย |
|---|---|
httpOnly: true | JavaScript บนหน้าเว็บอ่าน cookie นี้ไม่ได้ (ป้องกันการขโมยผ่าน XSS) |
sameSite: "lax" | ไม่แนบ cookie ไปกับ request ส่วนใหญ่ที่มาจากเว็บอื่น ลดความเสี่ยง CSRF |
secure | ส่ง cookie เฉพาะผ่าน HTTPS — เปิดเมื่อ deploy จริง ปิดตอนพัฒนาบน http://localhost |
path: "/" | ใช้ cookie ได้ทุกหน้าในเว็บ |
maxAge: 60 * 60 | cookie หมดอายุใน 3,600 วินาที (1 ชั่วโมง) |
5.2 หน้าเข้าสู่ระบบ#
สร้างไฟล์ app/login/page.js โครงสร้างเหมือนหน้า Register แต่มีเพียงอีเมลและรหัสผ่าน หาก Login สำเร็จจะพาไปหน้า /dashboard:
"use client";
import { useState } from "react";
import Link from "next/link";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [message, setMessage] = useState("");
// ส่งอีเมลและรหัสผ่าน หากสำเร็จ API จะตั้ง cookie token ให้
const handleLogin = async (event) => {
event.preventDefault();
const response = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await response.json();
if (response.ok) {
// โหลดหน้าใหม่ทั้งหน้า ให้ proxy.js ตรวจ cookie ที่เพิ่งได้รับ
window.location.href = "/dashboard";
} else {
setMessage(data.message); // เช่น "Invalid email or password"
}
};
return (
<div className="auth-box">
<h1>Login</h1>
<form onSubmit={handleLogin}>
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="Email"
required
/>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="Password"
required
/>
<button type="submit">Login</button>
</form>
{message && <p className="error">{message}</p>}
<p>
No account yet? <Link href="/register">Register</Link>
</p>
</div>
);
}javascriptหน้า Login มีหน้าที่ส่งข้อมูลและแสดงผลเท่านั้น ส่วนการจัดเก็บ token เป็นหน้าที่ของเบราว์เซอร์ ซึ่งรับ cookie จาก API ให้อัตโนมัติ
หน้า Login ใช้ window.location.href แทน router.push ของหน้า Register เพราะเมื่อ deploy จริง Next.js จะ prefetch หน้าที่มี <Link> ชี้ไว้ล่วงหน้า เช่น ลิงก์ Dashboard ในหน้าแรก หาก prefetch เกิดขึ้นตอนยังไม่ Login ผลที่ได้คือ redirect ไป /login จาก proxy.js และถูกเก็บไว้ใน cache ของเบราว์เซอร์ router.push("/dashboard") จะใช้ผลเก่านั้นซ้ำ ทำให้ Login สำเร็จแต่ยังค้างอยู่หน้าเดิม ส่วน window.location.href โหลดหน้าใหม่จากเซิร์ฟเวอร์ทุกครั้ง proxy.js จึงตรวจ cookie ใหม่เสมอ (ปัญหานี้ไม่เกิดตอน npm run dev เพราะโหมด development ไม่ prefetch)
เปิด http://localhost:3000/login ↗ ลองกรอกรหัสผ่านผิดก่อน จะเห็นข้อความ Invalid email or password จากนั้นกรอกให้ถูกต้อง จะถูกพาไปหน้า /dashboard (ยังไม่ได้สร้าง จึงแสดง 404 ไปก่อน)

6. Protected Area — หน้าสำหรับสมาชิก#
ตอนนี้ระบบออก token หลัง Login ได้แล้ว ขั้นต่อไปคือใช้ token ตรวจสิทธิ์เข้าหน้า Dashboard โดยทำงานร่วมกัน 2 ส่วน: proxy.js ตรวจ token ก่อนเปิดหน้า /dashboard และ API /api/me ตรวจ token ก่อนส่งข้อมูลผู้ใช้กลับไป

6.1 API route: GET /api/me#
สร้างไฟล์ app/api/me/route.js API นี้อ่าน cookie token ตรวจด้วย verifyToken แล้วส่งข้อมูลผู้ใช้กลับ:
import { NextResponse } from "next/server";
import { verifyToken } from "@/lib/auth";
// GET /api/me — คืนข้อมูลผู้ใช้ที่ Login อยู่ หรือ 401
export async function GET(request) {
const user = await verifyToken(request.cookies.get("token")?.value);
if (!user) {
return NextResponse.json(
{ status: "error", message: "Unauthorized" },
{ status: 401 }
);
}
return NextResponse.json({ id: user.id, email: user.email, fullname: user.fullname });
}javascriptrequest.cookies.get("token")?.value อ่านค่า cookie ที่เบราว์เซอร์แนบมา เครื่องหมาย ?. ป้องกัน error เมื่อไม่มี cookie นี้ (ได้ค่า undefined แล้ว verifyToken คืน null)
ทดสอบโดยเปิด http://localhost:3000/api/me ↗ บนเบราว์เซอร์เดิมที่ Login ไว้ จะเห็นข้อมูลผู้ใช้เป็น JSON แต่หากเปิดในหน้าต่าง Incognito (ไม่มี cookie) จะได้ {"status":"error","message":"Unauthorized"}
6.2 ป้องกันหน้าด้วย proxy.js#
สร้างไฟล์ proxy.js ที่ รากของโปรเจกต์ (ระดับเดียวกับโฟลเดอร์ app และ lib ไม่ใช่ภายใน app):
import { NextResponse } from "next/server";
import { verifyToken } from "@/lib/auth";
// ทำงานก่อนทุก request ที่ตรงกับ config.matcher
export async function proxy(request) {
const user = await verifyToken(request.cookies.get("token")?.value);
if (!user) {
// ยังไม่ Login หรือ token หมดอายุ: ส่งไปหน้า login
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next(); // token ถูกต้อง: ไปยังหน้าที่ขอได้ตามปกติ
}
// ให้ proxy ทำงานเฉพาะพื้นที่สมาชิก
export const config = {
matcher: ["/dashboard/:path*"],
};javascript| ส่วน | คำอธิบาย |
|---|---|
export async function proxy(request) | Next.js เรียกฟังก์ชันนี้ก่อน request จะถึงหน้าเว็บ |
NextResponse.redirect(new URL("/login", request.url)) | ส่งผู้ใช้ไปหน้า Login แทนการแสดงหน้าที่ขอ |
NextResponse.next() | ปล่อยให้ request ไปถึงหน้าเว็บตามปกติ |
matcher: ["/dashboard/:path*"] | ตรวจเฉพาะ /dashboard และทุกหน้าย่อย เช่น /dashboard/settings |
ทำไมต้องตรวจทั้งใน proxy และใน API:
proxy.jsช่วยให้ผู้ใช้ที่ยังไม่ Login ไม่เห็นหน้า Dashboard แต่ API route เรียกตรงได้จากเครื่องมืออื่นโดยไม่ผ่านหน้าเว็บ API ที่ส่งข้อมูลสำคัญจึงต้องตรวจ token เองเสมอ เหมือนที่/api/meทำในหัวข้อ 6.1
หมายเหตุ: หากทำตามบทความด้วย Next.js 15 หรือเก่ากว่า ให้ตั้งชื่อไฟล์เป็น
middleware.jsและเปลี่ยนชื่อฟังก์ชันเป็นmiddlewareแทน ส่วนโค้ดภายในเหมือนเดิม
6.3 หน้า Dashboard#
สร้างไฟล์ app/dashboard/page.js หน้านี้เรียก /api/me เพื่อนำชื่อและอีเมลของผู้ใช้มาแสดง:
"use client";
import { useState, useEffect } from "react";
export default function DashboardPage() {
const [user, setUser] = useState(null);
// โหลดข้อมูลผู้ใช้ที่ Login อยู่จาก API
useEffect(() => {
fetch("/api/me")
.then((response) => response.json())
.then((data) => setUser(data));
}, []);
if (!user) {
return <p>Loading...</p>;
}
return (
<div className="auth-box">
<h1>Dashboard</h1>
<p>Welcome, {user.fullname}</p>
<p>Email: {user.email}</p>
</div>
);
}javascriptทดสอบทั้ง 2 กรณี:
| สถานะ | เปิด http://localhost:3000/dashboard ↗ แล้ว |
|---|---|
| Login แล้ว | เห็นหน้า Dashboard พร้อมชื่อและอีเมลของผู้ใช้ |
| ยังไม่ Login (หรือเปิดใน Incognito) | ถูก redirect ไปหน้า /login ทันที |

ตรวจ cookie ด้วย DevTools — เมื่อ Login แล้วไปถึงหน้า Dashboard ได้จริง ให้กลับไปที่ http://localhost:3000/login ↗ กด F12 เปิด DevTools แล้วตรวจ 3 จุดต่อไปนี้:
1. Response ของ /api/login ส่ง cookie กลับมา — ก่อนกด Login ให้เปิดแท็บ Network ติ๊กช่อง Keep log และเลือกตัวกรอง Fetch/XHR แล้วจึง Login เนื่องจากหลัง Login หน้าเว็บจะโหลดหน้า /dashboard ใหม่ทั้งหน้า (window.location.href) และ DevTools จะล้างรายการในแท็บ Network ทุกครั้งที่เปลี่ยนหน้า หากไม่ติ๊ก Keep log request login จะหายไป คลิก request login แล้วดูแท็บ Headers ในส่วน Response Headers จะเห็น Set-Cookie: token=eyJ... พร้อมตัวเลือก Max-Age=3600; HttpOnly; SameSite=lax ตรงกับที่ตั้งไว้ใน response.cookies.set หัวข้อ 5.1

2. เบราว์เซอร์เก็บ cookie ไว้ — ไปที่แท็บ Application → Cookies → http://localhost:3000 จะเห็น cookie ชื่อ token ที่ช่อง HttpOnly มีเครื่องหมายถูก และช่อง SameSite เป็น Lax ค่าของ cookie คือ JWT ที่ขึ้นต้นด้วย eyJ

3. JavaScript อ่าน cookie นี้ไม่ได้ — ไปที่แท็บ Console พิมพ์ document.cookie แล้วกด Enter จะได้ค่าว่าง '' แม้แท็บ Application จะแสดงว่ามี token อยู่ นี่คือผลของ httpOnly ที่กันสคริปต์บนหน้าเว็บไม่ให้เข้าถึง token

ตรวจการ redirect ด้วย DevTools — เปิดหน้าต่าง Incognito ซึ่งยังไม่มี cookie token (หรือลบ cookie ในแท็บ Application) จากนั้นเปิดแท็บ Network เลือกตัวกรอง Doc แล้วพิมพ์ http://localhost:3000/dashboard ↗ ในช่อง URL จะเห็น request dashboard ได้ status 307 (Temporary Redirect) ตามด้วย request login ที่ได้ 200 ซึ่งเป็นผลจาก NextResponse.redirect ใน proxy.js

6.4 Inside the JWT — Encode, Decode และ Signing (ภายใน JWT)#
ค่าของ cookie token คือ JWT ซึ่งประกอบด้วย 3 ส่วนที่คั่นด้วยจุด:
eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZW1haWwiOiJrYXJueW9uZ0BtZS5jb20iLCJmdWxsbmFtZSI6Ikthcm4gWW9uZ3Npcml3aXQiLCJpYXQiOjE3OTM1MjAwMDAsImV4cCI6MTc5MzUyMzYwMH0.4cfHhM7O8QyDZe3Nm16tFDQ2goTWQa9VDPoe_Iy9org| ส่วน | เก็บอะไร |
|---|---|
| header | {"alg":"HS256"} — algorithm ที่ใช้เซ็น |
| payload | {"id":1,"email":...,"iat":...,"exp":...} — ข้อมูลผู้ใช้ เวลาออก token (iat) และเวลาหมดอายุ (exp) |
| signature | หลักฐานว่า header และ payload ไม่ถูกแก้ไข |
Encode และ decode — header และ payload คือ JSON ที่แปลงเป็นตัวอักษรที่ใส่ใน URL ได้อย่างปลอดภัยด้วย base64url encoding ส่วนการ decode คือการแปลงกลับเป็น JSON เดิม JWT ทุกตัวจึงขึ้นต้นด้วย eyJ ซึ่งคือ {" หลังผ่าน encoding ลอง decode payload ของ token จริง (คัดลอกค่า token จากแท็บ Application มาแทน eyJ...):
node -e "console.log(Buffer.from(process.argv[1].split('.')[1], 'base64url').toString())" eyJ...bash{"id":1,"email":"karnyong@me.com","fullname":"Karn Yongsiriwit","iat":1793520000,"exp":1793523600}ผลลัพธ์คือข้อมูลที่ส่งให้ signToken ในหัวข้อ 5.1 พร้อม iat และ exp ที่ jose เพิ่มให้ base64url ช่วยให้ส่งข้อมูลผ่าน cookie และ URL ได้สะดวก แต่ไม่ได้ซ่อนข้อมูล (ไม่ใช่ encryption) จึงไม่ใส่รหัสผ่านหรือข้อมูลลับใน payload
Signing — ตอนสร้าง token เซิร์ฟเวอร์คำนวณ HMAC-SHA256 ของ header.payload ด้วย JWT_SECRET แล้วแนบผลลัพธ์ไว้เป็น signature ตอนตรวจ jwtVerify จะคำนวณ signature ใหม่จาก header และ payload ที่ได้รับแล้วเทียบกับของเดิม หาก payload ถูกแก้แม้แต่ตัวอักษรเดียว (เช่น เปลี่ยน id จาก 1 เป็น 2 เพื่อปลอมเป็นผู้ใช้อื่น) signature จะไม่ตรงกัน verifyToken คืนค่า null และ proxy.js redirect ไปหน้า /login การสร้าง signature ใหม่ให้ตรงต้องใช้ JWT_SECRET ซึ่งมีเฉพาะเซิร์ฟเวอร์

7. Logout — ออกจากระบบ#
ขั้นตอนสุดท้ายคือ Logout โดยให้ API ลบ cookie token ออกจากเบราว์เซอร์ เมื่อผู้ใช้เปิดหน้า Dashboard อีกครั้ง proxy.js จะตรวจไม่พบ token และพากลับไปหน้า Login
7.1 API route: POST /api/logout#
สร้างไฟล์ app/api/logout/route.js:
import { NextResponse } from "next/server";
// POST /api/logout — ลบ cookie token
export async function POST() {
const response = NextResponse.json({ status: "ok" });
response.cookies.delete("token");
return response;
}javascriptเนื่องจาก JavaScript บนหน้าเว็บเข้าถึง cookie แบบ httpOnly ไม่ได้ ปุ่ม Logout จึงต้องเรียก API เพื่อให้เซิร์ฟเวอร์ส่ง response กลับมาลบ cookie
ข้อจำกัดของ JWT: การ Logout ลบเพียง cookie บนเบราว์เซอร์ token เดิมยังใช้ได้จนกว่าจะหมดอายุ (1 ชั่วโมง) หากมีผู้คัดลอก token ไปก่อนหน้า นี่คือเหตุผลที่ควรตั้งอายุ token ให้สั้น ระบบจริงที่ต้องยกเลิก token ได้ทันทีมักเก็บ session ไว้ในฐานข้อมูลแทน
7.2 ปุ่ม Logout ในหน้า Dashboard#
แก้ไฟล์ app/dashboard/page.js เดิม เพิ่มฟังก์ชัน handleLogout และปุ่ม Logout (แสดงเฉพาะส่วนที่เพิ่ม):
"use client";
import { useState, useEffect } from "react";
export default function DashboardPage() {
const [user, setUser] = useState(null);
/* ...useEffect เดิมที่โหลด /api/me... */
// ให้เซิร์ฟเวอร์ลบ cookie แล้วไปหน้า login
const handleLogout = async () => {
await fetch("/api/logout", { method: "POST" });
window.location.href = "/login"; // โหลดหน้าใหม่ ล้างหน้า Dashboard ที่ cache ไว้
};
/* ...ส่วนตรวจ loading เดิม... */
return (
<div className="auth-box">
{/* ...หัวข้อ ชื่อ และอีเมลเดิม... */}
<button onClick={handleLogout}>Logout</button>
</div>
);
}javascriptทดสอบที่ http://localhost:3000/dashboard ↗ กด Logout จะถูกพาไปหน้า Login จากนั้นลองเปิด http://localhost:3000/dashboard ↗ อีกครั้ง จะถูก redirect กลับไปหน้า Login เพราะไม่มี cookie token แล้ว ตรวจในแท็บ Application → Cookies จะเห็นว่า token หายไป

สรุป#
เมื่อทำครบทุกขั้นตอน จะได้ระบบสมัครสมาชิก Login เปิดหน้าสมาชิก และ Logout ที่ทำงานร่วมกันตั้งแต่หน้า React, API route และ proxy.js ไปจนถึงตาราง users ใน MySQL:
| ขั้นตอน | HTTP method | Route | สิ่งที่เกิดขึ้น |
|---|---|---|---|
| Register | POST | /api/register | bcrypt.hash แล้ว INSERT ลงตาราง users |
| Login | POST | /api/login | bcrypt.compare แล้วส่ง JWT ใน cookie แบบ httpOnly |
| อ่านข้อมูลผู้ใช้ | GET | /api/me | verifyToken แล้วส่งข้อมูลผู้ใช้กลับ หรือ 401 |
| เปิดหน้าสมาชิก | — | /dashboard | proxy.js ตรวจ token หากไม่ผ่าน redirect ไป /login |
| Logout | POST | /api/logout | ลบ cookie token |

แนวคิดหลักที่ได้เรียนรู้#
- Authentication ยืนยันว่าผู้ใช้เป็นใคร ส่วน Authorization ตัดสินว่าเข้าถึงอะไรได้
- เก็บรหัสผ่านเป็น bcrypt hash เสมอ ไม่เก็บ plain text และเทียบด้วย
bcrypt.compare - JWT เซ็นด้วย
JWT_SECRETตรวจได้ว่าถูกแก้ไขหรือหมดอายุหรือไม่ แต่ payload อ่านได้ จึงไม่ใส่ข้อมูลลับ - เก็บ token ใน cookie แบบ httpOnly ร่วมกับ
sameSiteและsecure proxy.jsทำหน้าที่เป็นด่านตรวจก่อนเข้าหน้าสมาชิก และ API ที่ส่งข้อมูลสำคัญต้องตรวจ token เองอีกชั้น- ตอบข้อความเดียวกันเมื่อ Login ไม่สำเร็จ เพื่อไม่เปิดเผยว่าอีเมลใดมีในระบบ
ฝึกต่อ: นำระบบนี้ไปรวมกับเว็บจัดการสินค้าจากบทความก่อนหน้า โดยเพิ่ม
"/products/:path*"ในmatcherของproxy.jsและตรวจ token ใน APIPOST,PUT,DELETEของสินค้า ให้เฉพาะสมาชิกที่ Login แล้วเพิ่ม แก้ไข หรือลบสินค้าได้ จากนั้นลองเพิ่มคอลัมน์roleในตารางusersแล้วอนุญาตให้ลบสินค้าได้เฉพาะผู้ใช้ที่มีroleเป็นadmin