breakfastz
← บทความทั้งหมด

· อ่าน 6 นาที

ทำเว็บ breakfastz.dev ด้วย Astro — เร็ว แทบไม่มี JS (มุมมองคนที่มาจาก Next.js)

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

หน้าแรกของ breakfastz.dev ข้างคำว่า Astro

ช่องเราทำของใหม่ทุกตอน — แอปทำ 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 ว่าสองหน้านี้คือเนื้อหาเดียวกันต่างภาษา

หน้า hub บนมือถือ ปุ่มวงกลมมุมขวาบนเป็นลิงก์ธรรมดาไปอีกภาษา ไม่ต้องใช้ JS

ดีกว่าทั้งเรื่อง SEO (ค้นภาษาไหนก็เจอหน้าภาษานั้น) และไม่ต้องโหลด JS เพื่อเปลี่ยนภาษา

เมนูซีรีส์เป็นแถวเลื่อนข้าง ใช้ 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) สำหรับคนที่อยากลองแกะ