ข้ามไปยังเนื้อหา

Server Islands

client island เป็นเรื่องของ interactivity ฝั่ง client ส่วน server islands แก้ปัญหาอีกแบบ: หน้าที่ส่วนใหญ่ cache ได้และ static แต่มีส่วนหนึ่งที่ personalize หรือช้า — avatar ของ user ที่ login อยู่, แถบ “แนะนำสำหรับคุณ”, ราคาสด ๆ ที่ต้อง query ใหม่

ถ้าไม่มี server islands คุณจะถูกบังคับให้เลือก: render ทั้งหน้าแบบ on demand (เสีย cache ของ 95% ที่ไม่เคยเปลี่ยน) หรือยอมทิ้งส่วนที่ personalize server islands ให้คุณได้ทั้งคู่ — cache เปลือก แล้ว defer ส่วนที่ dynamic

เพิ่ม directive server:defer ให้ .astro component ตัวไหนก็ได้ เพื่อเปลี่ยน component นั้นเป็น server island จากนั้น Astro จะ render ส่วนที่เหลือของหน้าทันที — โดยไม่รอ component ตัวนั้น — แล้ว HTML จริงของ component ที่ defer ไว้จะถูกดึงมาแยกต่างหากและสลับเข้ามาเมื่อพร้อม

---
import Avatar from '../components/Avatar.astro';
---
<Avatar server:defer />

HTML เริ่มต้นของหน้าออกทันทีและ cache ที่ edge ได้ ส่วน Avatar render บน server — จึงอ่าน cookie, session หรือ database ได้อย่างปลอดภัย — แต่ทำ นอกรอบ บน request ของตัวเอง หลังจากเปลือกอยู่บนจอแล้ว

เพราะ content จริงมาช้ากว่าเล็กน้อย คุณจึงใส่ fallback content ให้แสดงระหว่างรอ — skeleton, spinner หรือ placeholder กลาง ๆ — ด้วย slot="fallback":

---
import Avatar from '../components/Avatar.astro';
import GenericAvatar from '../components/GenericAvatar.astro';
---
<Avatar server:defer>
<GenericAvatar slot="fallback" />
</Avatar>

GenericAvatar render เข้าไปในเปลือกที่ cache ไว้ทันที ส่วน Avatar ที่ personalize จะเข้ามาแทนเมื่อ server render ของตัวเองส่งกลับมา

flowchart TB
  req["request the page"] --> shell["server renders shell instantly (cacheable)"]
  shell --> fb["fallback shown in place of the island"]
  fb --> fetch["browser fetches the deferred island"]
  fetch --> srv["island renders on the server (cookies, session, db)"]
  srv --> swap["real HTML swapped in over the fallback"]
หน้าออกพร้อม fallback แล้วดึง island ที่ defer มาสลับใส่

fallback content, การ fetch และการสลับไม่ใช่มายากลของ framework ที่ซ่อนอยู่ — build หน้าจริงที่มี Avatar แบบ server:defer (fallback เป็น GenericAvatar) แล้วอ่าน output จริง เห็นทั้งสามอย่างครบ เรียงตามลำดับ เป็น HTML ธรรมดา:

