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

SSR & Adapters

On-demand rendering รัน component script ของคุณต่อ request จึงอ่าน request-time data ได้ จะรัน server code ต้องติดตั้ง adapter สำหรับ platform เป้าหมายแล้วเพิ่มเข้า config:

astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
adapter: node({ mode: 'standalone' }), // or cloudflare(), vercel(), netlify()
});

เมื่อมี adapter route จะ render on demand ตรงที่ต้องการ (หรือเมื่อคุณ set export const prerender = false บน route) ถ้าไม่มี ฟีเจอร์ on-demand ใด ๆ จะ build ไม่ผ่านด้วย “an adapter is required”

sequenceDiagram
  participant B as Browser
  participant S as Astro server (adapter)
  participant C as Component script
  B->>S: GET /dashboard
  S->>C: run script with request context
  C->>C: read cookies, user, query params
  C-->>S: HTML + response settings
  S-->>B: Response (status, headers, body)
Request ไหลผ่าน render จนถึง response

มีสอง setting ที่ตัดสินต่อ route ว่าจะ build ครั้งเดียวหรือ render ต่อ request: default ระดับโปรเจกต์ output และ export prerender ของแต่ละหน้าเอง ซึ่งชนะ default เสมอ:

  • output: 'static' (default) — ทุก route ถูก prerender เป็น static HTML ตอน build เว้นแต่หน้านั้น opt เข้า on-demand ด้วย export const prerender = false (และมี adapter ติดตั้งไว้)
  • output: 'server' — ทุก route render on demand เป็น default; หน้าจะ opt ออก ไปเป็นไฟล์ static ด้วย export const prerender = true
---
// src/pages/about.astro — always static, even under output: 'server'
export const prerender = true;
---
<h1>About</h1>
---
// src/pages/dashboard.astro — always on-demand, even under output: 'static'
export const prerender = false;
const user = Astro.locals.user;
---
{user && <h1>Hi, {user.name}</h1>}

@astrojs/node มีสองโหมดที่ต่างกันจริง สร้าง dist/server/entry.mjs ที่ต่างกันเป๊ะ:

astro.config.mjs
import node from '@astrojs/node';
export default defineConfig({
adapter: node({ mode: 'standalone' }), // or { mode: 'middleware' }
});

Standalone คือ server ที่สมบูรณ์ รันเองได้ — node ./dist/server/entry.mjs เริ่ม listen เองเลย:

$ node ./dist/server/entry.mjs
04:47:44 [@astrojs/node] Server listening on http://127.0.0.1:4351

Middleware mode ไม่สร้าง listener เลย — entrypoint เดียวกันกลับ export handler ดิบ ๆ ให้เอาไปต่อกับ server ที่คุณมีอยู่แล้ว (Express, Fastify, http เปล่า ๆ):

$ node -e "import('./dist/server/entry.mjs').then(m => console.log(Object.keys(m)))"
[ 'handler', 'options', 'startServer' ]

พิสูจน์ด้วยการเอา handler ตัวนั้นเป๊ะไปต่อกับ Node http server เปล่า ๆ แล้วยืนยันว่า route จริงยัง resolve ผ่านมันได้:

// host.mjs — a minimal example of what "mounting into an existing server" means
import http from 'node:http';
import { handler as ssrHandler } from './dist/server/entry.mjs';
const server = http.createServer((req, res) => {
ssrHandler(req, res, (err) => { if (err) { res.statusCode = 500; res.end(String(err)); } });
});
server.listen(4351);
$ curl -s http://127.0.0.1:4351/api/health
{"ok":true,"hasSecret":true}

หยิบ standalone มาใช้เมื่อ Astro คือ server ทั้งหมด หยิบ middleware มาใช้เมื่อ output ของ Astro ต้องอยู่ในแอปที่มีอยู่แล้ว (Express server เดิมที่มี route อื่นอยู่, custom TLS termination ฯลฯ) — และจำไว้ว่า middleware mode ไม่ serve static file ให้เองเลย dist/client/ ยังต้อง serve แยกเอง

ใน on-demand page หรือ endpoint global object Astro เปิดให้เข้าถึง request:

---
export const prerender = false;
const url = Astro.url; // a URL object
const q = url.searchParams.get('q'); // query params
const method = Astro.request.method; // the raw Request
const token = Astro.cookies.get('session')?.value; // typed cookie access
const { id } = Astro.params; // dynamic route params
---
<p>Searching for {q}</p>
  • Astro.request คือ Request มาตรฐาน — headers, method และ await Astro.request.formData() หรือ .json() สำหรับ body
  • Astro.url คือ URL ที่ parse แล้ว — pathname และ searchParams
  • Astro.params เก็บ dynamic segment ([id])
  • Astro.cookies อ่านและเขียน cookie ด้วย API ที่ typed (.get, .set, .delete, .has)

ปกติหน้าจะ return HTML ของมันด้วย status 200 คุณควบคุม response ผ่าน Astro.response (set status และ header) หรือreturn Response ตรง ๆ:

