From 9c6ab643991c83c35d791529dac1de2c6115e45d Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Fri, 31 Jul 2026 19:33:21 +0200 Subject: [PATCH 1/7] Add Suspense support for useFlag loading --- .changeset/green-flowers-suspend.md | 5 + packages/react-sdk/README.md | 19 ++++ packages/react-sdk/src/index.tsx | 122 +++++++++++++++++++++++-- packages/react-sdk/test/usage.test.tsx | 57 ++++++++++++ 4 files changed, 195 insertions(+), 8 deletions(-) create mode 100644 .changeset/green-flowers-suspend.md diff --git a/.changeset/green-flowers-suspend.md b/.changeset/green-flowers-suspend.md new file mode 100644 index 000000000..9ddb8e772 --- /dev/null +++ b/.changeset/green-flowers-suspend.md @@ -0,0 +1,5 @@ +--- +"@reflag/react-sdk": minor +--- + +Add Suspense support for `useFlag` while flags are loading via provider-level and per-hook `suspense` options. diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index 1860305d8..87df79be3 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -511,6 +511,7 @@ The `ReflagClientProvider` accepts the following props: - `client`: A pre-initialized `ReflagClient` instance - `loadingComponent`: Optional React component to show while the client is initializing (same as `ReflagProvider`) +- `suspense`: Optional. Set to `true` to make `useFlag()` suspend while the client is loading > [!Note] > Most applications should use `ReflagProvider` or `ReflagBootstrappedProvider` instead of `ReflagClientProvider`. Only use this component when you need the advanced control it provides. @@ -549,6 +550,7 @@ The `` initializes the Reflag SDK, fetches flags and starts list - `staleTimeMs`: Maximum time (in milliseconds) that stale flags will be returned if `staleWhileRevalidate` is true and new flags cannot be fetched. - `offline`: Provide this option when testing or in local development environments to avoid contacting Reflag servers. - `loadingComponent` lets you specify an React component to be rendered instead of the children while the Reflag provider is initializing. If you want more control over loading screens, `useFlag()` and `useIsLoading` returns `isLoading` which you can use to customize the loading experience. +- `suspense`: Set to `true` to make `useFlag()` suspend while the provider is loading. Wrap components that call `useFlag()` in React `` boundaries and omit `loadingComponent` if you want Suspense fallbacks to control loading UI. - `enableTracking`: Set to `false` to stop sending tracking events and user/company updates to Reflag. Useful when you're impersonating a user (defaults to `true`), - `enableLiveFlagUpdates`: Enables live flag updates over SSE. Defaults to `true` in the React SDK. - `apiBaseUrl`: Optional base URL for the Reflag API. This also controls the SSE origin used for live flag updates and automated feedback, @@ -654,6 +656,23 @@ function StartHuddleButton() { } ``` +#### Suspense loading + +Enable `suspense` on the provider to have `useFlag()` throw a promise while `isLoading` is true. The nearest `` boundary will render its fallback until flags are ready. + +```tsx +import { Suspense } from "react"; +import { ReflagProvider } from "@reflag/react-sdk"; + + + }> + + +; +``` + +You can also opt in for a single call with `useFlag("huddle", { suspense: true })`, or opt out inside a suspense-enabled provider with `{ suspense: false }`. + ### `useTrack()` `useTrack()` lets you send custom events to Reflag. Use this whenever a user _uses_ a feature. These events can be used to analyze feature usage in Reflag. diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index 661ac91b8..cfd28f1a0 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -159,6 +159,12 @@ export type ReflagPropsBase = { */ initialLoading?: boolean; + /** + * Set to `true` to make `useFlag` suspend while the client is loading. + * Components that call `useFlag` must be wrapped in a React `` boundary. + */ + suspense?: boolean; + /** * A custom logger to use for SDK logs. * Use this for advanced control or filtering of SDK logs. @@ -248,9 +254,46 @@ function useReflagClient(initOptions: InitOptions & { debug?: boolean }) { return reflagClients.get(publishableKey)!; } +type LoadingPromiseState = { + promise: Promise; + resolve: () => void; +}; + +function isClientLoading(client: ReflagClient) { + const state = client.getState(); + return state === "idle" || state === "initializing"; +} + +function createLoadingPromise(client: ReflagClient): LoadingPromiseState { + let resolvePromise!: () => void; + let settled = false; + let unsubscribe: (() => void) | undefined; + + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + const resolve = () => { + if (settled) return; + settled = true; + unsubscribe?.(); + resolvePromise(); + }; + + unsubscribe = client.on("stateUpdated", (state) => { + if (state === "initialized" || state === "stopped") { + resolve(); + } + }); + + return { promise, resolve }; +} + type ProviderContextType = { isLoading: boolean; client: ReflagClient; + suspense: boolean; + getLoadingPromise: () => Promise; }; const ProviderContext = createContext(null); @@ -269,28 +312,63 @@ export function ReflagClientProvider({ client, loadingComponent, initialLoading = true, + suspense = false, children, }: ReflagClientProviderProps) { const hasInitialized = useRef(client.getState() === "initialized"); const [isLoading, setIsLoading] = useState( hasInitialized.current ? false : initialLoading, ); + const loadingPromiseRef = useRef(null); + + const getLoadingPromise = () => { + if (!loadingPromiseRef.current) { + loadingPromiseRef.current = createLoadingPromise(client); + } + + if (client.getState() === "idle") { + void Promise.resolve().then(() => { + if (client.getState() !== "idle") return; + return client.initialize().catch((e) => { + client.logger.error("failed to initialize client", e); + }); + }); + } + + return loadingPromiseRef.current.promise; + }; + + const setLoading = (loading: boolean) => { + if (!loading) { + loadingPromiseRef.current?.resolve(); + loadingPromiseRef.current = null; + } + + setIsLoading(loading); + }; + + useEffect(() => { + return () => { + loadingPromiseRef.current?.resolve(); + loadingPromiseRef.current = null; + }; + }, []); useOnEvent( "stateUpdated", (state) => { if (state === "initialized") { hasInitialized.current = true; - setIsLoading(false); + setLoading(false); return; } if (state === "initializing") { - setIsLoading(hasInitialized.current || initialLoading); + setLoading(hasInitialized.current || initialLoading); return; } - setIsLoading(false); + setLoading(false); }, client, ); @@ -300,6 +378,8 @@ export function ReflagClientProvider({ value={{ isLoading, client, + suspense, + getLoadingPromise, }} > {isLoading && typeof loadingComponent !== "undefined" @@ -351,6 +431,7 @@ export function ReflagProvider({ otherContext, loadingComponent, initialLoading = true, + suspense, logger, debug, ...config @@ -395,6 +476,7 @@ export function ReflagProvider({ client={client} initialLoading={initialLoading} loadingComponent={loadingComponent} + suspense={suspense} > {children} @@ -420,6 +502,7 @@ export function ReflagBootstrappedProvider({ children, loadingComponent, initialLoading = false, + suspense, logger, debug, ...config @@ -449,6 +532,7 @@ export function ReflagBootstrappedProvider({ client={client} initialLoading={initialLoading} loadingComponent={loadingComponent} + suspense={suspense} > {children} @@ -460,11 +544,22 @@ export type RequestFeedbackOptions = Omit< "flagKey" | "featureId" >; +export type UseFlagOptions = { + /** + * Override the provider suspense setting for this `useFlag` call. + * When true, `useFlag` throws a promise while flags are loading. + */ + suspense?: boolean; +}; + /** * @deprecated use `useFlag` instead */ -export function useFeature(key: TKey) { - return useFlag(key); +export function useFeature( + key: TKey, + options?: UseFlagOptions, +) { + return useFlag(key, options); } /** @@ -478,9 +573,12 @@ export function useFeature(key: TKey) { * } * ``` */ -export function useFlag(key: TKey): TypedFlags[TKey] { - const client = useClient(); - const isLoading = useIsLoading(); +export function useFlag( + key: TKey, + options: UseFlagOptions = {}, +): TypedFlags[TKey] { + const context = useSafeContext(); + const { client, isLoading } = context; const [flag, setFlag] = useState(client.getFlag(key)); const track = () => client.track(key); @@ -495,6 +593,14 @@ export function useFlag(key: TKey): TypedFlags[TKey] { client, ); + if ( + isLoading && + isClientLoading(client) && + (options.suspense ?? context.suspense) + ) { + throw context.getLoadingPromise(); + } + if (isLoading || !flag) { return { key, diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index 3a7c87d91..51e52ea39 100644 --- a/packages/react-sdk/test/usage.test.tsx +++ b/packages/react-sdk/test/usage.test.tsx @@ -367,6 +367,63 @@ describe("useFlag", () => { unmount(); }); + test("suspends while loading when suspense is enabled", async () => { + let resolveFlags!: () => void; + const flagsRequest = new Promise((resolve) => { + resolveFlags = resolve; + }); + const requestStarted = vi.fn(); + + server.use( + http.get(/\/features\/evaluated$/, async () => { + requestStarted(); + await flagsRequest; + return HttpResponse.json({ + success: true, + features: { + abc: { + key: "abc", + isEnabled: true, + targetingVersion: 1, + }, + }, + }); + }), + ); + + function FlaggedContent() { + const { isEnabled } = useFlag("abc"); + return {String(isEnabled)}; + } + + const { getByTestId, queryByTestId, unmount } = render( + Loading flags} + > + {getProvider({ + children: , + suspense: true, + })} + , + ); + + expect(getByTestId("suspense-fallback").textContent).toBe("Loading flags"); + expect(queryByTestId("flag-value")).toBeNull(); + + await waitFor(() => { + expect(requestStarted).toHaveBeenCalled(); + }); + + resolveFlags(); + + await waitFor(() => { + expect(getByTestId("flag-value").textContent).toBe("true"); + }); + expect(queryByTestId("suspense-fallback")).toBeNull(); + + unmount(); + }); + test("finishes loading", async () => { const { result, unmount } = renderHook(() => useFlag("huddle"), { wrapper: ({ children }) => getProvider({ children }), From 2f096fe758c92aa881adc52246865598a6e268db Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Sun, 2 Aug 2026 20:49:58 +0200 Subject: [PATCH 2/7] Simplify useFlag loading path --- packages/react-sdk/src/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index cfd28f1a0..96fc84594 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -579,7 +579,7 @@ export function useFlag( ): TypedFlags[TKey] { const context = useSafeContext(); const { client, isLoading } = context; - const [flag, setFlag] = useState(client.getFlag(key)); + const [flag, setFlag] = useState(() => client.getFlag(key)); const track = () => client.track(key); const requestFeedback = (opts: RequestFeedbackOptions) => @@ -601,7 +601,7 @@ export function useFlag( throw context.getLoadingPromise(); } - if (isLoading || !flag) { + if (isLoading) { return { key, isLoading, From 9fc20ccf02f09e399605382b12d85f70eaa14d39 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Mon, 3 Aug 2026 20:53:42 +0200 Subject: [PATCH 3/7] Address Suspense review feedback --- packages/react-sdk/README.md | 6 +- packages/react-sdk/src/index.tsx | 11 +- packages/react-sdk/test/usage.test.tsx | 168 ++++++++++++++++++++++--- 3 files changed, 163 insertions(+), 22 deletions(-) diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index 87df79be3..d17b704c0 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -477,7 +477,9 @@ This App Router approach leverages Server Components for server-side flag fetchi ## `` component -The `` is a lower-level component that accepts a pre-initialized `ReflagClient` instance. This is useful for advanced use cases where you need full control over client initialization or want to share a client instance across multiple parts of your application. +The `` is a lower-level component that accepts a `ReflagClient` instance. This is useful for advanced use cases where you need full control over client initialization or want to share a client instance across multiple parts of your application. + +In most cases you should initialize the client before rendering this provider. If you pass an idle client and enable `suspense`, a `useFlag()` call that suspends can initialize the client on demand; without Suspense, you must initialize the client yourself. ### Usage @@ -509,7 +511,7 @@ function App() { The `ReflagClientProvider` accepts the following props: -- `client`: A pre-initialized `ReflagClient` instance +- `client`: A `ReflagClient` instance. Prefer passing an already-initialized client; idle clients are initialized on demand only by suspense-enabled `useFlag()` calls. - `loadingComponent`: Optional React component to show while the client is initializing (same as `ReflagProvider`) - `suspense`: Optional. Set to `true` to make `useFlag()` suspend while the client is loading diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index 96fc84594..9eee4779e 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -259,7 +259,11 @@ type LoadingPromiseState = { resolve: () => void; }; +const failedInitializations = new WeakSet(); + function isClientLoading(client: ReflagClient) { + if (failedInitializations.has(client)) return false; + const state = client.getState(); return state === "idle" || state === "initializing"; } @@ -317,7 +321,9 @@ export function ReflagClientProvider({ }: ReflagClientProviderProps) { const hasInitialized = useRef(client.getState() === "initialized"); const [isLoading, setIsLoading] = useState( - hasInitialized.current ? false : initialLoading, + hasInitialized.current || failedInitializations.has(client) + ? false + : initialLoading, ); const loadingPromiseRef = useRef(null); @@ -331,6 +337,8 @@ export function ReflagClientProvider({ if (client.getState() !== "idle") return; return client.initialize().catch((e) => { client.logger.error("failed to initialize client", e); + failedInitializations.add(client); + setLoading(false); }); }); } @@ -358,6 +366,7 @@ export function ReflagClientProvider({ "stateUpdated", (state) => { if (state === "initialized") { + failedInitializations.delete(client); hasInitialized.current = true; setLoading(false); return; diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index 51e52ea39..e01ad175b 100644 --- a/packages/react-sdk/test/usage.test.tsx +++ b/packages/react-sdk/test/usage.test.tsx @@ -349,25 +349,7 @@ describe("", () => { }); describe("useFlag", () => { - test("returns a loading state initially", async () => { - const { result, unmount } = renderHook(() => useFlag("huddle"), { - wrapper: ({ children }) => getProvider({ children }), - }); - - // The flag should exist but may be loading or not depending on implementation - expect(result.current.key).toBe("huddle"); - expect(result.current.isEnabled).toBe(false); - expect(result.current.config).toEqual({ - key: undefined, - payload: undefined, - }); - expect(typeof result.current.track).toBe("function"); - expect(typeof result.current.requestFeedback).toBe("function"); - - unmount(); - }); - - test("suspends while loading when suspense is enabled", async () => { + function mockDelayedFlagsResponse() { let resolveFlags!: () => void; const flagsRequest = new Promise((resolve) => { resolveFlags = resolve; @@ -391,6 +373,30 @@ describe("useFlag", () => { }), ); + return { requestStarted, resolveFlags }; + } + + test("returns a loading state initially", async () => { + const { result, unmount } = renderHook(() => useFlag("huddle"), { + wrapper: ({ children }) => getProvider({ children }), + }); + + // The flag should exist but may be loading or not depending on implementation + expect(result.current.key).toBe("huddle"); + expect(result.current.isEnabled).toBe(false); + expect(result.current.config).toEqual({ + key: undefined, + payload: undefined, + }); + expect(typeof result.current.track).toBe("function"); + expect(typeof result.current.requestFeedback).toBe("function"); + + unmount(); + }); + + test("suspends while loading when suspense is enabled", async () => { + const { requestStarted, resolveFlags } = mockDelayedFlagsResponse(); + function FlaggedContent() { const { isEnabled } = useFlag("abc"); return {String(isEnabled)}; @@ -424,6 +430,130 @@ describe("useFlag", () => { unmount(); }); + test("suspends while loading when suspense is enabled for a single useFlag call", async () => { + const { requestStarted, resolveFlags } = mockDelayedFlagsResponse(); + + function FlaggedContent() { + const { isEnabled } = useFlag("abc", { suspense: true }); + return {String(isEnabled)}; + } + + const { getByTestId, queryByTestId, unmount } = render( + Loading flags} + > + {getProvider({ + children: , + })} + , + ); + + expect(getByTestId("suspense-fallback").textContent).toBe("Loading flags"); + expect(queryByTestId("flag-value")).toBeNull(); + + await waitFor(() => { + expect(requestStarted).toHaveBeenCalled(); + }); + + resolveFlags(); + + await waitFor(() => { + expect(getByTestId("flag-value").textContent).toBe("true"); + }); + expect(queryByTestId("suspense-fallback")).toBeNull(); + + unmount(); + }); + + test("does not suspend when suspense is disabled for a single useFlag call", async () => { + const { requestStarted, resolveFlags } = mockDelayedFlagsResponse(); + + function FlaggedContent() { + const { isEnabled, isLoading } = useFlag("abc", { suspense: false }); + return ( + + {String(isLoading)}:{String(isEnabled)} + + ); + } + + const { getByTestId, queryByTestId, unmount } = render( + Loading flags} + > + {getProvider({ + children: , + suspense: true, + })} + , + ); + + expect(queryByTestId("suspense-fallback")).toBeNull(); + expect(getByTestId("flag-value").textContent).toBe("true:false"); + + await waitFor(() => { + expect(requestStarted).toHaveBeenCalled(); + }); + + resolveFlags(); + + await waitFor(() => { + expect(getByTestId("flag-value").textContent).toBe("false:true"); + }); + + unmount(); + }); + + test("stops suspending if on-demand initialization fails", async () => { + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const initialize = vi + .spyOn(ReflagClient.prototype, "initialize") + .mockImplementationOnce(async function () { + (this as any).setState("initializing"); + throw new Error("init failed"); + }); + + function FlaggedContent() { + const { isEnabled, isLoading } = useFlag("abc"); + return ( + + {String(isLoading)}:{String(isEnabled)} + + ); + } + + const { getByTestId, queryByTestId, unmount } = render( + Loading flags} + > + {getProvider({ + children: , + logger, + suspense: true, + })} + , + ); + + expect(getByTestId("suspense-fallback").textContent).toBe("Loading flags"); + + await waitFor(() => { + expect(getByTestId("flag-value").textContent).toBe("false:false"); + }); + expect(queryByTestId("suspense-fallback")).toBeNull(); + expect(logger.error).toHaveBeenCalledWith( + "failed to initialize client", + expect.any(Error), + ); + + unmount(); + initialize.mockRestore(); + }); + test("finishes loading", async () => { const { result, unmount } = renderHook(() => useFlag("huddle"), { wrapper: ({ children }) => getProvider({ children }), From 5ef8a437aaa2af255d2b920606873b6298161977 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Mon, 3 Aug 2026 21:02:09 +0200 Subject: [PATCH 4/7] Use Suspense in Next.js React SDK examples --- .../dev/nextjs-bootstrap-demo/README.md | 2 +- .../dev/nextjs-bootstrap-demo/app/layout.tsx | 1 + .../dev/nextjs-bootstrap-demo/app/page.tsx | 16 +++++++++++++++- .../react-sdk/dev/nextjs-flag-demo/README.md | 2 +- .../react-sdk/dev/nextjs-flag-demo/app/page.tsx | 16 +++++++++++++++- .../nextjs-flag-demo/components/Providers.tsx | 2 ++ 6 files changed, 35 insertions(+), 4 deletions(-) diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/README.md b/packages/react-sdk/dev/nextjs-bootstrap-demo/README.md index bdf6c2122..327b865aa 100644 --- a/packages/react-sdk/dev/nextjs-bootstrap-demo/README.md +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/README.md @@ -1,6 +1,6 @@ This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). -The purpose of this project is to demonstrate usage integration with the Reflag React SDK. +The purpose of this project is to demonstrate usage integration with the Reflag React SDK using server-side bootstrapping, with `useFlag` wrapped in a React Suspense boundary for any later client-side loading states. ## Getting Started diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx index 4d6775991..7f143838c 100644 --- a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx @@ -40,6 +40,7 @@ export default async function RootLayout({ {children} diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/page.tsx b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/page.tsx index 470bb8381..a2e798950 100644 --- a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/page.tsx +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/page.tsx @@ -1,7 +1,19 @@ import Image from "next/image"; +import { Suspense } from "react"; import { Flags } from "@/components/Flags"; +function FlagsFallback() { + return ( +
+