src/components/GenericAvatar.astro
<img src="/generic-avatar.png" alt="Generic avatar" width="48" height="48" />
src/components/Avatar.astro
---
const cookie = Astro.request.headers.get('cookie');
const userId = cookie?.match(/uid=(\\w+)/)?.[1] ?? null;
---
<div data-testid="avatar-real">
{userId ? <p>Welcome back, user {userId}</p> : <p>Anonymous (no uid cookie forwarded)</p>}
</div>
src/pages/server-island-demo.astro
---
import Avatar from '../components/Avatar.astro';
import GenericAvatar from '../components/GenericAvatar.astro';
---
<html><body>
<h1>Server island demo</h1>
<Avatar server:defer>
<GenericAvatar slot="fallback" />
</Avatar>
</body></html>
<!DOCTYPE html><html><link rel="preload" as="fetch" href="/_server-islands/Avatar?e=FDA01BAC8D95CB8E26A560DAB87l7Jmcc5evsrWx2DYJJRT%2BfX1KJFY%3D&amp;p=&amp;s=" crossorigin="anonymous"><body><h1>Server island demo</h1><script>async function replaceServerIsland(id, r) { let s = document.querySelector(`script[data-island-id="${id}"]`); if (!s || r.status !== 200 || r.headers.get('content-type')?.split(';')[0].trim() !== 'text/html') return; let html = await r.text(); while (s.previousSibling && s.previousSibling.nodeType !== 8 && s.previousSibling.data !== '[if astro]>server-island-start<![endif]') s.previousSibling.remove(); s.previousSibling?.remove(); s.before(document.createRange().createContextualFragment(html)); s.remove(); }</script><!--[if astro]>server-island-start<![endif]--><img src="/generic-avatar.png" alt="Generic avatar" width="48" height="48"><script type="module" data-astro-rerun data-island-id="f07047c9-f26d-45fe-a9ea-1ac642bcc610">const headers = new Headers({});
let response = await fetch("/_server-islands/Avatar?e=FDA01BAC8D95CB8E26A560DAB87l7Jmcc5evsrWx2DYJJRT%2BfX1KJFY%3D&p=&s=", { headers });replaceServerIsland("f07047c9-f26d-45fe-a9ea-1ac642bcc610", response);</script></body></html>

สี่ชิ้นจริง เรียงตามลำดับที่ปรากฏจริง:

  1. <link rel="preload" as="fetch"> ใน <head> — browser เริ่ม fetch endpoint ของ server island ก่อนจะไปถึง body ด้วยซ้ำ โดยไม่บล็อก render
  2. script runtime replaceServerIsland ที่ใช้ร่วมกัน — ประมาณ 15 บรรทัดเท่าเดิม ไม่ว่าหน้าจะมี server island กี่ตัว
  3. markup fallback ของ GenericAvatar อยู่ตรงตำแหน่งที่ Avatar จะมาแทนที่ — นี่คือสิ่งที่ paint ก่อน
  4. <script type="module" data-island-id="..."> ที่ใช้ URL เดิมกับที่ preload fetch ไว้ แล้วเรียก replaceServerIsland(id, response) เพื่อสลับ fallback เป็น HTML จริงเมื่อมาถึง

