diff --git a/package.json b/package.json index aaedd7dfc..4fc0b4a9f 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "packages/sdk/react/contract-tests", "packages/sdk/react/examples/hello-react", "packages/sdk/react/examples/server-only", + "packages/sdk/react/examples/vercel-edge", "packages/sdk/react-native", "packages/sdk/react-native/example", "packages/sdk/react-native/contract-tests/entity", diff --git a/packages/sdk/react/examples/README.md b/packages/sdk/react/examples/README.md index 0d75b91da..e36894ed1 100644 --- a/packages/sdk/react/examples/README.md +++ b/packages/sdk/react/examples/README.md @@ -6,3 +6,4 @@ This directory contains example applications demonstrating the LaunchDarkly Reac |---------|-------------| | [hello-react](./hello-react/) | Minimal Vite + React app that evaluates a boolean feature flag and displays the result with real-time updates. This is the recommended starting point. | | [server-only](./server-only/) | Next.js App Router example demonstrating server-side flag evaluation with React Server Components. | +| [vercel-edge](./vercel-edge/) | Next.js App Router example using the Vercel Edge SDK to evaluate flags from Vercel Edge Config, with server-to-client bootstrap via `LDIsomorphicProvider`. | diff --git a/packages/sdk/react/examples/vercel-edge/.gitignore b/packages/sdk/react/examples/vercel-edge/.gitignore new file mode 100644 index 000000000..077d13c7b --- /dev/null +++ b/packages/sdk/react/examples/vercel-edge/.gitignore @@ -0,0 +1,43 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage +/test-results + +# next.js +/.next/ +/out/ +/.swc/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/packages/sdk/react/examples/vercel-edge/README.md b/packages/sdk/react/examples/vercel-edge/README.md new file mode 100644 index 000000000..0274cbc94 --- /dev/null +++ b/packages/sdk/react/examples/vercel-edge/README.md @@ -0,0 +1,84 @@ +# LaunchDarkly sample React + Vercel Edge application + +We've built a simple web application that demonstrates how the LaunchDarkly React SDK works with +the Vercel Edge SDK. The app evaluates feature flags using data stored in +[Vercel Edge Config](https://vercel.com/docs/edge-config/overview) and renders the result using +React Server Components. + +The Vercel SDK reads flag data from Edge Config instead of connecting to LaunchDarkly servers +directly, providing ultra-low latency flag evaluation at the edge. + +The demo shows 2 ways to use React server-side rendering: + +1. Using `createLDServerSession` and `useLDServerSession` to provide +per-request session isolation: Nested Server Components access the session through React's `cache()` +without any prop drilling. + +2. Using the `LDIsomorphicProvider` to bootstrap the browser SDK with server-evaluated flag values. +This allows the browser SDK to start immediately with real values. + +Below, you'll find the build procedure. For more comprehensive instructions, you can visit your +[Quickstart page](https://app.launchdarkly.com/quickstart#/) or the +[React SDK reference guide](https://docs.launchdarkly.com/sdk/client-side/react/react-web). + +This demo requires Node.js 18 or higher. + +## Prerequisites + +This example requires the [LaunchDarkly Vercel integration](https://vercel.com/integrations/launchdarkly) +to be configured. The integration syncs your LaunchDarkly flag data to Vercel Edge Config so that +the Vercel SDK can read it without connecting to LaunchDarkly servers. + +## Build instructions + +1. Set the `VERCEL_EDGE_CONFIG` environment variable to your Vercel Edge Config connection string. + You can find this in your Vercel project settings under Edge Config. + + ```bash + export VERCEL_EDGE_CONFIG="https://edge-config.vercel.com/ecfg_..." + ``` + +2. Set the `LD_CLIENT_SIDE_ID` environment variable to your LaunchDarkly client-side ID. + The Vercel SDK uses this to look up flag data in Edge Config, and the same value is used + to bootstrap the browser SDK. + + ```bash + export LD_CLIENT_SIDE_ID="my-client-side-id" + ``` + +3. If there is an existing boolean feature flag in your LaunchDarkly project that you want to + evaluate, set `LAUNCHDARKLY_FLAG_KEY`: + + ```bash + export LAUNCHDARKLY_FLAG_KEY="my-flag-key" + ``` + + Otherwise, `sample-feature` will be used by default. + +## Running + +On the command line, run: + +```bash +yarn dev +``` + +Then open [http://localhost:3000](http://localhost:3000) in your browser. You will see the +spec message, current context name, and a full-page background: green when the flag is on, +or grey when off. + +To simulate a different user, append the `?context=` query parameter. Each tab gets a +completely independent `LDServerSession` with its own context: + +| URL | Context | +|-----|---------| +| `http://localhost:3000/` | Sandy (example-user-key) — default | +| `http://localhost:3000/?context=sandy` | Sandy (example-user-key) | +| `http://localhost:3000/?context=jamie` | Jamie (user-jamie) | +| `http://localhost:3000/?context=alex` | Alex (user-alex) | + +If you have targeting rules in LaunchDarkly that serve different values to different user keys, +you will see different flag results for each context. + +In a production app, the user identity would come from auth tokens, cookies, or session data +instead of query parameters. diff --git a/packages/sdk/react/examples/vercel-edge/app/App.tsx b/packages/sdk/react/examples/vercel-edge/app/App.tsx new file mode 100644 index 000000000..a7fe2b2a9 --- /dev/null +++ b/packages/sdk/react/examples/vercel-edge/app/App.tsx @@ -0,0 +1,41 @@ +import { useLDServerSession } from '@launchdarkly/react-sdk/server'; + +import BootstrappedClient from './BootstrappedClient'; + +// The flag key to evaluate. Override with the LAUNCHDARKLY_FLAG_KEY environment variable. +const flagKey = process.env.LAUNCHDARKLY_FLAG_KEY || 'sample-feature'; + +export default async function App() { + // The session was stored here by createLDServerSession() in the parent page. + const session = useLDServerSession(); + + if (!session) { + return ( +

