Back

ระบบ Login และหน้าสมาชิกด้วย Next.js และ MySQL Blur image

จาก สร้างเว็บ CRUD ด้วย Next.js และ MySQL ผ่าน REST API ที่ใช้ API จัดการข้อมูลสินค้า บทความนี้จะนำแนวทางเดิมมาสร้าง ระบบสมาชิก ผู้ใช้สมัครด้วยอีเมลและรหัสผ่าน จากนั้นเข้าสู่ระบบ (Login) เพื่อเปิดหน้า Dashboard หากยังไม่ได้ Login ระบบจะพาไปหน้า Login โดยอัตโนมัติ

หากเตรียมเครื่องมือไว้แล้ว สามารถข้ามขั้นตอนติดตั้งที่ทำไปแล้วได้ ส่วนผู้ที่ยังไม่ได้เตรียมเครื่อง หัวข้อ 1 และ 2 จะอธิบายตั้งแต่ติดตั้ง CAMPP, VS Code และ Node.js ไปจนถึงสร้างโปรเจกต์ Next.js

ระบบที่สร้างในบทความนี้ประกอบด้วย 4 ส่วน:

ส่วนหน้าที่API route
Registerสมัครสมาชิก บันทึกผู้ใช้ใหม่ลงตาราง usersPOST /api/register
Loginตรวจอีเมลและรหัสผ่าน แล้วออก token เก็บใน cookiePOST /api/login
Protected Areaหน้า Dashboard ที่เปิดได้เฉพาะผู้ที่ Login แล้วGET /api/me
Logoutลบ cookie เพื่อออกจากระบบPOST /api/logout

ลำดับการสร้างในบทความ:

  1. เตรียม CAMPP และสร้างตาราง users
  2. เตรียมเครื่องมือและสร้างโปรเจกต์ Next.js ใหม่
  3. เชื่อม MySQL และเตรียมฟังก์ชันจัดการ token
  4. Register — สมัครสมาชิก
  5. Login — เข้าสู่ระบบ
  6. Protected Area — หน้าสำหรับสมาชิก
  7. Logout — ออกจากระบบ

Login to a protected area architecture

พื้นฐานที่ควรมีก่อนเริ่ม: ควรคุ้นเคยกับ HTML, CSS, JavaScript, React, API route ใน Next.js และการใช้ mysql2 หากยังไม่คุ้นเคย แนะนำให้อ่านบทความก่อนหน้านี้ตามลำดับ:

  1. HTML, CSS และ JavaScript เบื้องต้น
  2. React และ Next.js เบื้องต้น
  3. สร้างเว็บ CRUD ด้วย Next.js และ MySQL ผ่าน REST API

Authentication เบื้องต้น (พื้นฐานการยืนยันตัวตน)#

ก่อนลงมือเขียนโค้ด มาทำความเข้าใจแนวคิดที่เชื่อมการสมัครสมาชิก การ Login และการป้องกันหน้าเว็บเข้าด้วยกัน

Authentication และ Authorization — Authentication คือการยืนยันตัวตน เช่น ตรวจอีเมลและรหัสผ่านตอน Login ส่วน Authorization คือการตรวจสิทธิ์ว่าผู้ใช้นั้นเข้าถึงข้อมูลหรือทำสิ่งใดได้บ้าง บทความนี้เริ่มจากกฎพื้นฐาน คือเปิดหน้า Dashboard ได้เมื่อ Login แล้วเท่านั้น

Authentication vs Authorization

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

Password hashing with bcrypt

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

JWT (JSON Web Token)

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

httpOnly cookie

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