---
export const prerender = false;
const user = Astro.cookies.get('session')?.value;
// Not logged in? Redirect.
if (!user) return Astro.redirect('/login');
// Set a custom header on the HTML response.
Astro.response.headers.set('Cache-Control', 'private, max-age=0');
---
<h1>Your dashboard</h1>

Astro.redirect('/login') return Response แบบ redirect; Astro.response.headers แก้ header ขาออก; และคุณ return new Response(...) เพื่อคุมทุกอย่างเองได้ (หน้า 404, status กำหนดเอง) นี่คือ model Request/Response เดียวกับที่ web platform ทั้งหมดใช้ — Astro ไม่ได้คิดแบบของตัวเองขึ้นมา

import.meta.env.WHATEVER เปล่า ๆ ไม่มี type และไม่ได้ป้องกัน server secret ไม่ให้ถูกอ่านใน client code astro:env เช็ค schema ทุกตัวแปร และที่สำคัญกว่านั้นคือบังคับว่าแต่ละตัวอ่านได้จากที่ไหน — สองแกนที่เป็นอิสระจากกัน:

astro.config.mjs
import { defineConfig, envField } from 'astro/config';
export default defineConfig({
env: {
schema: {
PUBLIC_SITE_NAME: envField.string({ context: 'client', access: 'public', default: 'Probe' }),
API_URL: envField.string({ context: 'server', access: 'public', default: 'https://api.example.com' }),
DB_PASSWORD: envField.string({ context: 'server', access: 'secret' }),
},
},
});
context access: 'public' access: 'secret'
client อ่านได้จาก astro:env/client — inline เป็น literal ลงใน client bundle ไม่ใช่ combination ที่ valid — Astro ปฏิเสธไม่ให้ config ไม่มีทางส่ง secret ไปที่ browser อย่างปลอดภัย
server อ่านได้จาก astro:env/server, server-only code อ่านได้จาก astro:env/server เท่านั้น ไม่ถูก bundle ไป client เลย

ตัวแปร client/public ถูก inline เป็น literal จริง ๆ — พิสูจน์ด้วยการ grep client script ที่ compile แล้วของหน้าที่ import PUBLIC_SITE_NAME:

