- Published on
在 Next.js 接 Firebase Remote Config 當 feature flag:為什麼這樣選、跟踩到的幾個坑
- Authors

- Name
- Alex Yu
code 合進 main 那一刻,你會希望它「立刻被所有人看到」,還是「先躲著、隨時可以開關」?多數時候答案是後者,但部署一旦跟「使用者真的看見」綁在一起,這個彈性就沒了。
這次首頁要上的新區塊就卡在這個點。決定要用 feature flag 本身很單純,麻煩的是「要接誰」跟「接起來會撞到什麼」。
為什麼要 feature flag
把「部署」跟「上線」拆開,是這件事真正的價值。code 進 production 不等於使用者看得到——什麼時候讓誰看到,是一個 runtime 決定,不是一次 build。
實際上會用到的情境大概是這幾種:出包了能立刻關掉(kill switch),不用等 hotfix 走完 CI/CD;新功能先開給 5% 流量、觀察沒問題再慢慢放大(也就是灰度,gradual rollout);同個功能兩個版本對照看數據(A/B);某個合作活動的區塊到時間自動上下架。少了 flag,這些全部都要靠重新部署,而部署這條鏈通常是分鐘級的,出事的時候那幾分鐘很難熬。
業界常見的做法
光譜大致從「自己刻」到「買 SaaS」:
- 環境變數 / build-time 常數:最簡單,但改一次要 redeploy,等於沒解決上面那個問題。適合「幾乎不會動」的開關。
- 自己存 DB + 一張後台:彈性最大,但 targeting、灰度這些都要自己長出來,長到後面就是在重造一個 flag 平台。
- 開源自架:Unleash、GrowthBook 這類,功能完整(targeting、gradual rollout、A/B),代價是要自己維運一套服務。
- SaaS:LaunchDarkly、Statsig、ConfigCat,開箱即用、SDK 成熟,按 MAU/seat 收費。
- 雲廠商內建:AWS AppConfig、Firebase Remote Config 這種,如果你本來就在那個生態裡,幾乎零額外成本。
順帶解釋一下 targeting
上面一直提到 targeting。它指的是 flag 平台決定「這個開關對『誰』回什麼值」的規則——同一個 flag 不是全站 on/off,而是依條件分群給不同的值。常見的條件像:隨機 N% 的使用者(灰度)、特定 user ID 名單(內部員工、beta tester)、app 版本 ≥ 某版、某個國家或語系、A/B 分桶(而且同一個 user 每次都分到同一桶)。

