ทำเว็บ breakfastz.dev ด้วย Astro — เร็ว แทบไม่มี JS (มุมมองคนที่มาจาก Next.js)
หน้า hub ของช่องเป็นเว็บเนื้อหา ไม่ใช่แอป เลยลอง Astro แทน React SPA — HTML ล้วน ใส่ JS เฉพาะเกาะที่ต้องโต้ตอบ นี่คือ 6 อย่างที่ได้เรียนรู้

ช่องเราทำของใหม่ทุกตอน — แอปทำ pixel art, เว็บทำ avatar ฯลฯ — เลยต้องมี “บ้าน” ที่รวมทุกอย่างไว้: คลิปล่าสุด โปรเจคที่ลองได้ ลิงก์ช่องทั้งหมด และตอนนี้ก็บทความนี้ด้วย
ปกติเราทำแอปด้วย React + Vite แต่หน้านี้ต่างออกไป: มันคือเว็บเนื้อหา คนเข้ามาอ่าน กดลิงก์ แล้วไป ส่วนใหญ่มาจากมือถือผ่าน TikTok / IG ต้องเปิดเร็ว และต้องค้นเจอใน Google — โจทย์แบบนี้ Astro ถนัดกว่า (เราบันทึกการตัดสินใจไว้เป็น ADR ด้วย: เว็บเนื้อหาใช้ Astro, แอปใช้ React + Vite)
1. คิดกลับด้าน: HTML ก่อน แล้วค่อยเปิด JS ทีละเกาะ
Next.js ส่ง React ทั้งหน้าไปให้เบราว์เซอร์ แล้ว “hydrate” ทั้งหน้า ส่วน Astro ทำกลับกัน — ทุกอย่างเป็น HTML นิ่งๆ ไว้ก่อน ถ้าส่วนไหนต้องโต้ตอบ ค่อยประกาศเป็น “เกาะ” (island) ทีละชิ้น
ทั้งหน้า hub มีเกาะเดียว: ไข่มาสคอตที่จิ้มได้
<EggMascot client:visible label={t("tapEgg")} />
client:visible แปลว่า “โหลด JS ของชิ้นนี้ตอนมันเลื่อนเข้าจอ” — ก่อนหน้านั้นไม่มี React โหลดเลย ผลคือ HTML หน้าแรกบีบอัดแล้วราว 11 KB และ JS ทั้งหมดของเว็บ (React + ไข่) ราว 70 KB ที่ไม่บล็อกการเปิดหน้า
ทำไมไข่ต้องเป็นเกาะ ทั้งที่ hover ทำด้วย CSS ได้? เพราะ มือถือไม่มี hover — ต้องใช้ JS จับการแตะ
ส่วนที่ตลกคือเรายังเขียนด้วย React ได้ แม้ส่วนที่ไม่ต้องโต้ตอบ: ไอคอน lucide-react ในเมนูเป็น React component แต่ไม่ใส่ client: → Astro เรนเดอร์เป็น <svg> ตอน build แล้วจบ ไม่มี JS ตามไปสักไบต์
บทเรียน: React ใน Astro เป็นได้ทั้ง “ภาษาเขียน component” และ “ส่วนที่โต้ตอบได้” — ต่างกันแค่ใส่
client:หรือไม่
2. Content Collections: ข้อมูลที่มี type และ build พังถ้าผิด
ตอนแต่ละตอน คลิปแต่ละคลิป (และบทความแต่ละบทความ) เป็นไฟล์ละ 1 ชิ้น มี schema กำกับด้วย Zod
const episodes = defineCollection({
loader: glob({ pattern: "*.yaml", base: "./src/content/episodes" }),
schema: ({ image }) =>
z.discriminatedUnion("status", [
z.object({ status: z.literal("live"), url: z.url(), cover: image() /* … */ }),
z.object({ status: z.literal("soon"), date: z.coerce.date() /* … */ }),
]),
});
ข้อดีที่รู้สึกได้จริง:
- เพิ่มตอนใหม่ = เพิ่มไฟล์ YAML ไฟล์เดียว
- ลืมใส่
urlให้ตอนที่status: live→ build fail พร้อมบอกว่าขาดอะไร (ดีกว่าการ์ดที่ลิงก์ไปไหนไม่รู้) image()เช็กว่าไฟล์รูปมีจริง แล้วส่งต่อให้ระบบย่อรูปได้เลย
แต่ไม่ใช่ทุกอย่างต้องเป็น collection: ซีรีส์ของช่องมี 5 อัน แทบไม่เปลี่ยน เป็นไฟล์ TypeScript ธรรมดาพอ — ของที่เพิ่มเรื่อยๆ และมีหลายช่องข้อมูล → collection · ของน้อยและนิ่ง → ไฟล์ธรรมดา
3. รูปภาพ: ย่อให้เองตอน build
<Image src={ep.cover} alt="" widths={[400, 720]} sizes="(max-width: 720px) 100vw, 360px" />
Astro ทำรูปหลายขนาด แปลงเป็น WebP และใส่ width / height ให้ (หน้าไม่กระตุกตอนรูปโหลด) — ปกตอน EP.001 จาก 151 KB เหลือ 13–59 KB ตามขนาดจอ
ข้อยกเว้นเดียว: og.png (รูปพรีวิวตอนแชร์ลิงก์) อยู่ใน public/ เพราะ Facebook / LINE / X ต้องการ URL ที่ไม่เปลี่ยน
4. สองภาษา = สองหน้า HTML ไม่ใช่ปุ่มสลับด้วย JS
แอปของเราสลับภาษาด้วย JS ในหน้าเดียว แต่ hub ทำเป็น คนละหน้า: / เป็นไทย /en/ เป็นอังกฤษ ทั้งสองหน้าเรียก component ตัวเดียวกันแค่ส่ง lang ต่างกัน แล้วใส่ hreflang บอก Google ว่าสองหน้านี้คือเนื้อหาเดียวกันต่างภาษา
ปุ่มวงกลมมุมขวาบนเป็นลิงก์ธรรมดาไปอีกภาษา ไม่ต้องใช้ JS
ดีกว่าทั้งเรื่อง SEO (ค้นภาษาไหนก็เจอหน้าภาษานั้น) และไม่ต้องโหลด JS เพื่อเปลี่ยนภาษา
5. Carousel ไม่ต้องใช้ library
เมนูซีรีส์เป็นแถวเลื่อนข้าง ใช้ CSS scroll-snap-type ปัดแล้วหยุดตรงการ์ดเอง ส่วนปุ่มก่อนหน้า/ถัดไปกับจุดบอกตำแหน่งเป็น <script> ธรรมดาในไฟล์ .astro (Astro bundle ให้) — ไม่ต้องเป็น React island
รายละเอียดเล็กที่ต้องคิด: จอกว้างเห็นหลายใบพร้อมกัน “ใบปัจจุบัน” จึงควรนับจากใบซ้ายสุด ไม่ใช่ใบกลาง ไม่งั้นจุดบอกตำแหน่งจะไม่ขยับตอนอยู่ต้นแถว
6. กับดักที่เจอ
| อาการ | สาเหตุ | แก้ |
|---|---|---|
astro check พังกับ TypeScript 7 |
ยังไม่รองรับ | ใช้ TypeScript 6 ไปก่อน |
Playwright บอกว่า server ตาย ทั้งที่ astro preview รันอยู่ |
เมื่อรันใน shell ที่ไม่ใช่ terminal ปกติ (CI, AI agent) มันแยกตัวไปรันเบื้องหลังเอง | static server ด้วย Bun.serve ~30 บรรทัด |
lint เตือนว่าเป็น React hook ในไฟล์ .astro |
ชื่อฟังก์ชันขึ้นต้นด้วย use |
ตั้งชื่อ translator() แทน useTranslations() |
| รูปพรีวิวตอนแชร์โดนครอปหัวท้าย | หลาย platform ครอปเป็นจัตุรัส | วางของสำคัญไว้กลาง 630×630 |
| สิ่งแรกที่เห็นในส่วนโปรเจคคือกล่องว่าง | การ์ด “เร็วๆ นี้” อยู่หน้าสุด | ของที่ยังไม่มีเนื้อหาไว้ท้าย |
สรุป: เมื่อไรควรเลือก Astro
- เลือก Astro เมื่อเป็นเว็บที่คน “อ่าน”: landing, blog, docs, portfolio, หน้ารวมลิงก์ — ต้องเปิดเร็ว ต้อง SEO ดี
- เลือก React + Vite (หรือ Next / TanStack Start) เมื่อเป็นแอปที่คน “ใช้”: editor, dashboard, state เยอะ — แบบ Avatoast
เส้นแบ่งง่ายๆ: ถ้าเกาะ (island) เริ่มใหญ่จนกลายเป็นทั้งหน้า แปลว่าคุณกำลังทำแอป ไม่ใช่เว็บเนื้อหาแล้ว
โค้ดทั้งหมดมีคำอธิบายทีละไฟล์ (ไฟล์ LEARN.md ใน repo ของ hub) สำหรับคนที่อยากลองแกะ