Loading Reflag flags...

+
+        ...
+      
+
+ ); +} + export default async function Home() { return (
@@ -37,7 +49,9 @@ export default async function Home() { /> - + }> + + + ); +} + export default function Home() { return (
@@ -37,7 +49,9 @@ export default function Home() { /> - + }> + +
{ }, }} fallbackFlags={["fallback-feature"]} + offline={!publishableKey} + suspense > {children} From 6b639cfdd402baaaefacda5870075ef60eac3613 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 4 Aug 2026 11:00:11 +0200 Subject: [PATCH 5/7] Avoid unconfigured requests in bootstrap demo --- packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx index 7f143838c..6a925d9e5 100644 --- a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx @@ -40,6 +40,9 @@ export default async function RootLayout({ {children} From cbb26ad2560e170f3388b56e1c83426a2341b15d Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 4 Aug 2026 11:05:20 +0200 Subject: [PATCH 6/7] Warn when bootstrap demo keys are missing --- .../dev/nextjs-bootstrap-demo/README.md | 9 +++++++++ .../dev/nextjs-bootstrap-demo/app/client.ts | 20 +++++++++++++++++++ .../dev/nextjs-bootstrap-demo/app/layout.tsx | 4 +--- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/README.md b/packages/react-sdk/dev/nextjs-bootstrap-demo/README.md index 327b865aa..39d66a744 100644 --- a/packages/react-sdk/dev/nextjs-bootstrap-demo/README.md +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/README.md @@ -4,6 +4,15 @@ The purpose of this project is to demonstrate usage integration with the Reflag ## Getting Started +Configure both SDK keys in `.env.local`: + +```bash +REFLAG_SECRET_KEY=sec_... +REFLAG_PUBLISHABLE_KEY=pub_... +``` + +The example logs a warning and uses offline mode for the affected SDK if either key is missing. + Run the development server: ```bash diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/client.ts b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/client.ts index 1e8663f48..65bd91f99 100644 --- a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/client.ts +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/client.ts @@ -1,10 +1,28 @@ import { ReflagClient as ReflagNodeClient } from "@reflag/node-sdk"; const secretKey = process.env.REFLAG_SECRET_KEY; +export const publishableKey = process.env.REFLAG_PUBLISHABLE_KEY || ""; const offline = process.env.CI === "true" || !secretKey; declare global { var serverClient: ReflagNodeClient; + var reflagDemoEnvironmentWarningsShown: boolean | undefined; +} + +function warnAboutMissingKeys() { + if (globalThis.reflagDemoEnvironmentWarningsShown) return; + + if (!secretKey) { + console.warn( + "[Reflag demo] REFLAG_SECRET_KEY is missing; server-side flag evaluation will run in offline mode.", + ); + } + if (!publishableKey) { + console.warn( + "[Reflag demo] REFLAG_PUBLISHABLE_KEY is missing; the browser SDK will run in offline mode.", + ); + } + globalThis.reflagDemoEnvironmentWarningsShown = true; } /** @@ -13,6 +31,8 @@ declare global { * @returns The server client. */ export async function getServerClient() { + warnAboutMissingKeys(); + if (!globalThis.serverClient) { globalThis.serverClient = new ReflagNodeClient({ secretKey, diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx index 6a925d9e5..40c205fdc 100644 --- a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx @@ -4,7 +4,7 @@ import { Inter } from "next/font/google"; import "./globals.css"; import { ReflagBootstrappedProvider } from "@reflag/react-sdk"; -import { getServerClient } from "./client"; +import { getServerClient, publishableKey } from "./client"; const inter = Inter({ subsets: ["latin"] }); @@ -13,8 +13,6 @@ export const metadata: Metadata = { description: "Generated by create next app", }; -const publishableKey = process.env.REFLAG_PUBLISHABLE_KEY || ""; - export default async function RootLayout({ children, }: Readonly<{ From 0f505eaad56bc396b73ce92faf71a211a13702e6 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 4 Aug 2026 11:20:23 +0200 Subject: [PATCH 7/7] Show missing key warnings in browser --- .../dev/nextjs-bootstrap-demo/app/client.ts | 1 + .../dev/nextjs-bootstrap-demo/app/layout.tsx | 8 ++++-- .../components/EnvironmentWarnings.tsx | 28 +++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 packages/react-sdk/dev/nextjs-bootstrap-demo/components/EnvironmentWarnings.tsx diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/client.ts b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/client.ts index 65bd91f99..d7223c7e6 100644 --- a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/client.ts +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/client.ts @@ -2,6 +2,7 @@ import { ReflagClient as ReflagNodeClient } from "@reflag/node-sdk"; const secretKey = process.env.REFLAG_SECRET_KEY; export const publishableKey = process.env.REFLAG_PUBLISHABLE_KEY || ""; +export const secretKeyConfigured = Boolean(secretKey); const offline = process.env.CI === "true" || !secretKey; declare global { diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx index 40c205fdc..1d822eb13 100644 --- a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx @@ -2,9 +2,10 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; +import { EnvironmentWarnings } from "@/components/EnvironmentWarnings"; import { ReflagBootstrappedProvider } from "@reflag/react-sdk"; -import { getServerClient, publishableKey } from "./client"; +import { getServerClient, publishableKey, secretKeyConfigured } from "./client"; const inter = Inter({ subsets: ["latin"] }); @@ -35,10 +36,13 @@ export default async function RootLayout({ return ( + { + if (!secretKeyConfigured) { + console.warn( + "[Reflag demo] REFLAG_SECRET_KEY is missing; server-side flag evaluation is running in offline mode.", + ); + } + if (!publishableKeyConfigured) { + console.warn( + "[Reflag demo] REFLAG_PUBLISHABLE_KEY is missing; the browser SDK is running in offline mode.", + ); + } + }, [publishableKeyConfigured, secretKeyConfigured]); + + return null; +}