+ No LaunchDarkly session found. Ensure createLDServerSession() is called before rendering + this component. +

+ ); + } + + const flagValue = await session.boolVariation(flagKey, false); + const ctx = session.getContext() as { name?: string; key: string }; + + console.log('[LaunchDarkly] Flag evaluation:', { + flagKey, + flagValue, + context: session.getContext(), + }); + + return ( +
+

Feature flag: {flagKey}

+

Context: {ctx.name ?? ctx.key}

+

+ Server: feature flag evaluates to {String(flagValue)} (server-side + rendered). +

+ +
+ ); +} diff --git a/packages/sdk/react/examples/vercel-edge/app/BootstrappedClient.tsx b/packages/sdk/react/examples/vercel-edge/app/BootstrappedClient.tsx new file mode 100644 index 000000000..2bd3b635a --- /dev/null +++ b/packages/sdk/react/examples/vercel-edge/app/BootstrappedClient.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { useBoolVariation } from '@launchdarkly/react-sdk'; + +/** + * Client component that evaluates a flag via the bootstrapped react clientSDK. + * The LDIsomorphicProvider evaluates all flags on the server and passes them + * to the react client SDK as bootstrap data. + */ +export default function BootstrappedClient({ flagKey }: { flagKey: string }) { + const flagValue = useBoolVariation(flagKey, false); + + return ( +

+ Client: feature flag evaluates to {String(flagValue)} (bootstrapped). +

+ ); +} diff --git a/packages/sdk/react/examples/vercel-edge/app/layout.tsx b/packages/sdk/react/examples/vercel-edge/app/layout.tsx new file mode 100644 index 000000000..e3006e4d8 --- /dev/null +++ b/packages/sdk/react/examples/vercel-edge/app/layout.tsx @@ -0,0 +1,13 @@ +import './styles.css'; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/packages/sdk/react/examples/vercel-edge/app/page.tsx b/packages/sdk/react/examples/vercel-edge/app/page.tsx new file mode 100644 index 000000000..97bedb44a --- /dev/null +++ b/packages/sdk/react/examples/vercel-edge/app/page.tsx @@ -0,0 +1,81 @@ +import { createClient } from '@vercel/edge-config'; + +import { createLDServerSession, LDIsomorphicProvider } from '@launchdarkly/react-sdk/server'; +import { init } from '@launchdarkly/vercel-server-sdk'; + +import App from './App'; + +// The Vercel SDK reads flag data from Vercel Edge Config instead of connecting +// to LaunchDarkly servers directly, so it uses the client-side ID — not the +// server SDK key. +const clientSideId = process.env.LD_CLIENT_SIDE_ID || ''; +const edgeConfig = process.env.VERCEL_EDGE_CONFIG; +const edgeConfigClient = edgeConfig ? createClient(edgeConfig) : null; +const ldBaseClient = clientSideId && edgeConfigClient ? init(clientSideId, edgeConfigClient) : null; + +// Select via ?context=sandy|jamie|alex (defaults to sandy). +const PRESET_CONTEXTS = { + sandy: { kind: 'user' as const, key: 'example-user-key', name: 'Sandy' }, + jamie: { kind: 'user' as const, key: 'user-jamie', name: 'Jamie' }, + alex: { kind: 'user' as const, key: 'user-alex', name: 'Alex' }, +}; + +export default async function Home({ + searchParams, +}: { + searchParams: Promise<{ context?: string }>; +}) { + if (!edgeConfigClient) { + return ( +
+

+ Vercel Edge Config is required: set the VERCEL_EDGE_CONFIG environment variable and try + again. +

