[CODE]

五個民宿網站、一套自助更新架構:後台才是真正要解決的工程問題

五間恆春民宿、三種部署策略、一套共用的 GitHub App 後台——以及為什麼讓業主自己改定價,才是所有技術決策的起點。

2 min read AI 生成
astro tailwind typescript web

業主打電話來改房型定價,這不是支援請求,是基礎設施失敗的症狀。

這五個恆春民宿網站是一套直接訂房系統的其中一層:網站自己跑 Google Ads 流量,搭配訂房信號追蹤器把廣告支出和真實預訂連結起來。廣告引擎要能往真實訂房優化,前提是定價和促銷活動能在活動之間持續更新——業主必須能自己維護內容,不能走兩天的「打電話→等我」流程。這五個專案的每一個架構決策,都是從這個約束倒推的。讓業主依賴工程師改內容,整個 CPA 數學就算不過去。

混合輸出,不是選邊站

「靜態還是 SSR」這個提問方式對這幾個網站來說就是錯的。面向旅客的頁面——房型卡片、相片展示、定價表——在兩次請求之間根本不動。管理後台需要在請求時執行:OAuth 狀態驗證、GitHub App commit、session 管理。正確答案是在 astro.config.mjs 裡設 output: 'server',每個公開頁面加 export const prerender = true。Astro 在建置時把這些頁面輸出成靜態 HTML,和純靜態建置產出的結果 byte-identical。沒有匹配的路由——也就是管理後台——才走 Node runtime。

一個網站是真正靜態的:定價固定、沒有動態需求,部署到 Firebase Hosting,output: 'static'。Firebase CDN 對靜態資源設一年不過期的快取,HTML 五分鐘。沒有伺服器、沒有 cold start、沒有故障面。

另外四個跑在 Railway,@astrojs/node。兩個用 standalone 模式,Astro 的 entry point 處理所有事。兩個用 middleware 模式,把 Astro handler 掛進 Express,在回應離開容器前加上 gzip 和 brotli 壓縮。Astro 的 standalone adapter 不做壓縮,這個 Express 層在 Lighthouse 的文字壓縮指標上省下了量測到的 ~890ms。Cloudflare 擋在所有 Railway 部署前面:旅客瀏覽房型頁幾乎都吃快取,SSR 成本只在表單送出和 cache miss 時發生。Cold start 是真實代價——閒置後第一個請求要等幾秒——但只打到安靜時段後的第一位訪客,不影響高流量路徑。

兩個中途換了 adapter 的專案,切換成本在元件層面是零。Astro 的 adapter 設計是選擇它而非 Next.js 的理由之一。

圖片 pipeline 比一支 script 複雜

每個客戶交過來的都是一個 JPEG 資料夾,有的單張超過 8MB。「轉成 WebP」是答案的一部分,但在這個規模下有幾件事不能忽略。

實際的 pipeline 在 astro build 前執行,對每張來源圖產出:一個 1600px 的 legacy WebP(供現有頁面路徑使用)、一個 700px 的縮圖 WebP(畫廊用),以及六個響應式變體(480、960、1600px,WebP 和 AVIF 各一組)。兩種格式的 quality 設定不同——WebP 依斷點設 72/74/78,AVIF 設 55/60/65。AVIF 的編碼器在低得多的 quality 數字下能達到視覺等效的結果,所以兩個格式不能用同一套數字。一個網站還加了 2400px 斷點,給來源解析度夠高的 hero 圖在 Retina 螢幕上不用放大。

pipeline 在編碼前做 mtime 比對,來源沒動就跳過。一個有 40 張來源圖的 repo,冷跑產出 9 個輸出檔案;後續 CI run 只重新產出有變動的。沒有這個 check,每次部署會多出幾分鐘的固定成本。

legacy WebP 路徑存在是因為頁面是逐步遷移到 ResponsivePicture 元件的。直接拿掉 legacy 路徑會讓還在用 <stem>.webp 直接參照的畫廊壞掉。pipeline 兩者都保留,讓頁面一個一個選擇接入響應式處理,不需要一次性的遷移大關。

後台才是真正的工程問題