「全站一個布林」誰都做得到,targeting 細不細才是各家 flag 平台真正拉開的地方。我們目前只要「全站開關 + 偶爾灰度」,所以 Firebase Remote Config 的 condition(依 app 版本、語系、國家、隨機百分比、特定 user)夠用;哪天要做「先開給企業客戶」這種依任意自訂使用者屬性的規則,就得換 LaunchDarkly 那一類。
為什麼選 Firebase Remote Config
理由很現實:專案其他地方已經在用 Firebase,不想為了一個開關再引進一個 SaaS 或多維運一套服務,而 Remote Config 對「布林開關 + 偶爾灰度」這種需求已經夠用、且免費。
另一個點是它不挑 platform——Web、iOS、Android 各有 client SDK,後端則可以用 Admin SDK 配 server-side Remote Config 拉同一份 template。對一個同時有 web、app、backend 的產品來說,「同一個開關、所有端讀同一個來源」這件事本身就有價值,不用每個 platform 各刻一套。我們這次只用到 Web client SDK,但需要的時候 app 跟 backend 接得上去是個加分。
下面是接起來的形狀,跟過程中踩到的坑。key 名稱都用 new_home_section 代稱。
三層:firebase app → util → store + hook
Firebase 的 client SDK 只能在瀏覽器跑,所以 app 初始化做了個 guard,server 端直接回 null:
// lib/firebase/index.ts
export const getFirebaseApp = (): FirebaseApp | null => {
if (typeof window === 'undefined') return null
if (!firebaseConfig.apiKey || !firebaseConfig.projectId) return null // ← env 沒設也回 null
return getApps().length > 0 ? getApp() : initializeApp(firebaseConfig)
}中間一層 util 包掉 fetchAndActivate 跟取值,最外面是一個 Zustand store + useRemoteConfig hook,負責初始化、灌值進 store、提供 getFlag:
// hooks/useRemoteConfig/index.ts
'use client'
const DEFAULT_CONFIG = { [REMOTE_CONFIG_KEYS.NEW_HOME_SECTION]: false }
export const useRemoteConfig = () => {
const { isInitialized, isLoading, configs, setConfigs, setInitialized, setLoading } =
useRemoteConfigStore()
useEffect(() => {
if (isInitialized || isLoading) return
const init = async () => {
setLoading(true)
const rc = await initRemoteConfig(DEFAULT_CONFIG)
if (rc) setConfigs(getAllConfigValues())
setInitialized(true) // ← 就算 rc 是 null 也標記成 initialized
setLoading(false)
}
init()
}, [isInitialized, isLoading, setConfigs, setInitialized, setLoading])
const getFlag = useCallback(
(key: string): boolean => {
if (!isInitialized || configs[key] === undefined) {
return (DEFAULT_CONFIG[key] as boolean) ?? false // ← fallback
}
return getConfigBoolean(key)
},
[isInitialized, configs],
)
return { isInitialized, isLoading, getFlag, configs }
}使用端就一行:
const { getFlag } = useRemoteConfig()
if (!getFlag(REMOTE_CONFIG_KEYS.NEW_HOME_SECTION)) return null坑一:fetch 失敗時要還拿得到 default
initRemoteConfig 第一版是先 await fetchAndActivate(rc)、成功後才把 rc 存進 module 變數。但 fetchAndActivate 因網路問題 throw 的話,那個變數永遠是 null,後面取值一律回 false,連 rc.defaultConfig 設好的 default 都讀不到——等於 flag 系統一掛,畫面跟著歪。
把賦值挪到 await 前面就好。getRemoteConfig 拿到 instance 的當下 defaultConfig 已經套上去了:
const rc = getRemoteConfig(app)
rc.settings.minimumFetchIntervalMillis = isLocal ? 0 : 3600000
rc.defaultConfig = defaultValues
// ❌ fetch throw 的話 instance 一直是 null
await fetchAndActivate(rc)
remoteConfigInstance = rc
// ✅ 先存 instance,fetch 失敗也還讀得到 defaultConfig
remoteConfigInstance = rc
await fetchAndActivate(rc)hook 那層再補一道:isInitialized 為 true 但 configs 是空的(Firebase 沒設定、fetch 失敗),getFlag 直接走 DEFAULT_CONFIG。default 值因此有 SDK 的 defaultConfig、getFlag 的 early return、store 空值判斷三處都顧到。聽起來重複,但 flag 卡在 render path 上,整條鏈任何一段斷掉都不能讓畫面爆,多疊幾層無妨。
坑二:key 常數不要綁在 'use client' 檔案裡
REMOTE_CONFIG_KEYS 一開始宣告在 hooks/useRemoteConfig/index.ts——那是 'use client' 檔。結果別處(Server Component、middleware)只想 import 那個 key 名稱時,會把整個 client hook 連同 Firebase SDK 一起拖進 client bundle。
把純常數抽到一個沒有 'use client' 的 constants/remoteConfig.ts:
// constants/remoteConfig.ts —— 沒有 'use client'
export const REMOTE_CONFIG_KEYS = {
NEW_HOME_SECTION: 'new_home_section',
} as const
export type RemoteConfigKey =
(typeof REMOTE_CONFIG_KEYS)[keyof typeof REMOTE_CONFIG_KEYS]讀 flag 還是只能在 client(SDK 限制),但「key 名稱」這種純資料沒理由綁在 client boundary 裡。
坑三:isolatedModules 下,純測試檔要有 export
isolatedModules 開著時,一個 .ts 檔如果完全沒有 top-level 的 import / export,TypeScript 會把它當 global script 而非 module,編譯報錯。新加的某個測試檔全是 jest.fn() 跟 mock、沒有任何 import/export,就中了。加一行 export {} 把它變回 module 即可。
心得
接 Remote Config 本身不難,難的是想清楚「拿不到值的時候要怎樣」——它本質是個會 await 網路、可能失敗的東西,default 的 fallback 一定要在多個層級都成立,不能只靠 SDK。
選型上倒是沒糾結太久。flag 平台的功能差異(targeting 細不細、有沒有內建 analytics)在需求還只是「布林開關 + 偶爾灰度」的時候放大不了——就用生態裡現成的,等真的需要再說。
重點整理
- feature flag 的價值是把「部署」跟「上線」拆開:kill switch、灰度放量、A/B、定時上下架,少了它全要靠重新部署。
- 選型光譜:env var(要 redeploy)→ 自刻 DB → 開源自架(Unleash/GrowthBook)→ SaaS(LaunchDarkly/Statsig)→ 雲廠商內建。本來就在某個生態裡,用內建的最省;Firebase Remote Config 還能 web / iOS / Android client SDK 加後端 Admin SDK 讀同一份 template。
getRemoteConfig拿到 instance 後defaultConfig就生效,先存 instance 再await fetchAndActivate,fetch 失敗也讀得到 default;fallback 多疊幾層。- 純常數/型別放在沒有
'use client'的檔案,避免把 client SDK 拖進 bundle;isolatedModules下沒 import/export 的檔加export {}。