$ grep -o 'var e=.*Probe.*;' dist/server/chunks/*.mjs
var e=`Probe`;document.getElementById(`client-side`).textContent=e;

ลอง import module ที่เป็น server-only จาก client <script> แล้ว build ปฏิเสธตรง ๆ — ไม่ใช่ undefined ตอน runtime แต่เป็น compiler error จริง:

<script>
import { DB_PASSWORD } from 'astro:env/server';
console.log(DB_PASSWORD);
</script>
[ServerOnlyModule] The "astro:env/server" module is only available server-side.
Error reference:
https://docs.astro.build/en/reference/errors/server-only-module/

และ secret ที่ required แต่ไม่มีค่า set ไว้ก็ไม่ได้กลายเป็น undefined เงียบ ๆ เช่นกัน — route ที่ import มันจะ fail ด้วย diagnostic ของ schema เองทันทีที่ถูกใช้จริง:

$ curl -s http://127.0.0.1:4351/api/health
(500)
[ERROR] EnvInvalidVariables: The following environment variables defined in `env.schema` are invalid:
- DB_PASSWORD is missing

(บังคับให้เช็คนี้เกิดกับทุก secret ที่ config ไว้ไม่ว่าจะถูก import หรือไม่ ได้ด้วย env: { validateSecrets: true } — มีประโยชน์เมื่อต้องการจับ production secret ที่ขาดหายตั้งแต่ใน CI แทนที่จะรอ request แรกที่ต้องการมันจริง ๆ)

เมื่อ review rendering/config/env code ที่ generate มา ให้เช็ค:

  • config set output: 'hybrid' หรือเปล่า? ค่านั้นไม่มีอยู่แล้วตั้งแต่ Astro v5 (พิสูจน์แล้วข้างบน) — มันคือศัพท์ค้างจาก training data หรือคู่มือเก่า ไม่ใช่สิ่งที่ compiler ยอมรับ
  • หน้าอ่าน Astro.cookies, Astro.clientAddress หรืออะไรที่เกี่ยวกับ request เฉพาะเจาะจง พร้อมกับมี (หรือสืบทอด) prerender = true ด้วยไหม? การอ่านเหล่านั้นต้องการ request จริงที่หน้า prerendered ไม่มีเลย
  • secret ถูกอ่านผ่าน import.meta.env.SOMETHING เปล่า ๆ แทน astro:env/server หรือเปล่า ซึ่งเสีย schema check และการบังคับ client/server boundary ที่พิสูจน์แล้วข้างบนไปด้วย?
  • client-side code import จาก astro:env/server ตรง ๆ โดยคาดว่า “มันแค่จะเป็น undefined” ถ้าใช้ผิด แทนที่จะเป็น build error จริงที่พิสูจน์แล้วข้างบนหรือเปล่า?
  • สมมติว่า node ./dist/server/entry.mjs เริ่ม listener เสมอหรือเปล่า? ใน mode middleware มันไม่ทำ — พิสูจน์แล้วข้างบนว่ามัน export handler ที่ต้อง mount เอง

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

astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
output: 'hybrid',
adapter: node({ mode: 'standalone' }),
});
เฉลย

output: 'hybrid' ถูกลบออกใน Astro v5 — config นี้ fail validation บน Astro ที่ติดตั้งปัจจุบัน มันไม่ใช่ legacy alias ที่ยังยอมรับเงียบ ๆ พฤติกรรมที่ผู้เขียนต้องการ (“static เป็น default โดยมีบางหน้า on-demand”) คือสิ่งที่ output: 'static' ทำอยู่แล้วเป๊ะ ด้วย export const prerender = false ต่อหน้าที่ต้องการ server — พิสูจน์แล้วก่อนหน้าในบทนี้ วิธีแก้คือ output: 'static' (หรือไม่ต้องใส่ output เลยเพราะเป็น default อยู่แล้ว) พร้อม prerender ต่อหน้าตามที่ต้องการ; output: 'server' คืออีกทางเลือกที่ valid ถ้าส่วนใหญ่ของหน้าควรเป็น on-demand แทน

output คือ default ไม่ใช่การแบ่งแยกตายตัว — rendering mode จริงของแต่ละ route คือ prerender ซึ่งชนะเสมอไม่ว่าจะ set เองหรือสืบทอดจาก default ของโปรเจกต์ adapter คือชั้นแปลระหว่าง per-request rendering นั้นกับรูปแบบ request/response ของ runtime หนึ่ง ๆ (http ของ Node, fetch handler ของ Cloudflare ฯลฯ) — นี่คือเหตุผลเป๊ะที่ context.clientAddress, การเขียน Astro.cookies และการอ่าน env สด ๆ ต้องการroute ที่ on-demand จริง ไม่ใช่แค่มี adapter อยู่ที่ไหนสักที่ใน config

output: 'static' (default) output: 'server'
Override ต่อหน้า prerender = false opt หน้าเข้าสู่ server prerender = true opt หน้าออกไปเป็น static
จุดเริ่มต้นที่ดีที่สุด เว็บส่วนใหญ่ — มีไม่กี่หน้าที่ต้องการ server แอปที่ส่วนใหญ่ของ route เป็น on-demand
ยังต้องมี adapter ใช่ สำหรับหน้า on-demand ใด ๆ ใช่ เสมอ
Node standalone Node middleware
Startup listen เอง พิสูจน์แล้วข้างบน ไม่มี listener — คุณ mount handler เอง พิสูจน์แล้วข้างบน
Static asset serve ให้อัตโนมัติ คุณ serve dist/client/ เอง
เหมาะกับ Astro คือ server ทั้งหมด Astro อยู่ในแอปที่มีอยู่แล้ว
  • “output: 'hybrid' คือ mode สำหรับผสม static กับ on-demand page” พิสูจน์แล้วข้างบน: ค่านั้นไม่มีอยู่แล้วตั้งแต่ v5 — 'static' พร้อม prerender = false ต่อหน้าคือ mode นั้นเป๊ะ ภายใต้ชื่อปัจจุบัน
  • “มี adapter ตัวเดียวก็พอสำหรับ Astro.clientAddress/cookies/env สด ๆ ที่ไหนก็ได้ในโปรเจกต์” สิ่งเหล่านั้นยังต้องการroute นั้นเจาะจงเป็น on-demand — หน้า prerendered ที่มี adapter ติดตั้งอยู่ที่อื่นในโปรเจกต์ก็ยัง fail พิสูจน์แล้วในบท endpoint
  • “node ./dist/server/entry.mjs เริ่ม server เสมอ” แค่ใน mode standalone เท่านั้น — พิสูจน์แล้วข้างบน mode middleware export handler ที่ไม่มี listener เลย
  • “secret env var ที่แค่ไม่ได้ set จะกลายเป็น undefined ตอน runtime เหมือน env var ปกติ” พิสูจน์แล้วข้างบน: secret ที่มี schema แต่ไม่มีค่า throw error EnvInvalidVariables จริงทันทีที่ถูกอ่าน ไม่ใช่ undefined เงียบ ๆ
ต้อง config อะไรให้ on-demand rendering ทำงาน?
output เป็น 'static' และหน้าหนึ่งมี export const prerender = false เกิดอะไรขึ้นกับหน้านั้นจริง ๆ?
config set output: 'hybrid' เกิดอะไรขึ้นบน Astro ที่ติดตั้งปัจจุบัน ตามที่พิสูจน์ในบทนี้?
ผลลัพธ์นี้มาจาก Node adapter build ใน mode ไหน และหมายความว่าอะไร?
$ node -e "import('./dist/server/entry.mjs').then(m => console.log(Object.keys(m)))"
[ 'handler', 'options', 'startServer' ]
schema declare DB_PASSWORD เป็น envField.string({ context: 'server', access: 'secret' }) โดยไม่มี default และ process ที่ deploy ไม่มี DB_PASSWORD set เลย พฤติกรรมจริงที่พิสูจน์แล้วเมื่อ route import มันคืออะไร?
จะเกิดอะไรถ้า client-side code เขียน import { DB_PASSWORD } from 'astro:env/server'?