Proxy (proxy.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 Install Config

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

CAMPP Dashboard

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

ปุ่ม phpMyAdmin บนแอป CAMPP

phpMyAdmin

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

รันคำสั่ง SQL สร้างตาราง users ในแท็บ SQL ของ phpMyAdmin

แต่ละคอลัมน์ในตาราง users มีหน้าที่ดังนี้:

คอลัมน์ชนิดข้อมูลคำอธิบาย
idINT AUTO_INCREMENTรหัสประจำผู้ใช้ (Primary Key) เพิ่มอัตโนมัติ
emailVARCHAR(255) UNIQUEอีเมลสำหรับ Login — UNIQUE ห้ามมีอีเมลซ้ำในตาราง
passwordVARCHAR(255)hash ของรหัสผ่าน ไม่ใช่รหัสผ่านจริง
fullnameVARCHAR(255)ชื่อที่แสดงในหน้า Dashboard
created_atTIMESTAMPวันเวลาที่สมัครสมาชิก ตั้งค่าอัตโนมัติ

เมื่อสร้างตารางแล้ว ให้เว้นข้อมูลไว้ก่อน เพราะคอลัมน์ password ต้องเก็บ hash ที่สร้างด้วย bcrypt ผู้ใช้คนแรกจะถูกเพิ่มผ่านหน้า Register ในหัวข้อ 4 เพื่อให้ข้อมูลผ่านขั้นตอนเดียวกับการสมัครสมาชิกจริง

The users table structure

คลิกชื่อฐานข้อมูล 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 --version
bash

2.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+`)

เปิด New Terminal จากเมนู Terminal ใน VS Code

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

เลือก Command Prompt จากรายการ profile ของ Terminal

3. รัน npm run dev — พิมพ์คำสั่งต่อไปนี้แล้วกด Enter เพื่อตรวจสอบว่าโปรเจกต์ทำงาน

npm run dev
bash

เมื่อเซิร์ฟเวอร์พร้อมใช้งาน Terminal จะแสดง Local: http://localhost:3000 เปิด URL นี้ในเบราว์เซอร์เพื่อตรวจสอบหน้าเริ่มต้นของแอป หรือกด Ctrl ค้างแล้วคลิกลิงก์ใน Terminal

ผลลัพธ์หลังรัน npm run dev แสดงลิงก์ localhost:3000 และหน้าแอปในเบราว์เซอร์ของ VS Code

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>
  );
}
javascript

3. เชื่อม MySQL และเตรียมฟังก์ชันจัดการ token#

ก่อนสร้างหน้า Register และ Login ต้องเตรียมส่วนที่ทั้งระบบใช้ร่วมกัน ได้แก่ การเชื่อมต่อ MySQL การตั้งค่าลับ และฟังก์ชันสำหรับสร้างและตรวจ token

3.1 ติดตั้ง mysql2, bcryptjs และ jose#

หยุด development server ชั่วคราว (กด Ctrl+C ใน Terminal) แล้วติดตั้งไลบรารี 3 ตัว:

npm install mysql2 bcryptjs jose
bash
ไลบรารีใช้ทำอะไร
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-string
bash
ค่าคำอธิบาย
DB_HOSTที่อยู่ของเซิร์ฟเวอร์ฐานข้อมูล — เครื่องเดียวกันคือ localhost
DB_PORTCAMPP ใช้ 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,
});
javascript

3.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

Register flow

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

หมายเหตุ: คอลัมน์ email ตั้งเป็น 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>
  );
}
javascript

response.ok เป็น true เมื่อ status code อยู่ในกลุ่ม 2xx จึงใช้แยกกรณีสำเร็จกับไม่สำเร็จได้ ส่วน type="email", minLength และ required ให้เบราว์เซอร์ตรวจข้อมูลเบื้องต้นก่อนส่ง

เริ่ม development server อีกครั้ง:

npm run dev
bash

จากนั้นเปิด http://localhost:3000/register ↗ กรอกข้อมูลแล้วกด Register จะถูกพาไปหน้า /login (ซึ่งยังไม่ได้สร้าง จึงแสดง 404 ไปก่อน)

หน้า /register

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

phpMyAdmin Browse ตาราง users แสดง password เป็น hash


5. Login — เข้าสู่ระบบ#

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

Login flow

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: trueJavaScript บนหน้าเว็บอ่าน cookie นี้ไม่ได้ (ป้องกันการขโมยผ่าน XSS)
sameSite: "lax"ไม่แนบ cookie ไปกับ request ส่วนใหญ่ที่มาจากเว็บอื่น ลดความเสี่ยง CSRF
secureส่ง cookie เฉพาะผ่าน HTTPS — เปิดเมื่อ deploy จริง ปิดตอนพัฒนาบน http://localhost
path: "/"ใช้ cookie ได้ทุกหน้าในเว็บ
maxAge: 60 * 60cookie หมดอายุใน 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 ไปก่อน)

หน้า /login แสดงข้อความเมื่อรหัสผ่านผิด


6. Protected Area — หน้าสำหรับสมาชิก#

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

Protecting /dashboard with proxy.js

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 });
}
javascript

request.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 ทันที

หน้า /dashboard หลัง 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

DevTools แท็บ Network แสดง Set-Cookie ใน response ของ /api/login

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

DevTools แท็บ Application แสดง cookie token แบบ HttpOnly

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

DevTools แท็บ Console แสดง document.cookie เป็นค่าว่าง

ตรวจการ 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

DevTools แท็บ Network แสดง dashboard 307 แล้วไปที่ login

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 ซึ่งมีเฉพาะเซิร์ฟเวอร์

ภายใน JWT


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 หายไป

หน้า Dashboard พร้อมปุ่ม Logout


สรุป#

เมื่อทำครบทุกขั้นตอน จะได้ระบบสมัครสมาชิก Login เปิดหน้าสมาชิก และ Logout ที่ทำงานร่วมกันตั้งแต่หน้า React, API route และ proxy.js ไปจนถึงตาราง users ใน MySQL:

ขั้นตอนHTTP methodRouteสิ่งที่เกิดขึ้น
RegisterPOST/api/registerbcrypt.hash แล้ว INSERT ลงตาราง users
LoginPOST/api/loginbcrypt.compare แล้วส่ง JWT ใน cookie แบบ httpOnly
อ่านข้อมูลผู้ใช้GET/api/meverifyToken แล้วส่งข้อมูลผู้ใช้กลับ หรือ 401
เปิดหน้าสมาชิก—/dashboardproxy.js ตรวจ token หากไม่ผ่าน redirect ไป /login
LogoutPOST/api/logoutลบ cookie token

From visitor to member

แนวคิดหลักที่ได้เรียนรู้#

  • 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 ใน API POST, PUT, DELETE ของสินค้า ให้เฉพาะสมาชิกที่ Login แล้วเพิ่ม แก้ไข หรือลบสินค้าได้ จากนั้นลองเพิ่มคอลัมน์ role ในตาราง users แล้วอนุญาตให้ลบสินค้าได้เฉพาะผู้ใช้ที่มี role เป็น admin

แหล่งข้อมูลเพิ่มเติม#

ระบบ Login และหน้าสมาชิกด้วย Next.js และ MySQL
Author กานต์ ยงศิริวิทย์ / Karn Yongsiriwit
Published at November 7, 2026

Loading comments...

Comments 0