diff --git a/.eslintrc.js b/.eslintrc.js
index 5e73909ae5..af5ecdab29 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -13,6 +13,7 @@ module.exports = {
'**/dist/**',
'**/vercel/examples/**',
'**/react-native/example/**',
+ '**/react-universal/example/**',
'**/fromExternal/**',
],
rules: {
@@ -41,10 +42,10 @@ module.exports = {
'import/no-cycle': 'error',
'import/no-useless-path-segments': 'error',
'import/no-duplicates': 'error',
+ 'import/prefer-default-export': 'off',
'jest/no-disabled-tests': 'warn',
'jest/no-focused-tests': 'error',
'jest/no-identical-title': 'error',
- // 'jest/prefer-to-have-length': 'warn',
'jest/valid-expect': 'error',
},
globals: {
diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml
index 2bc51a5a0d..99a9416903 100644
--- a/.github/workflows/release-please.yml
+++ b/.github/workflows/release-please.yml
@@ -343,7 +343,6 @@ jobs:
permissions:
id-token: write
contents: write
- # HACK: react-universal sdk is not ready for release yet.
if: false #${{ needs.release-please.outputs.package-react-universal-release == 'true' }}
steps:
- uses: actions/checkout@v4
diff --git a/package.json b/package.json
index 4952e93a73..5e24af01dc 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"packages/sdk/react-native",
"packages/sdk/react-native/example",
"packages/sdk/react-universal",
+ "packages/sdk/react-universal/example",
"packages/sdk/vercel",
"packages/sdk/akamai-base",
"packages/sdk/akamai-base/example",
diff --git a/packages/sdk/react-native/example/README.md b/packages/sdk/react-native/example/README.md
index b05c3db2c2..0cd23e7018 100644
--- a/packages/sdk/react-native/example/README.md
+++ b/packages/sdk/react-native/example/README.md
@@ -14,7 +14,7 @@ yarn && yarn build
MOBILE_KEY=abcdef12456
```
-3. Replace `dev-test-flag` with your flag key in `src/welcome.tsx`.
+3. Replace `my-boolean-flag-1` with your flag key in `src/welcome.tsx`.
4. Run the app:
diff --git a/packages/sdk/react-universal/.eslintignore b/packages/sdk/react-universal/.eslintignore
new file mode 100644
index 0000000000..33a9488b16
--- /dev/null
+++ b/packages/sdk/react-universal/.eslintignore
@@ -0,0 +1 @@
+example
diff --git a/packages/sdk/react-universal/README.md b/packages/sdk/react-universal/README.md
index d3523cc570..60c26f754e 100644
--- a/packages/sdk/react-universal/README.md
+++ b/packages/sdk/react-universal/README.md
@@ -9,62 +9,49 @@
> [!CAUTION]
> This library is a beta version and should not be considered ready for production use while this message is visible.
-> **An idiomatic LaunchDarkly SDK which supports RSC, server side rendering and bootstrapping** :clap:
+## Features
-This SDK supports:
+- Supports both React Server Components and Client Components
+- Idiomatic server side rendering
+- Bootstrapping out of the box
-- React Server Components
-- Server side rendering
-- Bootstrapping
-
-## Installation
+## Install
```shell
# npm
-npm i @launchdarkly/react-universal-sdk --save-dev
+npm i @launchdarkly/react-universal-sdk
# yarn
yarn add -D @launchdarkly/react-universal-sdk
```
-### Server API
+## Server API
-- `initNodeSdk` - Initializes the Node SDK on server startup using the [instrumentation hook](https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation)
+- `initNodeSdk` - Initializes the Node SDK on startup.
-- `getBootstrap` - Returns a json suitable for bootstrapping the js sdk.
+- `getBootstrap` - Produces suitable bootstrap the js sdk.
-- `useLDClientRsc` - Use this to get an ldClient for Server Components.
+- `useLDClientRsc` - Gets a suitable ld client for Server Components.
-### Client API
+## Client API
- `LDProvider` - The react context provider.
-- `useLDClient` - Use this to get an ldClient for Client Components.
+- `useLDClient` - Gets a suitable ld client for Client Components.
## Usage
-1. Enable [instrumentationHook](https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation) in `next.config.mjs`:
+1. On server start, initialize the Node Server SDK. If you are using NextJS App Router, do this in `instrumentation.ts`. You'll need to enable the [instrumentationHook](https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation):
```ts
-/** @type {import('next').NextConfig} */
-const nextConfig = {
- experimental: { instrumentationHook: true },
-};
-
-export default nextConfig;
-```
-
-2. Create a new file `instrumentation.ts` at the root of your project. This will initialize the Node Server SDK.
-
-```ts
-import { initNodeSdk } from '@/ld/server';
+import { initNodeSdk } from '@launchdarkly/react-universal-sdk/server';
export async function register() {
await initNodeSdk();
}
```
-3. In your root layout component, render the `LDProvider` using your `LDContext` and `bootstrap`:
+2. At the application root, render the `LDProvider` with your `LDContext` and `bootstrap`. In App Router, do this in the root layout:
```tsx
export default async function RootLayout({
@@ -91,42 +78,38 @@ export default async function RootLayout({
}
```
-4. Server Components must use the async `useLDClientRsc` function:
+3. Server Components must use the async `useLDClientRsc` function:
```tsx
// You should use your own getLDContext function.
import { getLDContext } from '@/app/utils';
-import { useLDClientRsc } from '@/ld/server';
-export default async function Page() {
+import { useLDClientRsc } from '@launchdarkly/react-universal-sdk/server';
+
+export default async function ServerComponent() {
const ldc = await useLDClientRsc(getLDContext());
- const flagValue = ldc.variation('dev-test-flag');
+ const flagValue = ldc.variation('my-boolean-flag-1');
- return (
-
- Server Component: {flagValue.toString()}
-
- );
+ return <>Server Component: {flagValue.toString()}>;
}
```
-5. Client Components must use the `useLDClient` hook:
+Client Components must use the `useLDClient` hook:
```tsx
'use client';
-import { useLDClient } from '@/ld/client';
+import { useLDClient } from '@launchdarkly/react-universal-sdk/client';
-export default function LDButton() {
+export default function ClientComponent() {
const ldc = useLDClient();
- const flagValue = ldc.variation('dev-test-flag');
+ const flagValue = ldc.variation('my-boolean-flag-1');
return
Client Component: {flagValue.toString()}
;
}
```
-You will see both components are rendered on the server (view source on your browser). However, only Client Components
-will respond to live changes.
+You will see both Server and Client Components are rendered on the server (view source on your browser). However, only Client Components will respond to live changes because Server Components are excluded from the client bundle.
## Verifying SDK build provenance with the SLSA framework
diff --git a/packages/sdk/react-universal/example/.eslintrc.js b/packages/sdk/react-universal/example/.eslintrc.js
new file mode 100644
index 0000000000..64ec2a2361
--- /dev/null
+++ b/packages/sdk/react-universal/example/.eslintrc.js
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: ['plugin:@next/next/recommended'],
+};
diff --git a/packages/sdk/react-universal/example/.example.env.local b/packages/sdk/react-universal/example/.example.env.local
new file mode 100644
index 0000000000..bb3d148b61
--- /dev/null
+++ b/packages/sdk/react-universal/example/.example.env.local
@@ -0,0 +1,4 @@
+# Example only - Do not commit
+
+LD_SDK_KEY=''
+NEXT_PUBLIC_LD_CLIENT_SIDE_ID=''
diff --git a/packages/sdk/react-universal/example/.gitignore b/packages/sdk/react-universal/example/.gitignore
new file mode 100644
index 0000000000..4a8ccb098d
--- /dev/null
+++ b/packages/sdk/react-universal/example/.gitignore
@@ -0,0 +1,38 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+.yarn/install-state.gz
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# local env files
+.env*.local
+.env
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
+.idea
diff --git a/packages/sdk/react-universal/example/README.md b/packages/sdk/react-universal/example/README.md
index 310ac78353..229915b454 100644
--- a/packages/sdk/react-universal/example/README.md
+++ b/packages/sdk/react-universal/example/README.md
@@ -1,25 +1,35 @@
-# LaunchDarkly Universal SDK example
-
> [!IMPORTANT]
> This is an experimental project to demonstrate the use of LaunchDarkly with Next.js App Router.
>
> This is designed for the App Router. Pages router is not supported.
-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) using App Router.
+This example app uses the LaunchDarkly React Universal SDK. It features:
-## Quickstart
+- Server side rendering with both Server Components and Client Components.
+- A Client Component example in [app/components/helloClientComponent.tsx](https://github.com/launchdarkly/js-core/tree/main/packages/sdk/react-universal/example/app/components/helloClientComponent.tsx)
+- A Server Component (RSC) example in [app/components/helloServerComponent.tsx](https://github.com/launchdarkly/js-core/tree/main/packages/sdk/react-universal/example/app/components/helloServerComponent.tsx)
+- Out of the box bootstrapping.
-To run this project:
+This is a [Next.js](https://nextjs.org/) project created with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) using App Router.
+
+## Quickstart
-1. Create an .env file at repo root.
-2. Add your SDK key and client-side ID:
+1. Rename `.example.env.local` to `.env.local` and use your LaunchDarkly SDK keys:
```dotenv
-LD_SDK_KEY=sdk-***
-NEXT_PUBLIC_LD_CLIENT_SIDE_ID=***
+LD_SDK_KEY=''
+NEXT_PUBLIC_LD_CLIENT_SIDE_ID=''
```
-3. Replace `dev-test-flag` with your own flags in `app.tsx` and `LDButton.tsx`.
-4. `yarn && yarn dev`
+2. Either create `my-boolean-flag-1` in your LaunchDarkly environment or replace with your own flag in [helloClientComponent.tsx](https://github.com/launchdarkly/js-core/tree/main/packages/sdk/react-universal/example/app/components/helloClientComponent.tsx) and [helloServerComponent.tsx](https://github.com/launchdarkly/js-core/tree/main/packages/sdk/react-universal/example/app/components/helloServerComponent.tsx).
+
+3. Finally:
+
+```bash
+npm i && npm run dev
+
+# or
+yarn && yarn dev
+```
-You should see your flag value rendered in the browser.
+You will see both Server and Client Components are rendered on the server (view source on your browser). However, only Client Components will respond to live changes because Server Components are excluded from the client bundle.
diff --git a/packages/sdk/react-universal/example/app/components/helloClientComponent.tsx b/packages/sdk/react-universal/example/app/components/helloClientComponent.tsx
new file mode 100644
index 0000000000..ebdb4d25b6
--- /dev/null
+++ b/packages/sdk/react-universal/example/app/components/helloClientComponent.tsx
@@ -0,0 +1,25 @@
+'use client';
+
+import { useLDClient } from '@launchdarkly/react-universal-sdk/client';
+
+export default function HelloClientComponent() {
+ const ldc = useLDClient();
+
+ // WARNING: Using the ldClient to evaluate flags directly like this in prod
+ // can result in high event volumes. This example is contrived and is meant for
+ // demo purposes only. The recommended way is to utilise the `useVariation` hooks
+ // which should be supported soon.
+ const flagValue = ldc.variation('my-boolean-flag-1');
+
+ return (
+
+
+
+ {flagValue
+ ? 'This flag is evaluating True running Client-Side JavaScript'
+ : 'This flag is evaluating False running Client-Side JavaScript'}
+
+
+
+ );
+}
diff --git a/packages/sdk/react-universal/example/app/components/helloIdentify.tsx b/packages/sdk/react-universal/example/app/components/helloIdentify.tsx
new file mode 100644
index 0000000000..a5a540ec7b
--- /dev/null
+++ b/packages/sdk/react-universal/example/app/components/helloIdentify.tsx
@@ -0,0 +1,50 @@
+'use client';
+
+import { useState } from 'react';
+import { useCookies } from 'react-cookie';
+
+import type { JSSdk } from '@launchdarkly/react-universal-sdk';
+import { useLDClient } from '@launchdarkly/react-universal-sdk/client';
+
+export default function HelloIdentify() {
+ const ldc = useLDClient();
+ const [_, setCookie] = useCookies(['ld']);
+ const [contextKey, setContextKey] = useState('');
+
+ // WARNING: Using the ldClient to evaluate flags directly like this in prod
+ // can result in high event volumes. This example is contrived and is meant for
+ // demo purposes only. The recommended way is to utilise the `useVariation` hooks
+ // which should be supported soon.
+ const flagValue = ldc.variation('my-boolean-flag-1');
+
+ function onClickLogin() {
+ const context = { kind: 'user', key: contextKey };
+ (ldc as JSSdk).identify(context).then(() => {
+ console.log('identify successful, persisting to cookies');
+ setCookie('ld', context);
+ });
+ }
+
+ return (
+
+
+
+ {flagValue
+ ? 'This flag is evaluating True running Client-Side JavaScript'
+ : 'This flag is evaluating False running Client-Side JavaScript'}
+
+ To evaluate the flags below, create a boolean flag with a key of{' '}
+ my-boolean-flag-1 with the Client-SDK
+ switch enabled. This flag will be evaluated by both the client SDK and server SDKs.
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/sdk/react-universal/example/app/page.tsx b/packages/sdk/react-universal/example/app/page.tsx
new file mode 100644
index 0000000000..23923fbbf7
--- /dev/null
+++ b/packages/sdk/react-universal/example/app/page.tsx
@@ -0,0 +1,31 @@
+import Link from 'next/link';
+
+import HelloClientComponent from './components/helloClientComponent';
+import HelloServerComponent from './components/helloServerComponent';
+
+export default async function Page() {
+ return (
+
+
+
+
Hello From LaunchDarkly!
+
+ This is a Next.js 14 template with LaunchDarkly
+
+
+ To evaluate the flags below, create a boolean flag with a key of{' '}
+ my-boolean-flag-1 with the Client-SDK
+ switch enabled. This flag will be evaluated by both the client SDK and server SDKs.
+
+
+
+
+
+
+ Login Page
+
+
+
+
+ );
+}
diff --git a/packages/sdk/react-universal/example/app/utils/getLDContext.ts b/packages/sdk/react-universal/example/app/utils/getLDContext.ts
new file mode 100644
index 0000000000..14b0d72d01
--- /dev/null
+++ b/packages/sdk/react-universal/example/app/utils/getLDContext.ts
@@ -0,0 +1,32 @@
+import { cookies } from 'next/headers';
+
+import type { LDContext } from '@launchdarkly/node-server-sdk';
+import { isServer } from '@launchdarkly/react-universal-sdk';
+
+const anonymous: LDContext = { kind: 'user', key: 'anon-key', anonymous: true };
+
+/**
+ * This is an example of how you can source your LDContext. You may also
+ * retrieve it from a database or from request headers.
+ *
+ * This example looks for an LDContext in a server cookie called 'ld'.
+ *
+ * @param def The default context if none is found in cookies. As final
+ * fallback, anonymous is returned.
+ *
+ */
+export function getLDContext(def?: LDContext) {
+ let context = def ?? anonymous;
+
+ if (isServer) {
+ const ld = cookies().get('ld');
+ if (!ld) {
+ console.log(`*** no cookie, defaulting to ${JSON.stringify(context)} ***`);
+ } else {
+ console.log(`*** found cookie ${JSON.stringify(ld.value)} ***`);
+ context = JSON.parse(ld.value);
+ }
+ }
+
+ return context;
+}
diff --git a/packages/sdk/react-universal/example/app/utils/index.ts b/packages/sdk/react-universal/example/app/utils/index.ts
new file mode 100644
index 0000000000..2acd95eef6
--- /dev/null
+++ b/packages/sdk/react-universal/example/app/utils/index.ts
@@ -0,0 +1 @@
+export * from './getLDContext';
diff --git a/packages/sdk/react-universal/example/instrumentation.ts b/packages/sdk/react-universal/example/instrumentation.ts
new file mode 100644
index 0000000000..d7ea2aa3a7
--- /dev/null
+++ b/packages/sdk/react-universal/example/instrumentation.ts
@@ -0,0 +1,5 @@
+import { initNodeSdk } from '@launchdarkly/react-universal-sdk/server';
+
+export async function register() {
+ await initNodeSdk();
+}
diff --git a/packages/sdk/react-universal/example/next.config.mjs b/packages/sdk/react-universal/example/next.config.mjs
new file mode 100644
index 0000000000..925f1dd903
--- /dev/null
+++ b/packages/sdk/react-universal/example/next.config.mjs
@@ -0,0 +1,6 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ experimental: { instrumentationHook: true },
+};
+
+export default nextConfig;
diff --git a/packages/sdk/react-universal/example/package.json b/packages/sdk/react-universal/example/package.json
new file mode 100644
index 0000000000..64b67d8ac9
--- /dev/null
+++ b/packages/sdk/react-universal/example/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "ld-nextjs",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint",
+ "prettier": "npx prettier --write \"**/*.{js,ts,tsx,json,yaml,yml,md,mjs}\" --log-level warn"
+ },
+ "dependencies": {
+ "@launchdarkly/react-universal-sdk": "workspace:^",
+ "next": "^14.2.4",
+ "react": "^18",
+ "react-cookie": "^7.1.4",
+ "react-dom": "^18"
+ },
+ "devDependencies": {
+ "@next/eslint-plugin-next": "^14.2.4",
+ "@trivago/prettier-plugin-sort-imports": "^4.3.0",
+ "@types/node": "^20",
+ "@types/react": "^18",
+ "@types/react-dom": "^18",
+ "autoprefixer": "^10.0.1",
+ "eslint": "^8",
+ "eslint-config-next": "14.1.0",
+ "postcss": "^8",
+ "prettier": "^3.3.0",
+ "tailwindcss": "^3.3.0",
+ "typescript": "^5"
+ }
+}
diff --git a/packages/sdk/react-universal/example/postcss.config.js b/packages/sdk/react-universal/example/postcss.config.js
new file mode 100644
index 0000000000..12a703d900
--- /dev/null
+++ b/packages/sdk/react-universal/example/postcss.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/packages/sdk/react-universal/example/public/fonts/Audimat3000-Regulier.otf b/packages/sdk/react-universal/example/public/fonts/Audimat3000-Regulier.otf
new file mode 100644
index 0000000000..5c73a433e9
Binary files /dev/null and b/packages/sdk/react-universal/example/public/fonts/Audimat3000-Regulier.otf differ
diff --git a/packages/sdk/react-universal/example/public/next.svg b/packages/sdk/react-universal/example/public/next.svg
new file mode 100644
index 0000000000..5174b28c56
--- /dev/null
+++ b/packages/sdk/react-universal/example/public/next.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/sdk/react-universal/example/public/vercel.svg b/packages/sdk/react-universal/example/public/vercel.svg
new file mode 100644
index 0000000000..d2f8422273
--- /dev/null
+++ b/packages/sdk/react-universal/example/public/vercel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/sdk/react-universal/example/tailwind.config.ts b/packages/sdk/react-universal/example/tailwind.config.ts
new file mode 100644
index 0000000000..36bb7e4164
--- /dev/null
+++ b/packages/sdk/react-universal/example/tailwind.config.ts
@@ -0,0 +1,22 @@
+import type { Config } from 'tailwindcss';
+
+const config: Config = {
+ content: [
+ './pages/**/*.{js,ts,jsx,tsx,mdx}',
+ './components/**/*.{js,ts,jsx,tsx,mdx}',
+ './app/**/*.{js,ts,jsx,tsx,mdx}',
+ ],
+ theme: {
+ extend: {
+ fontFamily: {
+ audimat: ['Audimat'],
+ },
+ backgroundImage: {
+ 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
+ 'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
+ },
+ },
+ },
+ plugins: [],
+};
+export default config;
diff --git a/packages/sdk/react-universal/example/tsconfig.json b/packages/sdk/react-universal/example/tsconfig.json
new file mode 100644
index 0000000000..86824be7ae
--- /dev/null
+++ b/packages/sdk/react-universal/example/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "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": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/sdk/react-universal/package.json b/packages/sdk/react-universal/package.json
index dba75b93e2..f93f2ff285 100644
--- a/packages/sdk/react-universal/package.json
+++ b/packages/sdk/react-universal/package.json
@@ -21,6 +21,14 @@
".": {
"types": "./dist/src/index.d.ts",
"default": "./dist/src/index.js"
+ },
+ "./client": {
+ "types": "./dist/src/client/index.d.ts",
+ "default": "./dist/src/client/index.js"
+ },
+ "./server": {
+ "types": "./dist/src/server/index.d.ts",
+ "default": "./dist/src/server/index.js"
}
},
"files": [
@@ -31,7 +39,7 @@
"build": "yarn clean && tsc",
"tsw": "yarn tsc --watch",
"start": "rimraf dist && yarn tsw",
- "lint": "eslint . --ext .ts",
+ "lint": "eslint . --ext .ts,.tsx",
"prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../.prettierignore",
"test": "NODE_OPTIONS=\"--experimental-vm-modules --no-warnings\" jest --ci --runInBand",
"coverage": "yarn test --coverage",
@@ -40,6 +48,7 @@
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
"@types/jest": "^29.5.0",
+ "@types/react": "^18",
"@typescript-eslint/eslint-plugin": "^6.20.0",
"@typescript-eslint/parser": "^6.20.0",
"eslint": "^8.45.0",
@@ -51,9 +60,17 @@
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
+ "react": "^18",
"rimraf": "^5.0.1",
"ts-jest": "^29.1.0",
"typedoc": "0.25.0",
"typescript": "5.1.6"
+ },
+ "dependencies": {
+ "@launchdarkly/node-server-sdk": "^9.4.6",
+ "launchdarkly-js-client-sdk": "^3.4.0"
+ },
+ "peerDependencies": {
+ "react": "*"
}
}
diff --git a/packages/sdk/react-universal/src/client/LDProvider.tsx b/packages/sdk/react-universal/src/client/LDProvider.tsx
new file mode 100644
index 0000000000..af8a319390
--- /dev/null
+++ b/packages/sdk/react-universal/src/client/LDProvider.tsx
@@ -0,0 +1,51 @@
+'use client';
+
+import { initialize, type LDOptions } from 'launchdarkly-js-client-sdk';
+import { type PropsWithChildren, useEffect, useState } from 'react';
+import React from 'react';
+
+import type { LDContext, LDFlagSet } from '@launchdarkly/node-server-sdk';
+
+import { isServer } from '../isServer';
+import type { JSSdk } from '../types';
+import { Provider, type ReactContext } from './reactContext';
+import { setupListeners } from './setupListeners';
+
+type LDProps = {
+ clientSideID: string;
+ context: LDContext;
+ options?: LDOptions;
+};
+
+/**
+ * This is the LaunchDarkly Provider which uses the React context api to store
+ * and pass data to child components through hooks.
+ *
+ * @param clientSideID Your LaunchDarkly client side id.
+ * @param context The LDContext for evaluation.
+ * @param options Configuration options for the js sdk. See {@link LDOptions}.
+ * @param children Your react application to be rendered.
+ */
+export const LDProvider = ({
+ clientSideID,
+ context,
+ options,
+ children,
+}: PropsWithChildren) => {
+ let jsSdk: JSSdk = undefined as any;
+ if (!isServer) {
+ jsSdk = initialize(clientSideID ?? '', context, options);
+ }
+
+ const [state, setState] = useState({
+ jsSdk,
+ context,
+ bootstrap: options?.bootstrap as LDFlagSet,
+ });
+
+ useEffect(() => {
+ setupListeners(setState, jsSdk);
+ }, []);
+
+ return {children};
+};
diff --git a/packages/sdk/react-universal/src/client/hooks/index.ts b/packages/sdk/react-universal/src/client/hooks/index.ts
new file mode 100644
index 0000000000..918a4e1f76
--- /dev/null
+++ b/packages/sdk/react-universal/src/client/hooks/index.ts
@@ -0,0 +1,3 @@
+// TODO: Implement variation and typed variation hooks.
+
+export * from './useLDClient';
diff --git a/packages/sdk/react-universal/src/client/hooks/useLDClient.ts b/packages/sdk/react-universal/src/client/hooks/useLDClient.ts
new file mode 100644
index 0000000000..4b6c80c34e
--- /dev/null
+++ b/packages/sdk/react-universal/src/client/hooks/useLDClient.ts
@@ -0,0 +1,15 @@
+import { useContext } from 'react';
+
+import { LDClientRsc } from '../../ldClientRsc';
+import { context as reactContext, type ReactContext } from '../reactContext';
+
+/**
+ * Only useLDClient with Client Components.
+ */
+export const useLDClient = () => {
+ const { context, bootstrap, jsSdk } = useContext(reactContext);
+
+ // TODO: memo construction of LDClientRsc
+ // On the browser, always use the js sdk.
+ return jsSdk ?? new LDClientRsc(context, bootstrap);
+};
diff --git a/packages/sdk/react-universal/src/client/index.ts b/packages/sdk/react-universal/src/client/index.ts
new file mode 100644
index 0000000000..619a160425
--- /dev/null
+++ b/packages/sdk/react-universal/src/client/index.ts
@@ -0,0 +1,5 @@
+import type { ReactContext } from './reactContext';
+
+export * from './LDProvider';
+export * from './hooks';
+export type { ReactContext };
diff --git a/packages/sdk/react-universal/src/client/reactContext.ts b/packages/sdk/react-universal/src/client/reactContext.ts
new file mode 100644
index 0000000000..e7892205ae
--- /dev/null
+++ b/packages/sdk/react-universal/src/client/reactContext.ts
@@ -0,0 +1,23 @@
+'use client';
+
+import { createContext } from 'react';
+
+import type { LDContext, LDFlagSet } from '@launchdarkly/node-server-sdk';
+
+import type { JSSdk } from '../types';
+
+export type ReactContext = {
+ jsSdk?: JSSdk;
+ context: LDContext;
+ bootstrap: LDFlagSet;
+};
+
+export const context = createContext({
+ jsSdk: undefined as any,
+ context: {} as any,
+ bootstrap: undefined as any,
+});
+
+const { Provider, Consumer } = context;
+
+export { Provider, Consumer };
diff --git a/packages/sdk/react-universal/src/client/setupListeners.ts b/packages/sdk/react-universal/src/client/setupListeners.ts
new file mode 100644
index 0000000000..04a18749a8
--- /dev/null
+++ b/packages/sdk/react-universal/src/client/setupListeners.ts
@@ -0,0 +1,10 @@
+import type { Dispatch, SetStateAction } from 'react';
+
+import type { JSSdk } from '../types';
+import type { ReactContext } from './reactContext';
+
+export const setupListeners = (setState: Dispatch>, jsSdk: JSSdk) => {
+ jsSdk.on('change', () => {
+ setState((prevState) => ({ ...prevState, jsSdk }));
+ });
+};
diff --git a/packages/sdk/react-universal/src/index.ts b/packages/sdk/react-universal/src/index.ts
index e69de29bb2..8d1a713dfb 100644
--- a/packages/sdk/react-universal/src/index.ts
+++ b/packages/sdk/react-universal/src/index.ts
@@ -0,0 +1,3 @@
+export * from './ldClientRsc';
+export * from './isServer';
+export * from './types';
diff --git a/packages/sdk/react-universal/src/isServer.ts b/packages/sdk/react-universal/src/isServer.ts
new file mode 100644
index 0000000000..df6bd5693a
--- /dev/null
+++ b/packages/sdk/react-universal/src/isServer.ts
@@ -0,0 +1 @@
+export const isServer = typeof window === 'undefined';
diff --git a/packages/sdk/react-universal/src/ldClientRsc.ts b/packages/sdk/react-universal/src/ldClientRsc.ts
new file mode 100644
index 0000000000..51398a249a
--- /dev/null
+++ b/packages/sdk/react-universal/src/ldClientRsc.ts
@@ -0,0 +1,30 @@
+import type { LDContext, LDFlagSet, LDFlagValue } from '@launchdarkly/node-server-sdk';
+
+import { isServer } from './isServer';
+import type { JSSdk } from './types';
+
+/**
+ * A partial ldClient suitable for RSC and server side rendering.
+ */
+export class LDClientRsc implements Partial {
+ constructor(
+ private readonly ldContext: LDContext,
+ private readonly bootstrap: LDFlagSet,
+ ) {}
+
+ allFlags(): LDFlagSet {
+ return this.bootstrap;
+ }
+
+ getContext(): LDContext {
+ return this.ldContext;
+ }
+
+ variation(key: string, defaultValue?: LDFlagValue): LDFlagValue {
+ if (isServer) {
+ // On the server during ssr, call variation for analytics purposes.
+ global.nodeSdk.variation(key, this.ldContext, defaultValue).then(/* ignore */);
+ }
+ return this.bootstrap[key] ?? defaultValue;
+ }
+}
diff --git a/packages/sdk/react-universal/src/server/getBootstrap.ts b/packages/sdk/react-universal/src/server/getBootstrap.ts
new file mode 100644
index 0000000000..5c985120ae
--- /dev/null
+++ b/packages/sdk/react-universal/src/server/getBootstrap.ts
@@ -0,0 +1,13 @@
+import type { LDContext } from '@launchdarkly/node-server-sdk';
+
+/**
+ * Returns a json suitable for bootstrapping the js sdk.
+
+ * @param context The LDContext to generate bootstrap for.
+ *
+ * @returns A promise which resolves to a json object suitable for bootstrapping the js sdk.
+ */
+export const getBootstrap = async (context: LDContext) => {
+ const allFlags = await global.nodeSdk.allFlagsState(context);
+ return allFlags?.toJSON();
+};
diff --git a/packages/sdk/react-universal/src/server/index.ts b/packages/sdk/react-universal/src/server/index.ts
new file mode 100644
index 0000000000..5d75eba27f
--- /dev/null
+++ b/packages/sdk/react-universal/src/server/index.ts
@@ -0,0 +1,3 @@
+export * from './initNodeSdk';
+export * from './useLDClientRsc';
+export * from './getBootstrap';
diff --git a/packages/sdk/react-universal/src/server/initNodeSdk.ts b/packages/sdk/react-universal/src/server/initNodeSdk.ts
new file mode 100644
index 0000000000..7eec079692
--- /dev/null
+++ b/packages/sdk/react-universal/src/server/initNodeSdk.ts
@@ -0,0 +1,24 @@
+/**
+ * Initializes the Node SDK on server startup.
+ *
+ * Run this once in the instrumentation hook to set up the Node SDK.
+ * The node client created is saved in a global variable.
+ */
+export const initNodeSdk = async () => {
+ if (process.env.NEXT_RUNTIME === 'nodejs') {
+ const sdk = await import('@launchdarkly/node-server-sdk');
+
+ // Create a new nodejs client and save it globally.
+ global.nodeSdk = sdk.init(process.env.LD_SDK_KEY ?? '');
+
+ try {
+ await global.nodeSdk.waitForInitialization({ timeout: 5 });
+ } catch (e) {
+ // Log and report errors here.
+ // A non-initialized ldClient will be returned which
+ // will use defaults for evaluation.
+ // eslint-disable-next-line no-console
+ console.log(`LaunchDarkly NodeClient init error: ${e}`);
+ }
+ }
+};
diff --git a/packages/sdk/react-universal/src/server/useLDClientRsc.ts b/packages/sdk/react-universal/src/server/useLDClientRsc.ts
new file mode 100644
index 0000000000..dec9576117
--- /dev/null
+++ b/packages/sdk/react-universal/src/server/useLDClientRsc.ts
@@ -0,0 +1,35 @@
+import { cache } from 'react';
+
+import type { LDContext } from '@launchdarkly/node-server-sdk';
+
+import { LDClientRsc } from '../ldClientRsc';
+import { getBootstrap } from './getBootstrap';
+
+const ldClientRsc = 'ldClientRsc';
+const getServerCache = cache(() => new Map());
+
+/**
+ * Server Components only. This creates and caches an LDClientRsc object
+ * using React cache which is available on the server side only.
+ *
+ * @param context The LDContext for evaluation.
+ *
+ * @returns An {@link LDClientRsc} object suitable for RSC and server side rendering.
+ */
+export const useLDClientRsc = async (context: LDContext) => {
+ const serverCache = getServerCache();
+ let cachedClient = serverCache.get(ldClientRsc);
+
+ if (!cachedClient) {
+ const bootstrap = await getBootstrap(context);
+ // eslint-disable-next-line no-console
+ console.log(`*** create cache ldClientRsc: ${context.key}`);
+ cachedClient = new LDClientRsc(context, bootstrap);
+ serverCache.set(ldClientRsc, cachedClient);
+ } else {
+ // eslint-disable-next-line no-console
+ console.log(`*** reuse cache ldClientRsc: ${cachedClient.getContext().key}`);
+ }
+
+ return cachedClient as LDClientRsc;
+};
diff --git a/packages/sdk/react-universal/src/types.ts b/packages/sdk/react-universal/src/types.ts
new file mode 100644
index 0000000000..78ce8e871b
--- /dev/null
+++ b/packages/sdk/react-universal/src/types.ts
@@ -0,0 +1,12 @@
+import type { LDClient as NodeSdk } from '@launchdarkly/node-server-sdk';
+
+export type { LDClient as JSSdk } from 'launchdarkly-js-client-sdk';
+
+export type { NodeSdk };
+
+declare global {
+ module globalThis {
+ // eslint-disable-next-line vars-on-top, no-var
+ var nodeSdk: NodeSdk;
+ }
+}
diff --git a/packages/sdk/react-universal/tsconfig.eslint.json b/packages/sdk/react-universal/tsconfig.eslint.json
index 56c9b38305..8241f86c36 100644
--- a/packages/sdk/react-universal/tsconfig.eslint.json
+++ b/packages/sdk/react-universal/tsconfig.eslint.json
@@ -1,5 +1,5 @@
{
"extends": "./tsconfig.json",
- "include": ["/**/*.ts"],
+ "include": ["/**/*.ts", "/**/*.tsx"],
"exclude": ["node_modules"]
}
diff --git a/packages/sdk/react-universal/tsconfig.json b/packages/sdk/react-universal/tsconfig.json
index 47d4dbb174..402ebe28f1 100644
--- a/packages/sdk/react-universal/tsconfig.json
+++ b/packages/sdk/react-universal/tsconfig.json
@@ -3,8 +3,8 @@
"allowSyntheticDefaultImports": true,
"declaration": true,
"declarationMap": true,
- "lib": ["es6"],
- "module": "ES6",
+ "lib": ["es6", "dom"],
+ "module": "ESNext",
"moduleResolution": "node",
"noImplicitOverride": true,
"outDir": "dist",
@@ -17,7 +17,8 @@
"strict": true,
"stripInternal": true,
"target": "ES2017",
- "types": ["jest", "node"]
+ "types": ["jest", "node", "react/canary"],
+ "jsx": "react"
},
"exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__", "example"]
}