Read from Supabase
Day 4 - ชั่วโมงที่ 2: อ่านข้อมูลจาก Supabase
เป้าหมายของชั่วโมงนี้
หลังจบชั่วโมงนี้ ผู้เรียนจะสามารถ:
- เชื่อม Next.js กับ Supabase ผ่าน Server Component
- อ่าน Issue ทั้งหมดและเรียงรายการจากใหม่ไปเก่า
- แปลงชื่อ Field จาก Database ให้ตรงกับ Type ที่ UI ใช้
- แสดงข้อมูลจริงในหน้า
/issues - อ่าน Issue รายการเดียวจาก
idใน URL
ไฟล์ที่ใช้ในชั่วโมงนี้
lib/supabase/server.ts สร้าง Client สำหรับติดต่อ Supabase
lib/issues.ts รวม Function สำหรับอ่าน Issue
app/page.tsx ปรับหน้าแรกให้เชื่อมไปหน้ารายการ
app/issues/page.tsx แสดง Issue ทั้งหมด
app/issues/[id]/page.tsx แสดง Issue รายการเดียวSlide 1: Recap จากชั่วโมงที่ 1
ชั่วโมงที่แล้วเราเตรียมฝั่ง Database ไว้แล้ว:
- สร้าง Supabase Project และ Table
issues - กำหนด Column, Constraint, RLS และ Policy
- เพิ่มข้อมูลทดลองลงใน Table
- เก็บ Project URL และ Publishable Key ไว้ใน
.env.local - ปรับ Type
Issueให้ตรงกับข้อมูลใน Database
ชั่วโมงนี้เราจะนำข้อมูลเหล่านั้นมาแสดงใน Next.js แทน Mock Data
Supabase
→ อ่านข้อมูลใน lib/issues.ts
→ ส่งข้อมูลให้ page.tsx
→ แสดงผลด้วย IssueListSlide 2: ติดตั้ง Supabase JavaScript Client
เปิด Terminal ที่ Project Root แล้วรัน:
npm install @supabase/supabase-jsPackage นี้ทำให้ JavaScript ติดต่อ Supabase Data API ได้ เช่น:
.select()อ่านข้อมูล.insert()เพิ่มข้อมูล.update()แก้ไขข้อมูล.delete()ลบข้อมูล
ชั่วโมงนี้จะเริ่มจาก .select() เพื่ออ่านข้อมูลก่อน ส่วนการเพิ่มและแก้ไขข้อมูลจะทำในชั่วโมงถัดไป
Slide 3: สร้าง Supabase Server Client
สร้าง Folder และไฟล์ใหม่ที่ Project Root:
project-root/
├─ app/
├─ components/
├─ lib/
│ └─ supabase/
│ └─ server.ts
└─ package.jsonใช้ Code นี้เป็นเนื้อหาทั้งไฟล์ lib/supabase/server.ts:
import { createClient } from "@supabase/supabase-js";
export function createSupabaseServerClient() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
if (!supabaseUrl || !supabaseKey) {
throw new Error("Supabase environment variables are missing");
}
return createClient(supabaseUrl, supabaseKey, {
auth: {
persistSession: false,
},
});
}Function นี้ทำหน้าที่สร้าง Client กลาง เพื่อให้ไฟล์อื่นนำไปใช้ติดต่อ Supabase โดยไม่ต้องเขียน URL และ Key ซ้ำ
process.env...อ่านค่าจาก.env.localifหยุดการทำงานทันทีเมื่อ Environment Variables ไม่ครบcreateClient(...)สร้างตัวเชื่อมต่อ SupabasepersistSession: falseไม่บันทึก Login Session เพราะ Client นี้ยังใช้บน Server เพื่ออ่านข้อมูลสาธารณะ
เราใช้ Publishable Key ซึ่งทำงานร่วมกับสิทธิ์ GRANT, RLS และ Policy ที่ตั้งไว้ในชั่วโมงก่อน ห้ามนำ Secret Key หรือ Service Role Key มาใส่ในตัวแปรที่ขึ้นต้นด้วย NEXT_PUBLIC_
Slide 4: แยกรูปแบบข้อมูลของ Database และ UI
Database ใช้ชื่อ Column แบบ snake_case แต่ TypeScript ใน UI ใช้ชื่อ Property แบบ camelCase:
| Database | UI |
|---|---|
reporter_name | reporterName |
admin_comment | adminComment |
created_at | createdAt |
updated_at | updatedAt |
สร้างไฟล์ใหม่ lib/issues.ts แล้วเริ่มด้วย Code นี้:
import type { Issue, IssueStatus } from "@/types/issue";
import { createSupabaseServerClient } from "./supabase/server";
type DbIssue = {
id: string;
reporter_name: string;
reporter_email: string;
title: string;
description: string;
status: IssueStatus;
admin_comment: string | null;
created_at: string;
updated_at: string;
};DbIssue อธิบายข้อมูล 1 Row ที่ Supabase ส่งกลับมา ชื่อ Property จึงต้องตรงกับชื่อ Column ใน Table
Type นี้มีไว้ช่วยตรวจ Code เท่านั้น ไม่ได้สร้างหรือแก้ Table ใน Supabase
Slide 5: แปลง DbIssue ให้เป็น Issue
ทำไมต้องแปลงข้อมูล
ข้อมูลจาก Supabase ยังใช้ชื่อ Field ตาม Database เช่น reporter_name แต่ Component ที่เราสร้างไว้ใช้ Type Issue และเรียก Field ว่า reporterName
ถ้าส่ง DbIssue เข้า Component โดยตรง:
- ชื่อ Field จะไม่ตรงกับ Code เดิม
- TypeScript จะแจ้งว่า Type ไม่ตรงกัน
- Component ต้องรู้รายละเอียดของ Database และต้องแก้หลายจุดเมื่อโครงสร้าง Database เปลี่ยน
เราจึงใช้ mapIssue() เป็นตัวกลาง แปลงข้อมูลจากรูปแบบของ Database ให้เป็นรูปแบบเดียวกับที่ UI ใช้อยู่
เพิ่ม Function นี้ต่อจาก DbIssue ในไฟล์ lib/issues.ts:
function mapIssue(row: DbIssue): Issue {
return {
id: row.id,
reporterName: row.reporter_name,
reporterEmail: row.reporter_email,
title: row.title,
description: row.description,
status: row.status,
adminComment: row.admin_comment ?? undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}mapIssue() รับข้อมูลจาก Database 1 Row แล้วคืน Object รูปแบบ Issue ที่ Component เดิมของเราเข้าใจ
DbIssue จาก Supabase
↓ mapIssue()
Issue สำหรับ UIrow.admin_comment ?? undefined หมายถึง ถ้า Database ส่ง null มา ให้เปลี่ยนเป็น undefined ตาม Type Issue ที่กำหนดไว้
การแปลงข้อมูลไว้ใน lib/issues.ts ทำให้ Component ไม่ต้องรู้ว่า Database ตั้งชื่อ Column อย่างไร
Slide 6: สร้าง getIssues() สำหรับอ่านทุก Issue
เพิ่ม Function นี้ต่อจาก mapIssue() ในไฟล์ lib/issues.ts:
export async function getIssues(): Promise<Issue[]> {
const supabase = createSupabaseServerClient();
const { data, error } = await supabase
.from("issues")
.select("*")
.order("created_at", { ascending: false });
if (error) {
throw new Error(`Failed to fetch issues: ${error.message}`);
}
return (data ?? []).map((row) => mapIssue(row as DbIssue));
}ลำดับการทำงาน:
- สร้าง Supabase Client
- เลือกข้อมูลทุก Column จาก Table
issues - เรียง
created_atจากใหม่ไปเก่าด้วยascending: false - ถ้า Query ไม่สำเร็จ ให้หยุดและแจ้ง Error
- ถ้าไม่มีข้อมูล ให้ใช้ Array ว่าง
- แปลงทุก Row ด้วย
mapIssue()แล้วคืนค่าเป็นIssue[]
อ่าน Syntax ของบรรทัด return
return (data ?? []).map((row) => mapIssue(row as DbIssue));data ?? []ถ้าdataมีค่าให้ใช้ข้อมูลนั้น แต่ถ้าเป็นnullหรือundefinedให้ใช้ Array ว่างแทน.map(...)วนอ่านทุก Row ใน Array แล้วสร้าง Array ใหม่(row) => ...คือ Arrow Function ที่รับข้อมูลครั้งละ 1 Rowrow as DbIssueบอก TypeScript ให้ตรวจrowในรูปแบบDbIssueคำสั่งนี้ไม่ได้เปลี่ยนข้อมูลจริงmapIssue(...)แปลง Row จากชื่อ Field แบบ Database ให้เป็นIssueสำหรับ UIreturnส่ง Array ใหม่ชนิดIssue[]กลับไปให้ Code ที่เรียกgetIssues()
เขียนแยกเป็นหลายบรรทัดเพื่อดูขั้นตอนได้ดังนี้:
const rows = data ?? [];
const issues = rows.map((row) => mapIssue(row as DbIssue));
return issues;Promise<Issue[]> บอกว่า Function ต้องรอการอ่านข้อมูล และเมื่อเสร็จแล้วจะได้ Array ของ Issue
Slide 7: ให้หน้า /issues ใช้ข้อมูลจาก Supabase
ใช้ Code นี้แทนเนื้อหาทั้งไฟล์ app/issues/page.tsx:
import { IssueList } from "@/components/IssueList";
import { getIssues } from "@/lib/issues";
export default async function IssuesPage() {
const issues = await getIssues();
return (
<main className="mx-auto max-w-5xl px-6 py-8">
<h1 className="mb-4 text-2xl font-bold text-slate-950">
รายการปัญหา
</h1>
<IssueList issues={issues} />
</main>
);
}หน้านี้เป็น Server Component จึงประกาศเป็น async และใช้ await getIssues() ก่อนส่งข้อมูลให้ IssueList
ไม่ต้องแก้ IssueList เพิ่ม เพราะ Day 3 Hour 4 ทำให้ onUpdateStatus เป็น Optional Prop ไว้แล้ว การส่งเฉพาะ issues จึงแสดงรายการแบบอ่านอย่างเดียวได้
อย่าใส่ "use client" ใน app/issues/page.tsx เพราะหน้านี้มีหน้าที่อ่านข้อมูลบน Server
Slide 8: ปรับหน้าแรกให้เลิกใช้ Mock Data
หน้าแรกเดิมยังแสดง IssueBoard และ Mock Data จาก data/issue.ts การแก้ครั้งนี้แบ่งเป็น 3 ส่วน:
- ลบ: Import
IssueBoard, Importissuesและ<IssueBoard initialIssues={issues} /> - แก้: ข้อความใน Header และ Layout ของ
<main> - เพิ่ม: Import
Linkพร้อมหัวข้อ คำอธิบาย และ Link ไปหน้า/issues
ใช้ Code นี้แทนเนื้อหาทั้งไฟล์ app/page.tsx บรรทัดที่ Highlight คือส่วนที่เพิ่มหรือแก้จาก Code เดิม:
import Link from "next/link"; export default function Home() { return ( <> <header className="bg-teal-800 px-6 py-8 text-white"> <div className="mx-auto max-w-5xl"> <h1 className="text-3xl font-bold">ระบบแจ้งปัญหา IT</h1> <p className="mt-2 text-teal-100"> แจ้งและติดตามปัญหาการใช้งานระบบภายใน </p> </div> </header> <main className="mx-auto max-w-5xl px-6 py-12"> <h2 className="text-2xl font-semibold">จัดการปัญหาการใช้งานระบบ</h2> <p className="mt-2 text-slate-600"> ดูรายการปัญหาที่แจ้งเข้ามาและติดตามสถานะการดำเนินงาน </p> <Link href="/issues" className="mt-6 inline-block rounded-md bg-teal-700 px-4 py-2 font-semibold text-white hover:bg-teal-800" > ดูรายการปัญหา </Link> </main> <footer className="px-6 py-8 text-center text-sm text-slate-500"> <p>ฝ่ายเทคโนโลยีสารสนเทศ</p> </footer> </> );}
ส่วน Header เดิมและ Footer เดิมยังคงใช้ต่อ เพียงแก้ข้อความใน Header ให้ตรงกับหน้าที่ของระบบ
ตอนนี้แต่ละหน้ามีหน้าที่ชัดเจน:
/ หน้าแนะนำระบบและ Link ไปหน้ารายการ
/issues รายการข้อมูลจริงจาก SupabaseSlide 9: ทดสอบหน้ารายการ
เปิดหน้า:
http://localhost:3000/issuesตรวจสอบว่า:
- เห็นข้อมูลที่ Seed ไว้ใน Supabase
- รายการใหม่กว่าอยู่ด้านบน
- แต่ละ Row มี Link
ดูรายละเอียด - Refresh หน้าแล้วข้อมูลยังอยู่ เพราะอ่านจาก Database ไม่ใช่ State
ถ้าหน้าว่างหรือเกิด Error ให้ตรวจตามลำดับ:
.env.localใช้ชื่อ Environment Variables ตรงกับ Slide 3- Restart Dev Server หลังแก้
.env.local - Table ชื่อ
issuesไม่ใช่issue - รัน
GRANT SELECTและสร้าง RLS Policy จาก Hour 1 ครบแล้ว
Slide 10: สร้าง getIssueById()
เมื่อกด ดูรายละเอียด เราต้องอ่าน Issue เพียงรายการเดียวตาม id ใน URL
เพิ่ม Function นี้ต่อจาก getIssues() ในไฟล์ lib/issues.ts:
export async function getIssueById(id: string): Promise<Issue | null> {
const supabase = createSupabaseServerClient();
const { data, error } = await supabase
.from("issues")
.select("*")
.eq("id", id)
.maybeSingle();
if (error?.code === "22P02") {
return null;
}
if (error) {
throw new Error(`Failed to fetch issue: ${error.message}`);
}
return data ? mapIssue(data as DbIssue) : null;
}.eq("id", id)กรอง Row ที่ Columnidตรงกับค่าจาก URL.maybeSingle()บอกว่า Query นี้ควรได้ 0 หรือ 1 Row- Error Code
22P02หมายถึงค่าที่ส่งมาใช้เป็น UUID ไม่ได้ กรณีนี้ให้คืนnullเพื่อไปหน้า 404 - Error ชนิดอื่นยังต้อง
throwตามเดิม เพื่อไม่ซ่อนปัญหาเรื่องสิทธิ์หรือการเชื่อมต่อ Database - ถ้าพบข้อมูล จะส่งผ่าน
mapIssue() - ถ้าไม่พบข้อมูล จะคืน
nullให้หน้า Detail จัดการต่อ
ทำไมต้องตรวจ 22P02 แยกออกมา
Column id ของ Table เป็นชนิด UUID ถ้าผู้ใช้พิมพ์ URL เช่น /issues/not-found Supabase จะอ่าน not-found เป็น UUID ไม่ได้และส่ง Error Code 22P02 กลับมา
error?.codeอ่านcodeเฉพาะเมื่อมี Error เครื่องหมาย?.ช่วยป้องกันการอ่าน Property จากค่าnull- เราตรวจ
22P02ก่อนif (error)เพื่อเปลี่ยนกรณีรหัสผิดรูปแบบให้เป็นnull - หน้า Detail จะเห็นว่าไม่มี Issue แล้วเรียก
notFound() - Error อื่น เช่น ไม่มีสิทธิ์อ่าน Table ยังถูก
throwเพื่อให้เราเห็นและแก้ปัญหาได้
id ผิดรูปแบบ → 22P02 → return null → notFound() → หน้า 404Slide 11: ให้ Dynamic Route อ่านข้อมูลจริง
Folder [id] เป็น Dynamic Route เช่น /issues/abc123 จะส่ง abc123 มาเป็น params.id
หน้า Detail จาก Day 2 มี Dynamic Route, params และ notFound() อยู่แล้ว การปรับครั้งนี้มีทั้งส่วนเชื่อมข้อมูลและส่วนจัดหน้า:
- ลบ:
import { issues } from "@/data/issue"; - เพิ่ม: Import
getIssueByIdจากlib/issues.ts - แก้: จากค้นหาใน Mock Array ด้วย
.find()เป็นรอข้อมูลจาก Supabase ด้วยawait getIssueById(id) - เพิ่ม Tailwind: จัดความกว้าง ระยะห่าง สีข้อความ Border และพื้นหลังให้หน้า Detail สอดคล้องกับหน้าอื่น
- ปรับโครงสร้าง: ใช้
<dl>,<dt>และ<dd>สำหรับข้อมูลที่เป็นหัวข้อกับค่า เช่น ผู้แจ้งและอีเมล
ใช้ Code นี้แทนเนื้อหาทั้งไฟล์ app/issues/[id]/page.tsx บรรทัดที่ Highlight คือส่วนที่เพิ่มหรือแก้:
import { notFound } from "next/navigation";import { getIssueById } from "@/lib/issues"; type IssueDetailPageProps = { params: Promise<{ id: string; }>;}; export default async function IssueDetailPage({ params,}: IssueDetailPageProps) { const { id } = await params; const issue = await getIssueById(id); if (!issue) { notFound(); } return ( <main className="mx-auto max-w-3xl px-6 py-8"> <h1 className="text-2xl font-bold text-slate-950">{issue.title}</h1> <p className="mt-2 text-slate-600">{issue.description}</p> <dl className="mt-6 grid gap-4 rounded-lg border border-slate-200 bg-white p-6"> <div> <dt className="text-sm font-semibold text-slate-500">ผู้แจ้ง</dt> <dd className="mt-1 text-slate-950">{issue.reporterName}</dd> </div> <div> <dt className="text-sm font-semibold text-slate-500">อีเมล</dt> <dd className="mt-1 text-slate-950">{issue.reporterEmail}</dd> </div> <div> <dt className="text-sm font-semibold text-slate-500">สถานะ</dt> <dd className="mt-1 text-slate-950">{issue.status}</dd> </div> </dl> </main> );}
ใน Next.js เวอร์ชันนี้ params เป็น Promise จึงต้องใช้ await params ก่อนอ่าน id
ถ้า getIssueById() คืน null คำสั่ง notFound() จะหยุดการแสดงหน้านี้และเปิดหน้า 404 แทน
Tailwind มีหน้าที่จัดหน้าตาเท่านั้น ไม่ได้เปลี่ยนวิธีอ่านข้อมูล ส่วน mapIssue() ทำให้ JSX ยังเรียก Property เดิม เช่น issue.reporterName และ issue.status ได้
Slide 12: ทดสอบหน้า Detail
กด ดูรายละเอียด จากหน้า /issues แล้วตรวจสอบว่า:
- URL เปลี่ยนเป็น
/issues/[id] - หัวข้อและรายละเอียดตรงกับ Row ที่เลือก
- แสดงชื่อผู้แจ้ง อีเมล และสถานะ
จากนั้นทดลองใส่ค่าที่ไม่มีอยู่จริงใน URL เช่น:
/issues/not-foundSupabase จะส่ง Error Code 22P02 เพราะ not-found ไม่ใช่ UUID จากนั้น getIssueById() จะคืน null และหน้า Detail จะเรียก notFound() เพื่อแสดงหน้า 404 เริ่มต้นของ Next.js
Slide 13: สรุป Read Flow
หน้ารายการ
/issues
→ getIssues()
→ SELECT ทุก Row และเรียงจากใหม่ไปเก่า
→ mapIssue() ทุก Row
→ IssueListหน้ารายละเอียด
/issues/[id]
→ อ่าน id จาก params
→ getIssueById(id)
→ พบข้อมูล: แสดง Detail
→ ไม่พบข้อมูล: แสดง 404ตอนนี้ Supabase เป็น Source of Truth สำหรับการแสดงรายการและรายละเอียดแล้ว ชั่วโมงถัดไปเราจะทำให้ Form บันทึก Issue ใหม่ลง Supabase ผ่าน Server Action
คำศัพท์สำคัญ
| คำศัพท์ | ความหมาย |
|---|---|
| Supabase Client | ตัวเชื่อมระหว่าง Application กับ Supabase |
| Query | คำสั่งขอข้อมูลจาก Database |
| Mapping | การแปลงข้อมูลจากรูปแบบหนึ่งเป็นอีกรูปแบบ |
| Server Component | Component ที่ทำงานและอ่านข้อมูลบน Server ได้ |
| Dynamic Route | Route ที่บางส่วนของ URL เปลี่ยนค่าได้ เช่น [id] |
maybeSingle() | รับผลลัพธ์เป็น 0 หรือ 1 Row |