三個客戶想自己更新促銷橫幅和房型卡片。給 CMS 帳號這條路在第一個網站就失敗了,後來也確認會繼續失敗。每個業主都收下了帳號,用了兩三週,然後再也不登入。CMS 的操作邏輯假設使用者有編輯者的工作習慣。這些業主用 WhatsApp 和電話跑業務。內容管理介面對他們來說是一份從來沒有說好的第二份工作。

替代方案是一個以 GitHub API 為底層的專用表單。業主填好表單——促銷文案、促銷期間、房型卡片內容——送出之後,後台把變更 commit 進 repo,Railway 自動重新部署。兩分鐘後線上反映更動。不需要終端機、不需要記密碼、不需要打電話。

實作用的是 GitHub App,不是個人存取 token。原因有三:App 認證憑證是以安裝為單位範疇,commit 記錄在 App bot 名下而不是 Wayne 的個人帳號,同一個 App 安裝可以對應不同 repo 而不需要憑證重疊。後台認證層是 Google OAuth 搭配伺服器端驗證的 email 白名單——業主不需要建立帳號,白名單確保連結外洩也不會開放存取。

一個非顯而易見的設計:targetBranch() 函式把 BASE_URL 環境變數對映到目標 git branch。開發環境的 Railway 部署寫進 dev;staging 寫進 staging;正式上線部署寫進 main。同一份程式碼,不需要改任何東西就能在環境間晉升。這件事很重要,因為後台本身需要在各環境測試,不能讓 dev 的操作污染到線上 branch。

相片卡片的寫入透過 git tree API 做原子性 commit——一個 commit 同時落下更新後的 JSON、上傳的 JPEG 和轉換後的 WebP。業主永遠不會看到圖片路徑壞掉的卡片出現在線上。

剩下的已知死角:CI build 失敗的時候業主看不到錯誤——他們送出表單、等兩分鐘、網站沒動,然後還是會打電話來。目前用 build 狀態通知信處理。不完美,但這幾個網站的 build 失敗率低到目前撐得住。

Tailwind v4 和字體載入的坑

CSS 優先的設定——design token 寫在 .css 而不是 tailwind.config.js——適應期大概一天,之後確實比 v3 清爽,五個專案跨 repo 共用色彩 token 時尤其明顯。v3 有幾個 utility 名稱在 v4 改了,ring utilities 和 gradient helper 是反覆踩到的地方。單次修不超過 20 分鐘,但五個專案各踩一次加起來等於半天沒有預算到。

字體載入問題是最晚被我發現的。Google Fonts 在兩個網站的後期都以 render-blocking stylesheet 的形式存在,才被我注意到。修法——<link>media="print"onload 再切回 all——不是新技巧,但 Tailwind v4 的字體設定跟 v3 差異夠大,讓這個 blocking 行為在兩個專案各自建置到後期才浮現。第二次發現之後才進了 base layout 模板。

幾個網站在 astro.config.mjs 裡設了 inlineStylesheets: 'always'。Astro 預設的 'auto' 只 inline 4KB 以下的 stylesheet,較大的 bundle 會退回外部檔案請求。這幾個網站的 Tailwind bundle 跑到 ~12KB,大到觸發 render-blocking 外部請求,即使 stylesheet 已在快取也有量測到的 LCP 延遲。Inline 換來的是每頁 HTML 稍微胖一點,但對頁面數量少、gzip 壓縮率好的靜態網站而言,LCP 的收益值得這個取捨。

複製貼上的代價

圖片轉換 script 在每個專案都是直接複製進去的。到第四個 repo,四個版本已經各自靜靜地分叉——有的改了目標寬度,有的多了 quality flag,輸出路徑約定也不統一。每個專案都碰到了上一個沒遇到的約束,當下調整,沒有往回同步。重來的話,這是第二個專案就該做的事:抽成 shared internal package,不是四個手動維護的副本。

整套選型——Astro 混合輸出、GitHub App 後台、靜態走 Firebase、SSR 走 Railway——都是從同一個約束倒推的:業主自己能操作、不需要打電話找我。那個依賴才是小型業者繼續把房源放在 OTA 平台的真正原因。OTA 佣金比多數人想的高很多,但業者留在平台不是因為無知,是因為過去根本沒有夠便宜的替代方案讓他們能實際操作。AI 輔助開發讓五個客製化網站加上整合後台這個服務層在這種客戶的預算規模下第一次成立。