登入狀態由 server 的 middleware 判斷,結果放進context.locals。同一個 request 後面的頁面或 Action 直接讀這份結果,不必各自實作 session 查詢;.astro
頁面若要把資料交給 Vue island,只傳畫面需要的初始狀態,不把 cookie、token 或完整 session 序列化進 props 與 HTML。
Day 22:用 Drizzle ORM + Turso 接上資料層後,feedback 已經存得進資料庫,但後端還得在每次 request 進來時辨認是誰送的。這份工作由 middleware 與locals 接手。
頁面第一次 render 和使用者點擊 island 後呼叫 Action,分別屬於兩個 HTTP request:
request A:開啟頁面
↓
middleware:讀 cookie、辨認使用者
↓
context.locals A:保存這次 request 的 user / session
↓
.astro page:用 Astro.locals 讀取,將最小 props 放進 HTML
↓
Vue island hydrate
使用者點擊
↓
request B:呼叫 Action
↓
middleware 再執行一次,建立 context.locals B
↓
Action:從 ctx.locals 做權限檢查,再讀寫 DB
可以把 locals 想成這次 request 隨身帶著的識別證。middleware 寫入後,同一個 request 後面的 route
handler 都能直接讀;request 一結束,這份 locals 也跟著消失。request A 與 request
B 各有自己的 locals,不會跨 request 保存資料,也不能取代 Pinia、資料庫或 session store。
Astro 會從 src/middleware.ts 找具名匯出的 onRequest。app 裡的實作如下:
// src/middleware.ts
import { defineMiddleware } from "astro:middleware";
export const onRequest = defineMiddleware(async (context, next) => {
context.locals.user = null;
context.locals.session = null;
// 預渲染頁與沒有 cookie 的匿名 request 不需要查 session。
if (context.isPrerendered || !context.request.headers.get("cookie")) {
return next();
}
const { createAuth } = await import("./lib/auth");
const auth = createAuth(context.url.origin);
const data = await auth.api.getSession({
headers: context.request.headers,
});
context.locals.user = data?.user ?? null;
context.locals.session = data?.session ?? null;
return next();
});
next() 會把 request 交給後面的 middleware 或 route。執行到 route 時,前面寫入的 user、session 還留在同一個locals 物件裡。
這裡會先判斷是否需要查 session:沒有 cookie 就略過,匿名訪客不必為了確認「沒有登入」多查一次資料庫。遇到 build-time
render 時,context.isPrerendered 會讓 middleware 直接交給 next(),避免靜態頁在建置時碰到個人化資料。
createAuth() 與 getSession() 的細節會在
Day 24:Astro 的驗證策略與 Better Auth說明;這裡只追蹤 session 查完後的去向。
middleware 寫的是 context.locals,.astro 頁面讀的則是 Astro.locals:
---
export const prerender = false;
const user = Astro.locals.user;
---
<p>
目前 server 看到的登入者:
<strong>{user ? user.email : '(未登入)'}</strong>
</p>
context.locals 與 Astro.locals 指向同一次 render 的 request context。API
route 或 Action 收到自己的 request 時,也會先經過 middleware,再從各自的 context 讀取該次 request 的 locals。Day 20:用 Endpoint 輸出 JSON 與 RSS和
Day 21:Actions 與 Endpoints 怎麼選介紹過的 handler
context,現在多了這份由 middleware 先算好的 request 資料。
型別則放在 src/env.d.ts:
import type { Auth } from "./lib/auth";
type SessionData = Auth["$Infer"]["Session"];
declare global {
namespace App {
interface Locals {
user: SessionData["user"] | null;
session: SessionData["session"] | null;
}
}
}
這樣 middleware 寫入、頁面讀取、Action 判斷時都有同一套型別。Astro 5 起不能用 context.locals = { ... }
整個替換物件,應該逐欄賦值,或用 Object.assign();原因之一是 integration 也可能在同一個物件上放資料。
這個 app 維持 Astro 預設的output: 'static'。文章頁在 build 時先產生 HTML,但登入狀態要等訪客真的送出 request 才知道,因此需要 session 的頁面必須改成 on-demand
rendering:
---
export const prerender = false; const user = Astro.locals.user;
---
| 頁面 | render 時機 | 能否讀目前訪客的 session |
|---|---|---|
| 一般文章頁 | build time | 不能,當時還沒有這位訪客 |
| 登入/收藏頁 | request time | 能,middleware 可讀 cookie |
判斷標準和
Day 15:動態路由的 SSG/SSR 分界相同:內容對所有人相同就預渲染;內容取決於這次 request,就改成 on-demand。
舊文章可能把「static 為主,部分頁面動態」寫成 output: 'hybrid'。這個選項從 Astro
5 起已移除。現在直接維持 static 預設,在個別 route 寫 prerender = false 即可。
server 能讀完整的 user 與 session,Vue island 只需要畫面用得到的部分。現有的 /demos/auth 是 on-demand
route;收到 request 後,.astro frontmatter 會先查出登入者與初始收藏狀態,再投影成兩個布林值:
---
const user = Astro.locals.user;
let initialFavorited = false; if (user) { // server 查 favorites,略去查詢細節 initialFavorited = true; }
---
<FavoriteButton client:load slug="day-24-auth" isLoggedIn="{!!user}" initialFavorited="{initialFavorited}" />
Vue island 收到的是 UI 需要的最小資料:
const props = defineProps({
slug: { type: String, required: true },
isLoggedIn: { type: Boolean, default: false },
initialFavorited: { type: Boolean, default: false },
});
hydrated component 的 props 會被序列化到輸出的 <astro-island>
HTML。能序列化不代表適合傳到瀏覽器;cookie、token、完整 session 與不必要的 DB 欄位,都不應序列化進 island
props 或 HTML。敏感 cookie 還應設為 HttpOnly,避免 client JavaScript 讀取。
這兩種容器的規則不同:
| 容器 | 在哪裡使用 | 是否要能序列化 |
|---|---|---|
context.locals |
server 的單次 request | 不必 |
| hydrated island props | 從 server 跨到 browser | 必須,而且使用者看得到 |
早期 Astro 曾要求 locals 可序列化,現在已取消這項限制;island
props 仍然必須序列化。若混用兩條規則,不該離開 server 的資料可能會進到 HTML。
「server 先算好登入狀態,再傳 props」只適用於 on-demand
page,因為預渲染文章頁在 build 時還沒有目前訪客。靜態文章頁若要放收藏按鈕,可以讓 island
hydrate 後另查 session,並處理 loading/狀態切換。這個 capstone 目前只在 on-demand 的 /demos/auth
示範 server 先給初始狀態。
locals 留在 server、不必序列化;island props 會跨到瀏覽器、必須序列化。實務上也有人把這兩種容器合成一個。
一個上線中的多語系品牌官網就採用這種設計,middleware 以 sequence() 串起三段:
// src/middleware/index.ts
import { sequence } from "astro:middleware";
export const onRequest = sequence(createRequestStore, setLocale, updateStore);
這三段都不寫 context.locals,整個專案也沒有用到 locals。專案改用自建的 request store:server 端以 Node 的AsyncLocalStorage 包住每次 request,瀏覽器端則讀同一份資料的另一個副本。這份副本由 layout 送出去:
---
// layout 的 frontmatter const requestData = JSON.stringify(requestStore.get());
---
<script set:html="{`window.__PAGE_STATE__" ="${requestData};`}" />
這個設計允許寫入時標記不外流的欄位。store 內部維護一份排除名單,get()
交給 layout 序列化之前會先濾掉這些欄位,因此不會把所有 server 資料直接送到瀏覽器。
這種設計沿用 Nuxt 類框架的機制:server 算好狀態,再序列化成 payload 給 client。把熟悉的形狀搬過來,可以同時處理 server 取值與 island 取值。
代價是那份排除名單必須由人維護。兩種容器合成一個之後,「不外流」不再由結構保證:漏標一個欄位,它就會出現在 HTML 裡。locals
不必序列化,因為它從來不離開 server;要跨界的資料改走 props,序列化就會成為每次都得明確寫出的動作。
這個專案維持 output: 'static' 且沒有安裝 adapter,所以那整套 per-request 機制只在 astro build
期間執行過,線上不會有第二次。把專案複製出來實際跑 build 時,Node 還跳了一則 localStorage is not available
的警告,因為有元件在建置階段就碰了只有瀏覽器才有的 API。這跟前面「登入狀態為什麼不能塞進預渲染頁」是同一條界線:沒有 request,就沒有這次 request 的使用者。
isLoggedIn
能決定按鈕顯示「收藏這篇」或「登入以收藏」,但它只是 client 拿到的初始提示。使用者可以修改瀏覽器裡的任何資料,所以點擊送出的 Action
request 會再次經過 middleware,Action 再從該次 request 的 ctx.locals.user 判斷:
toggleFavorite: defineAction({
input: z.object({ slug: z.string().min(1) }),
handler: async ({ slug }, ctx) => {
const user = ctx.locals.user;
if (!user) {
throw new ActionError({
code: "UNAUTHORIZED",
message: "請先登入才能收藏文章",
});
}
// 通過 server 端授權後,才讀寫 favorites。
},
});
頁面 request 先算初始 UI,可省掉 island hydrate 後為了第一次顯示而另查 session;Action
request 仍須重新辨認使用者,才能在 server 端授權資料操作。兩次 session 查詢分屬不同 request,不能共用同一份 locals。
依 .nvmrc 使用 Node 24.16.0 跑 npm run build,Astro 回報 output: "static" 並完成建置。Day 23 文章產在dist/client/blog/day-23-middleware-locals/index.html;需要登入狀態的 /demos/auth 沒有靜態 HTML,而是編進dist/server/chunks/auth_*.mjs。
同一個 app 裡,公開文章在 build 時完成;登入 demo 等 request 進來才 render。實際產物與前面的表格一致。
| 範圍 | 放置位置 | 用途 |
|---|---|---|
| 跨 request | cookie+session store | 讓下一次 request 還能辨認同一位使用者 |
| 單次 request | context.locals |
讓該次 request 的 route handler 不必重做 session 查詢 |
| server → browser | 最小、可序列化的 island props | 只提供初始 UI;不可當授權依據 |
Vue island 負責互動,不負責授權。每次會讀寫資料的 request 都要交給 middleware 與 server
handler,以該次 request 的 locals 判斷權限。
版本基準:Astro
7,2026-07-23 查證。官方文件:Middleware、locals、isPrerendered、On-demand rendering、Framework component props。