+
+ ); + } + + if (!ldBaseClient) { + return ( +
+

+ LaunchDarkly client-side ID is required: set the LD_CLIENT_SIDE_ID environment variable + and try again. +

+
+ ); + } + + try { + await ldBaseClient.waitForInitialization(); + } catch { + return ( +
+

+ SDK failed to initialize. Please check your Edge Config connection and LaunchDarkly + client-side ID for any issues. +

+
+ ); + } + + // Resolve the evaluation context from the ?context= query parameter. + // In a real app this would come from authentication tokens, cookies, or session data. + const { context: contextKey = 'sandy' } = await searchParams; + const context = + PRESET_CONTEXTS[contextKey as keyof typeof PRESET_CONTEXTS] ?? PRESET_CONTEXTS.sandy; + + // Create a per-request session bound to this user's context. + // createLDServerSession also stores the session in React's cache() so any Server Component + // in this render tree can retrieve it via useLDServerSession(). + const session = createLDServerSession(ldBaseClient, context); + + // Wrap the app with LDIsomorphicProvider to bootstrap the browser SDK with + // server-evaluated flag values. + return ( + + + + ); +} diff --git a/packages/sdk/react/examples/vercel-edge/app/styles.css b/packages/sdk/react/examples/vercel-edge/app/styles.css new file mode 100644 index 000000000..699ead1da --- /dev/null +++ b/packages/sdk/react/examples/vercel-edge/app/styles.css @@ -0,0 +1,50 @@ +*, +*::before, +*::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +/* ── Error states (page.tsx) ───────────────────────── */ +.error { + min-height: 100vh; + background-color: #373841; + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; +} + +/* ── App ──────────────────────────────────────────── */ +.no-session { + color: #ff6b6b; + font-family: monospace; +} + +.app { + min-height: 100vh; + color: #ffffff; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: calc(10px + 2vmin); + padding: 2rem; + gap: 1rem; +} + +.app--on { + background-color: #00844b; +} +.app--off { + background-color: #373841; +} + +.context, +.flag-key { + font-size: 0.7em; + opacity: 0.75; +} diff --git a/packages/sdk/react/examples/vercel-edge/e2e/verify.spec.ts b/packages/sdk/react/examples/vercel-edge/e2e/verify.spec.ts new file mode 100644 index 000000000..e11ffd5bb --- /dev/null +++ b/packages/sdk/react/examples/vercel-edge/e2e/verify.spec.ts @@ -0,0 +1,9 @@ +import { expect, test } from '@playwright/test'; + +test('feature flag evaluates to true', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByText('feature flag evaluates to true', { exact: false })).toHaveCount(2, { + timeout: 10000, + }); +}); diff --git a/packages/sdk/react/examples/vercel-edge/next.config.ts b/packages/sdk/react/examples/vercel-edge/next.config.ts new file mode 100644 index 000000000..1af3e01c2 --- /dev/null +++ b/packages/sdk/react/examples/vercel-edge/next.config.ts @@ -0,0 +1,14 @@ +import type { NextConfig } from 'next'; +import path from 'path'; + +const nextConfig: NextConfig = { + // We suppress strict mode for this example to make the render log only one + // evaluation. While it is correct to double evaluate with strict mode on, that + // behavior is not immediately obvious to some users. + reactStrictMode: false, + turbopack: { + root: path.resolve(__dirname, '../../../../..'), + }, +}; + +export default nextConfig; diff --git a/packages/sdk/react/examples/vercel-edge/package.json b/packages/sdk/react/examples/vercel-edge/package.json new file mode 100644 index 000000000..b15651b95 --- /dev/null +++ b/packages/sdk/react/examples/vercel-edge/package.json @@ -0,0 +1,26 @@ +{ + "name": "@internal/react-sdk-example-vercel-edge", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint", + "test": "playwright test" + }, + "dependencies": { + "@launchdarkly/react-sdk": "0.1.1", + "@launchdarkly/vercel-server-sdk": "workspace:^", + "@vercel/edge-config": "^1.1.0", + "next": "^16", + "react": "^19", + "react-dom": "^19" + }, + "devDependencies": { + "@playwright/test": "^1.49.1", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "typescript": "^5" + } +} diff --git a/packages/sdk/react/examples/vercel-edge/playwright.config.ts b/packages/sdk/react/examples/vercel-edge/playwright.config.ts new file mode 100644 index 000000000..23e75b9d1 --- /dev/null +++ b/packages/sdk/react/examples/vercel-edge/playwright.config.ts @@ -0,0 +1,17 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + timeout: 30_000, + reporter: [['list']], + use: { + baseURL: 'http://localhost:3000', + }, + webServer: { + command: 'yarn start', + port: 3000, + timeout: 30_000, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/packages/sdk/react/examples/vercel-edge/tsconfig.json b/packages/sdk/react/examples/vercel-edge/tsconfig.json new file mode 100644 index 000000000..d45e92c43 --- /dev/null +++ b/packages/sdk/react/examples/vercel-edge/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules", "e2e", "playwright.config.ts"] +}