Flags in the browser.
Hooks for React, server rendering in Next.js without a flash, and which key may ship to a browser.
1
Which key goes in the browser
Anything you put in a browser bundle is public, SDK keys included. A key can read its whole config: every flag key and every clear-text rule value. So create a separate client config (for example “Web”) for flags the browser needs, with no internal rules, and keep your server config's key on the server. Use hashed comparators for lists of users.
2
React
npm install @productcraft/flags @productcraft/flags-reactimport { FlagsProvider, useFlag, useFlagDetails } from "@productcraft/flags-react";
export function App({ user }: { user: { id: string; plan: string } }) {
return (
<FlagsProvider
options={{ sdkKey: import.meta.env.VITE_FLAGS_SDK_KEY }}
user={{ identifier: user.id, custom: { plan: user.plan } }}
>
<Checkout />
</FlagsProvider>
);
}
function Checkout() {
const newCheckout = useFlag("new_checkout", false); // boolean, from the default's type
const limits = useFlag("limits", { max: 5 }); // a json setting, parsed
const banner = useFlagDetails("banner_text", "Welcome"); // value, reason, rule, revision
return newCheckout ? (
<p>
New checkout, up to {limits.max} items. {banner.value}
</p>
) : (
<p>Old checkout</p>
);
}useFlag takes its type from your default and re-renders when a new config arrives. In a browser the client relies on the HTTP cache to revalidate (no CORS preflight per poll) and pauses polling while the tab is hidden.
3
Next.js App Router, no flash
Evaluate on the server with a client that holds the same config as the browser (the client config's key) and pass its snapshot as bootstrap. The server render and the first browser render then agree, so there is no flash and no hydration mismatch; the browser's own download starts after mount.
// app/layout.tsx (a server component)
import type { ReactNode } from "react";
import { FlagsClient } from "@productcraft/flags";
import { FlagsProvider } from "@productcraft/flags-react";
import { currentUser } from "@/lib/auth";
// One client per server process, with the CLIENT config's key: the
// same config the browser downloads, so server and browser agree.
const serverFlags = new FlagsClient({ sdkKey: process.env.NEXT_PUBLIC_FLAGS_SDK_KEY! });
export default async function RootLayout({ children }: { children: ReactNode }) {
await serverFlags.waitForReady();
const user = await currentUser();
return (
<html lang="en">
<body>
<FlagsProvider
options={{ sdkKey: process.env.NEXT_PUBLIC_FLAGS_SDK_KEY! }}
bootstrap={serverFlags.snapshot().toJSON() ?? undefined}
user={{ identifier: user.id }}
>
{children}
</FlagsProvider>
</body>
</html>
);
}A server component that only needs a value can read it with the server config's key, which never reaches the browser:
// app/pricing/page.tsx (a server component)
import { FlagsClient } from "@productcraft/flags";
import { currentUser } from "@/lib/auth";
// A server config's key: this one never reaches the browser.
const flags = new FlagsClient({ sdkKey: process.env.PRODUCTCRAFT_FLAGS_SDK_KEY });
export default async function PricingPage() {
const user = await currentUser();
const annualFirst = await flags.getValue("pricing_annual_first", false, { identifier: user.id });
return <h1>{annualFirst ? "Annual plans" : "Monthly plans"}</h1>;
}4
Without React
import { FlagsClient } from "@productcraft/flags";
// A returning visitor starts from the last config they saw.
const store = {
get: (k: string) => localStorage.getItem(k),
set: (k: string, v: string) => localStorage.setItem(k, v),
};
const flags = new FlagsClient({
sdkKey: "pcf_sdk_…", // a CLIENT config's key: it is public once it ships
cache: store,
defaultUser: { identifier: "user-123" },
});
flags.on("configChanged", () => render());
const showBanner = await flags.getValue("show_banner", false);
declare function render(): void;
console.log(showBanner);