props (และ slot content) ที่ส่งให้ server island ไม่ได้ถูกส่งไป browser แบบ plain text — URL ข้างบน (?e=...&p=&s=...) ถูกเข้ารหัสจริง ไม่ใช่แค่ obfuscate เอกสารจริงของ Astro (astro/dist/core/encryption.js) ใช้ AES-GCM ด้วย key 256-bit ที่ generate ใหม่ทุก build เก็บไว้ฝั่ง server; ตามเอกสารทางการ (docs.astro.build/en/guides/server-islands/#reusing-the-encryption-key):

Astro uses cryptography to encrypt props passed to server islands, protecting sensitive data from accidental exposure. This encryption relies on a new, random key that is generated on each build and embedded in the server bundle.

key ใหม่ทุก build แปลว่า CDN ที่ cache หน้าข้ามการ deploy หรือ rolling deployment ที่ server instance เก่ากับใหม่อยู่ด้วยกันชั่วคราว อาจเจอปัญหา decrypt ไม่ตรงกัน ทางแก้คือ astro create-key ที่ generate key คงที่ให้ตั้งเป็น environment variable ASTRO_KEY เพื่อให้ทุก build (และทุก backend instance) เข้ารหัส/ถอดรหัสด้วย key เดียวกัน

Cache header บน response — พิสูจน์แล้ว และไม่ใช่อย่างที่อาจคิด

หัวข้อที่มีชื่อว่า “Cache header บน response — พิสูจน์แล้ว และไม่ใช่อย่างที่อาจคิด”

curl -D - จริงกับ endpoint _server-islands ที่ build ออกมา (header อย่างเดียว จาก build ที่รันจริง):

$ curl -s -D - "http://127.0.0.1:4601/_server-islands/Avatar?e=...&p=&s=" -o /dev/null
HTTP/1.1 200 OK
content-type: text/html
x-robots-tag: noindex
Date: Sat, 26 Sep 2026 02:35:38 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

ไม่มี Cache-Control header เลย โดย default — มีแค่ x-robots-tag: noindex (กัน search engine index endpoint fragment ดิบ ๆ) Astro ไม่ cache response ของ server island ให้เอง ถ้า fragment นั้น cache ได้ ต้องตั้ง Cache-Control เองข้างใน component (เช่น Astro.response.headers.set('Cache-Control', ...)) เหมือนกับ route on-demand อื่น ๆ

ข้อนี้ไม่ใช่คำแนะนำเบา ๆ — ถอด adapter ออกจากโปรเจกต์ output: 'static' ที่มี server:defer อยู่ที่ไหนสักที่ build จะพังทันที ตั้งแต่ก่อนจะเริ่ม prerender หน้าไหนด้วยซ้ำ:

[NoAdapterInstalledServerIslands] Cannot use server islands without an adapter. Please install
and configure the appropriate server adapter for your final deployment.
Hint:
See https://docs.astro.build/en/guides/on-demand-rendering/ for more information.
Error reference:
https://docs.astro.build/en/reference/errors/no-adapter-installed-server-islands/

(ทำซ้ำจริงกับ astro build ที่ output: 'static' ไม่มี adapter เลย มีหน้าเดียวที่ใช้ server:defer) เพิ่ม adapter: node({ mode: 'standalone' }) — หรือ adapter ไหนก็ได้ — กลับเข้าไป โปรเจกต์เดียวกันนี้ build ผ่านสะอาดทันที ไม่ต้องเปลี่ยนอะไรในหน้าเลย

fetch ของ server island รันแบบ same-origin จาก browser และ fetch() แบบ same-origin ส่ง cookie ให้โดย default (ไม่ต้องใส่ option credentials) — ดังนั้น Astro.request.headers.get('cookie') ข้างใน Avatar.astro เห็น cookie ของผู้เยี่ยมชมจริง พิสูจน์ด้วย browser session จริง: ตั้ง document.cookie = 'uid=99' แล้ว navigate ใหม่ไปหน้านั้น พอ fetch ที่ defer resolve:

document.querySelector('[data-testid=avatar-real]').outerHTML
// "<div data-testid=\"avatar-real\"><p>Welcome back, user 99</p></div>"

— เทียบกับตอนไม่มี cookie เลย ที่ได้ผลลัพธ์แบบ anonymous ทั่วไป ("Anonymous (no uid cookie forwarded)" พิสูจน์ก่อนหน้าด้วย curl ตรง ๆ ที่ไม่มี header Cookie) ไม่ต้องทำอะไรพิเศษเพื่อส่ง cookie เลย มันเป็นแค่ same-origin request ธรรมดาในมุมมองของ browser

server islands เป็นเครื่องมือที่ใช่เมื่อหน้าหนึ่ง cache ได้ ยกเว้นส่วน dynamic เล็ก ๆ:

  • หน้า marketing หรือหน้าสินค้าที่เหมือนกันสำหรับทุกคน ยกเว้น คำทักทายที่ personalize หรือ cart
  • หน้าที่ส่วนใหญ่ static แต่มี query ที่ ช้า อยู่หนึ่งอันที่คุณไม่อยากให้บล็อก first paint
  • ส่วน auth-gated บนหน้าที่เป็น public และ CDN-cache

จำไว้สองอย่าง อย่างแรก server:defer render บน server จึงต้องติดตั้ง adapter (Cloudflare, Node, Vercel…) — เงื่อนไขเดียวกับ on-demand rendering ทุกแบบ ถ้าไม่มีจะเจอ NoAdapterInstalledServerIslands build error จริงที่พิสูจน์ข้างบน แม้บนโปรเจกต์ที่ไม่มี adapter และ static ทั้งหมด อย่างที่สอง ไม่ใช่สำหรับ client interactivity — ถ้าส่วนนั้นต้องตอบสนอง click นั่นคือ client island (client directive) อาจต้องใช้เพิ่มเติม server island เป็นเรื่องของการ defer output ที่ render บน server ไม่ใช่การ hydrate JavaScript

ตอนเจอ snippet ที่ generate มาใช้ server:defer เช็คสิ่งเหล่านี้ก่อนเชื่อ:

  • server:defer ถูกใช้บนหน้า/โปรเจกต์ที่ไม่มี adapter ตั้งไว้ไหม? เป็น build error จริงที่เกิดทันที (NoAdapterInstalledServerIslands พิสูจน์ข้างบน) ไม่ใช่แค่ warning และไม่ใช่แค่โผล่ตอน runtime
  • component สมมติว่า props ถูกส่งแบบ plain text บนสายเพราะ “มันก็แค่ query string” ไหม? พิสูจน์ข้างบน: param e/p/s เข้ารหัสด้วย AES-GCM จริง ไม่ใช่แค่มีอยู่เฉย ๆ — แต่ก็ไม่ได้ฟรีจากต้นทุนด้วย (key ใหม่ทุก build เว้นแต่ตั้ง ASTRO_KEY)
  • comment หรือข้อสมมติไหนอ้างว่า response ของ server island “cache อัตโนมัติ” หรือ “cache ที่ edge เหมือนเปลือก” ไหม? พิสูจน์ข้างบนด้วย curl -D - จริง: ไม่มี Cache-Control header โดย default เลย — cache ได้แค่เปลือกรอบ ๆ เว้นแต่ component จะตั้ง header เอง
  • server:defer ถูกเอามาใช้กับ component ที่ต้องตอบสนอง click ไหม? นั่นคือ client interactivity — เป็น client directive ไม่ใช่ server island ทั้งสองแก้ปัญหาคนละแบบ ใช้ร่วมกันได้แต่แทนกันไม่ได้
  • fallback (slot="fallback") render อะไรที่แอบสมมติ cookie/สถานะ auth ไหม (ซึ่งขัดกับจุดประสงค์ของเปลือกที่เร็วและ cache ได้)? fallback คือสิ่งที่ทุกคนเห็นก่อน รวมถึงตอน cache ด้วย มันควรจะกลางๆ โดยดีไซน์ ส่วนที่ personalize มาถึงแค่ตอน fetch ที่ defer เท่านั้น

🐛 หา bug ในโค้ดนี้

---
// src/pages/dashboard.astro — a project with output: 'static' and no adapter configured
import Avatar from '../components/Avatar.astro';
---
<Avatar server:defer />
เฉลย

นี่คือการใช้ server:defer ที่ syntax ถูกต้องสมบูรณ์ — bug ไม่ได้อยู่ในไฟล์นี้เลย มันอยู่ใน astro.config.mjs ของโปรเจกต์: output: 'static' โดยไม่มี adapter เลย server:defer render บน server เสมอ บน request ของตัวเอง ซึ่งต้องการความสามารถ render แบบ on-demand — build จึงพังทันทีและชัดเจน ทำซ้ำจริงข้างบนแล้ว: [NoAdapterInstalledServerIslands] Cannot use server islands without an adapter. ทางแก้ไม่ได้อยู่ใน component นี้ แต่คือเพิ่ม adapter อะไรก็ได้ (adapter: node({ mode: 'standalone' }), Cloudflare, Vercel…) เข้าไปใน config แม้ว่าหน้าอื่นทุกหน้าบนไซต์อาจ static เต็มตัวและไม่ต้องการ adapter เลยก็ตาม

server island คือภาพสะท้อนกลับของ client island: client island ส่ง JavaScript เพื่อ defer ตอนที่ component กลายเป็น interactive; server island defer ตอนที่ server render ของ component เกิดขึ้น — หลังจากเปลือกส่งออกไปแล้ว บน request ของตัวเอง เพื่อไม่ให้ส่วนที่ช้าหรือ personalize บล็อกส่วนที่เร็วและ cache ได้ ทั้งคู่คือ “defer ชิ้นนี้ไว้ ส่งที่เหลือทั้งหมดตอนนี้” — อันหนึ่งอยู่บน timeline ของ client อีกอันอยู่บน timeline ของ server

หน้า prerendered หน้า on-demand server island (server:defer)
cache ได้ไหม ได้เต็มที่ ไม่ได้โดย default เปลือกได้ ส่วน island ได้เฉพาะถ้าตั้ง Cache-Control เอง (พิสูจน์ข้างบน)
เห็น cookie/session ไหม ไม่ (build ครั้งเดียว ก่อนมี request ไหนเลย) ได้ ได้ — พิสูจน์ข้างบนแล้วว่า cookie จริงไปถึง Astro.request.headers
ต้องมี adapter ไหม ไม่ต้อง ต้อง ต้อง — พิสูจน์ข้างบนแล้ว แม้ไซต์ที่เหลือจะ static ทั้งหมด
บล็อก first paint ตรงส่วนที่ช้าไหม N/A (ไม่มีอะไร dynamic) ใช่ ทั้งหน้ารอ ไม่ — เปลือกออกก่อน island มาทีหลัง สลับเข้าไปทับ fallback
  • “response ของ server island เอง cache พร้อมเปลือกด้วย” พิสูจน์ข้างบนด้วย curl -D - จริง: ไม่มี Cache-Control header เลยโดย default การ cache fragment เป็นสิ่งที่ต้อง opt-in เอง ข้างในตัว component
  • “props ที่ส่งให้ server island มองเห็นได้ใน page source เหมือน query string ธรรมดา” พิสูจน์ข้างบนและยืนยันในซอร์สของ Astro เอง: มันเข้ารหัสด้วย AES-GCM ไม่ใช่ plain text — แม้ key จะใหม่ทุก build เว้นแต่ตั้ง ASTRO_KEY ซึ่งสำคัญสำหรับ CDN และ rolling deploy
  • “ไซต์ที่ static ทั้งหมดไม่ต้องการ adapter เลย” จริงจนกว่าจะมี server:defer ตัวแรก — พิสูจน์ข้างบนแล้วว่า build พังทันที (NoAdapterInstalledServerIslands) ทันทีที่มีสักตัวปรากฏในโปรเจกต์ ไม่ว่าที่เหลือจะ static แค่ไหน
  • “server island เห็น cookie ของผู้เยี่ยมชมไม่ได้เพราะ fetch เป็น request แยกนอกรอบ” พิสูจน์ข้างบนด้วย browser session จริง: fetch เป็น same-origin ทำให้ browser ส่ง cookie ให้โดย default — Astro.request.headers.get('cookie') เห็นเหมือนกับที่หน้าโหลดปกติเห็นทุกอย่าง
server:defer ทำอะไร?
use case หลักของ server island คืออะไร?
จะแสดงอะไรระหว่างที่ component server:defer กำลังโหลด?
server island ต้องมีอะไรถึงจะรันได้?
นี่คือ response header จริงจาก curl -D - กับ endpoint /_server-islands/ ที่ build แล้ว มีอะไรหายไปที่คุณอาจคิดว่าน่าจะมี?
HTTP/1.1 200 OK
content-type: text/html
x-robots-tag: noindex
server island อ่าน Astro.request.headers.get('cookie') เพื่อ personalize output ในเมื่อ fetch ไป /_server-islands/<name> ถูกเรียกโดย script ที่ browser รันหลังเปลือกโหลดเสร็จ cookie ของผู้เยี่ยมชมไปถึงจริงไหม?