From 1ef196a88dfba58362573a06ecd5404e70ceeb26 Mon Sep 17 00:00:00 2001 From: Jeremy Dorn Date: Thu, 2 May 2024 21:34:45 -0500 Subject: [PATCH] Update Next 14 examples to use GrowthBook SDK v1.0.0 (#50) --- .../src/app/client-optimized/ClientApp.tsx | 52 +++++++-------- next-js/src/app/client-optimized/page.tsx | 24 +++++-- next-js/src/app/client/page.tsx | 48 ++++++++------ next-js/src/app/hybrid/ClientApp.tsx | 58 ++++++++--------- next-js/src/app/hybrid/page.tsx | 38 ++++++++--- next-js/src/app/page.tsx | 13 ++-- next-js/src/app/server/page.tsx | 29 +++++++-- next-js/src/app/static/page.tsx | 20 +++++- next-js/src/app/streaming/AsyncComponent.tsx | 28 +++++++-- next-js/src/lib/GrowthBookTracking.tsx | 22 +++++++ next-js/src/lib/growthbookClient.ts | 57 ----------------- next-js/src/lib/growthbookServer.ts | 63 ++++++------------- 12 files changed, 239 insertions(+), 213 deletions(-) create mode 100644 next-js/src/lib/GrowthBookTracking.tsx delete mode 100644 next-js/src/lib/growthbookClient.ts diff --git a/next-js/src/app/client-optimized/ClientApp.tsx b/next-js/src/app/client-optimized/ClientApp.tsx index a92d799..ec1e03c 100644 --- a/next-js/src/app/client-optimized/ClientApp.tsx +++ b/next-js/src/app/client-optimized/ClientApp.tsx @@ -1,35 +1,31 @@ "use client"; -import Cookies from "js-cookie"; -import { AutoExperiment, FeatureDefinition } from "@growthbook/growthbook"; -import { gb, setPayload } from "@/lib/growthbookClient"; -import { GrowthBookProvider } from "@growthbook/growthbook-react"; +import { onExperimentView } from "@/lib/GrowthBookTracking"; import ClientComponent from "./ClientComponent"; +import { GrowthBook, GrowthBookPayload } from "@growthbook/growthbook"; +import { GrowthBookProvider } from "@growthbook/growthbook-react"; +import { useMemo } from "react"; import { GB_UUID_COOKIE } from "@/middleware"; -import { useCallback, useEffect, useMemo, useRef } from "react"; - -export default function ClientApp({ - payload, -}: { - payload: { - features: Record>; - experiments: AutoExperiment[]; - }; -}) { - // Helper to hydrate client-side GrowthBook instance with payload from the server - const hydrate = useCallback(() => { - setPayload(payload); - gb.setAttributes({ - id: Cookies.get(GB_UUID_COOKIE), - }); - }, [payload]); +import Cookies from "js-cookie"; - // Hydrate immediately on first render and whenever the payload changes - const ref = useRef(); - if (!ref.current) { - ref.current = true; - hydrate(); - } - useEffect(() => hydrate(), [hydrate]); +export default function ClientApp({ payload }: { payload: GrowthBookPayload }) { + // Create a singleton GrowthBook instance for this page + const gb = useMemo( + () => + new GrowthBook({ + apiHost: process.env.NEXT_PUBLIC_GROWTHBOOK_API_HOST, + clientKey: process.env.NEXT_PUBLIC_GROWTHBOOK_CLIENT_KEY, + decryptionKey: process.env.NEXT_PUBLIC_GROWTHBOOK_DECRYPTION_KEY, + trackingCallback: onExperimentView, + attributes: { + id: Cookies.get(GB_UUID_COOKIE), + }, + }).initSync({ + payload, + // Optional, enable streaming updates + streaming: true, + }), + [payload] + ); return ( diff --git a/next-js/src/app/client-optimized/page.tsx b/next-js/src/app/client-optimized/page.tsx index 26024ad..26703ea 100644 --- a/next-js/src/app/client-optimized/page.tsx +++ b/next-js/src/app/client-optimized/page.tsx @@ -1,9 +1,25 @@ -import { getInstance, getPayload } from "@/lib/growthbookServer"; +import { configureServerSideGrowthBook } from "@/lib/growthbookServer"; import ClientApp from "./ClientApp"; +import { GrowthBook } from "@growthbook/growthbook"; export default async function PrerenderedClientPage() { - // Get server-side GrowthBook instance in order to fetch the feature flag payload - const gb = await getInstance(); + // Helper to configure cache for next.js + configureServerSideGrowthBook(); - return ; + // Create and initialize a GrowthBook instance + const gb = new GrowthBook({ + apiHost: process.env.NEXT_PUBLIC_GROWTHBOOK_API_HOST, + clientKey: process.env.NEXT_PUBLIC_GROWTHBOOK_CLIENT_KEY, + decryptionKey: process.env.NEXT_PUBLIC_GROWTHBOOK_DECRYPTION_KEY, + }); + await gb.init({ timeout: 1000 }); + + // Get the payload to hydrate the client-side GrowthBook instance + // We need the decrypted payload so the initial client-render can be synchronous + const payload = gb.getDecryptedPayload(); + + // Cleanup your GrowthBook instance + gb.destroy(); + + return ; } diff --git a/next-js/src/app/client/page.tsx b/next-js/src/app/client/page.tsx index 5a2ed39..effdcea 100644 --- a/next-js/src/app/client/page.tsx +++ b/next-js/src/app/client/page.tsx @@ -1,32 +1,40 @@ "use client"; import Cookies from "js-cookie"; -import { GrowthBookProvider } from "@growthbook/growthbook-react"; -import { useEffect } from "react"; +import { GrowthBook, GrowthBookProvider } from "@growthbook/growthbook-react"; +import { useEffect, useMemo } from "react"; import ClientComponent from "./ClientComponent"; -import { gb } from "@/lib/growthbookClient"; import { GB_UUID_COOKIE } from "@/middleware"; +import { onExperimentView } from "@/lib/GrowthBookTracking"; export default function ClientPage() { + // Create a single memoized GrowthBook instance for the client + const gb = useMemo(() => { + return new GrowthBook({ + apiHost: process.env.NEXT_PUBLIC_GROWTHBOOK_API_HOST, + clientKey: process.env.NEXT_PUBLIC_GROWTHBOOK_CLIENT_KEY, + decryptionKey: process.env.NEXT_PUBLIC_GROWTHBOOK_DECRYPTION_KEY, + trackingCallback: onExperimentView, + }); + }, []); + useEffect(() => { - const load = async () => { - try { - await gb.loadFeatures(); + // Fetch feature payload from GrowthBook + gb.init({ + // Optional, enable streaming updates + streaming: true, + }); - let uuid = Cookies.get(GB_UUID_COOKIE); - if (!uuid) { - uuid = Math.random().toString(36).substring(2); - Cookies.set(GB_UUID_COOKIE, uuid); - } + // Set targeting attributes for the user + let uuid = Cookies.get(GB_UUID_COOKIE); + if (!uuid) { + uuid = Math.random().toString(36).substring(2); + Cookies.set(GB_UUID_COOKIE, uuid); + } + gb.setAttributes({ + id: uuid, + }); + }, [gb]); - gb.setAttributes({ - id: uuid, - }); - } catch (e) { - console.error(e); - } - }; - load(); - }, []); return ( diff --git a/next-js/src/app/hybrid/ClientApp.tsx b/next-js/src/app/hybrid/ClientApp.tsx index 3a58256..b8f5d20 100644 --- a/next-js/src/app/hybrid/ClientApp.tsx +++ b/next-js/src/app/hybrid/ClientApp.tsx @@ -1,39 +1,33 @@ "use client"; -import Cookies from "js-cookie"; -import { AutoExperiment, FeatureDefinition } from "@growthbook/growthbook"; -import { gb, setPayload } from "@/lib/growthbookClient"; -import { GrowthBookProvider } from "@growthbook/growthbook-react"; -import ClientComponent from "./ClientComponent"; +import { onExperimentView } from "@/lib/GrowthBookTracking"; +import { GrowthBookPayload } from "@growthbook/growthbook"; +import { GrowthBook, GrowthBookProvider } from "@growthbook/growthbook-react"; +import { PropsWithChildren, useMemo } from "react"; import { GB_UUID_COOKIE } from "@/middleware"; -import { useCallback, useEffect, useRef } from "react"; +import Cookies from "js-cookie"; export default function ClientApp({ payload, -}: { - payload: { - features: Record>; - experiments: AutoExperiment[]; - }; -}) { - // Helper to hydrate client-side GrowthBook instance with payload from the server - const hydrate = useCallback(() => { - setPayload(payload); - gb.setAttributes({ - id: Cookies.get(GB_UUID_COOKIE), - }); - }, [payload]); - - // Hydrate immediately on first render and whenever the payload changes - const ref = useRef(); - if (!ref.current) { - ref.current = true; - hydrate(); - } - useEffect(() => hydrate(), [hydrate]); - - return ( - - - + children, +}: PropsWithChildren<{ payload: GrowthBookPayload }>) { + // Create a singleton GrowthBook instance for this page + const gb = useMemo( + () => + new GrowthBook({ + apiHost: process.env.NEXT_PUBLIC_GROWTHBOOK_API_HOST, + clientKey: process.env.NEXT_PUBLIC_GROWTHBOOK_CLIENT_KEY, + decryptionKey: process.env.NEXT_PUBLIC_GROWTHBOOK_DECRYPTION_KEY, + trackingCallback: onExperimentView, + attributes: { + id: Cookies.get(GB_UUID_COOKIE), + }, + }).initSync({ + payload, + // Optional, enable streaming updates + streaming: true, + }), + [payload] ); + + return {children}; } diff --git a/next-js/src/app/hybrid/page.tsx b/next-js/src/app/hybrid/page.tsx index f6ead0a..f72d767 100644 --- a/next-js/src/app/hybrid/page.tsx +++ b/next-js/src/app/hybrid/page.tsx @@ -1,22 +1,44 @@ -import { getInstance, getPayload } from "@/lib/growthbookServer"; import { cookies } from "next/headers"; -import { GrowthBookTracking } from "@/lib/growthbookClient"; import { GB_UUID_COOKIE } from "@/middleware"; import ClientApp from "./ClientApp"; import RevalidateMessage from "@/app/revalidate/RevalidateMessage"; +import { GrowthBook } from "@growthbook/growthbook"; +import { configureServerSideGrowthBook } from "@/lib/growthbookServer"; +import { GrowthBookTracking } from "@/lib/GrowthBookTracking"; +import ClientComponent from "./ClientComponent"; export default async function ServerCombo() { - // create instance per request, server-side - const gb = await getInstance(); + // Helper to configure cache for next.js + configureServerSideGrowthBook(); - // using cookies means next will render this page dynamically + // Create and initialize a GrowthBook instance + const gb = new GrowthBook({ + apiHost: process.env.NEXT_PUBLIC_GROWTHBOOK_API_HOST, + clientKey: process.env.NEXT_PUBLIC_GROWTHBOOK_CLIENT_KEY, + decryptionKey: process.env.NEXT_PUBLIC_GROWTHBOOK_DECRYPTION_KEY, + }); + await gb.init({ timeout: 1000 }); + + // Set targeting attributes for the user gb.setAttributes({ id: cookies().get(GB_UUID_COOKIE)?.value || "", }); + // Evaluate any feature flags const feature1Enabled = gb.isOn("feature1"); const feature2Value = gb.getFeatureValue("feature2", "fallback"); + // Get the payload to hydrate the client-side GrowthBook instance + // We need the decrypted payload so the initial client-render can be synchronous + const payload = gb.getDecryptedPayload(); + + // If the above features ran any experiments, get the tracking call data + // This is passed into the client component below + const trackingData = gb.getDeferredTrackingCalls(); + + // Cleanup your GrowthBook instance + gb.destroy(); + return (

Server / Client Hybrid

@@ -35,11 +57,13 @@ export default async function ServerCombo() { - + + + - +
); } diff --git a/next-js/src/app/page.tsx b/next-js/src/app/page.tsx index 2456f0d..201dca8 100644 --- a/next-js/src/app/page.tsx +++ b/next-js/src/app/page.tsx @@ -15,14 +15,9 @@ export default function Home() { language.
  • - Next, create a .env.local file in the root of this - example with the following contents (fill in values with the ones - shown after creating your SDK Connection): -
    {`NEXT_PUBLIC_GROWTHBOOK_CLIENT_KEY=
    -NEXT_PUBLIC_GROWTHBOOK_API_HOST=
    -# Optional (only if you enabled encryption):
    -NEXT_PUBLIC_GROWTHBOOK_DECRYPTION_KEY=
    -`}
    + Next, in this example root, copy .env.local.example to{" "} + .env.local and fill in the values from your GrowthBook + SDK Connection.
  • @@ -98,7 +93,7 @@ NEXT_PUBLIC_GROWTHBOOK_DECRYPTION_KEY= user saw is sent to the client where an analytics event is triggered (or console.log in these examples). This happens via the{" "} GrowthBookTracking client component defined in{" "} - src/lib/growthbookClient.ts. + src/lib/GrowthBookTracking.

    diff --git a/next-js/src/app/server/page.tsx b/next-js/src/app/server/page.tsx index 78ba791..691ca6c 100644 --- a/next-js/src/app/server/page.tsx +++ b/next-js/src/app/server/page.tsx @@ -1,21 +1,38 @@ -import { getInstance } from "@/lib/growthbookServer"; -import { GrowthBookTracking } from "@/lib/growthbookClient"; import { cookies } from "next/headers"; import { GB_UUID_COOKIE } from "@/middleware"; import RevalidateMessage from "@/app/revalidate/RevalidateMessage"; +import { GrowthBook } from "@growthbook/growthbook"; +import { GrowthBookTracking } from "@/lib/GrowthBookTracking"; +import { configureServerSideGrowthBook } from "@/lib/growthbookServer"; export default async function ServerDynamic() { - // create instance per request, server-side - const gb = await getInstance(); + // Helper to configure cache for next.js + configureServerSideGrowthBook(); - // using cookies means next will render this page dynamically + // Create and initialize a GrowthBook instance + const gb = new GrowthBook({ + apiHost: process.env.NEXT_PUBLIC_GROWTHBOOK_API_HOST, + clientKey: process.env.NEXT_PUBLIC_GROWTHBOOK_CLIENT_KEY, + decryptionKey: process.env.NEXT_PUBLIC_GROWTHBOOK_DECRYPTION_KEY, + }); + await gb.init({ timeout: 1000 }); + + // Set targeting attributes for the user await gb.setAttributes({ id: cookies().get(GB_UUID_COOKIE)?.value || "", }); + // Evaluate any feature flags const feature1Enabled = gb.isOn("feature1"); const feature2Value = gb.getFeatureValue("feature2", "fallback"); + // If the above features ran any experiments, get the tracking call data + // This is passed into the client component below + const trackingData = gb.getDeferredTrackingCalls(); + + // Cleanup + gb.destroy(); + return (

    Dynamic Server Rendering

    @@ -34,7 +51,7 @@ export default async function ServerDynamic() { - +
    ); } diff --git a/next-js/src/app/static/page.tsx b/next-js/src/app/static/page.tsx index 0fb026b..8e956e9 100644 --- a/next-js/src/app/static/page.tsx +++ b/next-js/src/app/static/page.tsx @@ -1,13 +1,29 @@ import RevalidateMessage from "@/app/revalidate/RevalidateMessage"; -import { getInstance } from "@/lib/growthbookServer"; +import { configureServerSideGrowthBook } from "@/lib/growthbookServer"; +import { GrowthBook } from "@growthbook/growthbook"; export default async function ServerStatic() { + // Helper to configure cache for next.js + configureServerSideGrowthBook(); + + // Create and initialize a GrowthBook instance + const gb = new GrowthBook({ + apiHost: process.env.NEXT_PUBLIC_GROWTHBOOK_API_HOST, + clientKey: process.env.NEXT_PUBLIC_GROWTHBOOK_CLIENT_KEY, + decryptionKey: process.env.NEXT_PUBLIC_GROWTHBOOK_DECRYPTION_KEY, + }); + await gb.init({ timeout: 1000 }); + // By not using cookies or headers, this page can be statically rendered // Note: This means you can't target individual users or run experiments - const gb = await getInstance(); + // Evaluate any feature flags const feature1Enabled = gb.isOn("feature1"); const feature2Value = gb.getFeatureValue("feature2", "fallback"); + + // Cleanup your GrowthBook instance + gb.destroy(); + return (

    Static Pages

    diff --git a/next-js/src/app/streaming/AsyncComponent.tsx b/next-js/src/app/streaming/AsyncComponent.tsx index 6a07861..241681a 100644 --- a/next-js/src/app/streaming/AsyncComponent.tsx +++ b/next-js/src/app/streaming/AsyncComponent.tsx @@ -1,22 +1,40 @@ -import { getInstance } from "@/lib/growthbookServer"; -import { GrowthBookTracking } from "@/lib/growthbookClient"; +import { configureServerSideGrowthBook } from "@/lib/growthbookServer"; import { cookies } from "next/headers"; import { GB_UUID_COOKIE } from "@/middleware"; +import { GrowthBook } from "@growthbook/growthbook"; +import { GrowthBookTracking } from "@/lib/GrowthBookTracking"; export default async function AsyncComponent() { - // create instance per request, server-side - const gb = await getInstance(); + // Helper to configure cache for next.js + configureServerSideGrowthBook(); + + // Create and initialize a GrowthBook instance + const gb = new GrowthBook({ + apiHost: process.env.NEXT_PUBLIC_GROWTHBOOK_API_HOST, + clientKey: process.env.NEXT_PUBLIC_GROWTHBOOK_CLIENT_KEY, + decryptionKey: process.env.NEXT_PUBLIC_GROWTHBOOK_DECRYPTION_KEY, + }); + await gb.init({ timeout: 1000 }); // Artificial 2 second delay to simulate a slow server await new Promise((resolve) => setTimeout(resolve, 2000)); + // Set targeting attributes for the user gb.setAttributes({ id: cookies().get(GB_UUID_COOKIE)?.value || "", }); + // Evaluate any feature flags const feature1Enabled = gb.isOn("feature1"); const feature2Value = gb.getFeatureValue("feature2", "fallback"); + // If the above features ran any experiments, get the tracking call data + // This is passed into the client component below + const trackingData = gb.getDeferredTrackingCalls(); + + // Cleanup your GrowthBook instance + gb.destroy(); + return (

    @@ -42,7 +60,7 @@ export default async function AsyncComponent() {

  • - + ); } diff --git a/next-js/src/lib/GrowthBookTracking.tsx b/next-js/src/lib/GrowthBookTracking.tsx new file mode 100644 index 0000000..3b7dd97 --- /dev/null +++ b/next-js/src/lib/GrowthBookTracking.tsx @@ -0,0 +1,22 @@ +"use client"; +import { TrackingCallback, TrackingData } from "@growthbook/growthbook"; +import { useEffect } from "react"; + +export const onExperimentView: TrackingCallback = (experiment, result) => { + // TODO: track with Google Analytics, Segment, etc. + console.log("Viewed Experiment", { + experimentId: experiment.key, + variationId: result.key, + }); +}; + +// Helper component to track experiment views from server components +export function GrowthBookTracking({ data }: { data: TrackingData[] }) { + useEffect(() => { + data.forEach(({ experiment, result }) => { + onExperimentView(experiment, result); + }); + }, [data]); + + return null; +} diff --git a/next-js/src/lib/growthbookClient.ts b/next-js/src/lib/growthbookClient.ts deleted file mode 100644 index cf5e3c9..0000000 --- a/next-js/src/lib/growthbookClient.ts +++ /dev/null @@ -1,57 +0,0 @@ -"use client"; -import { - GrowthBook, - Experiment, - Result, - FeatureDefinition, - AutoExperiment, -} from "@growthbook/growthbook"; - -// TODO: these are defined in the GrowthBook SDK, but not exported -interface TrackingData { - experiment: Experiment; - result: Result; -} -type TrackingCallback = ( - experiment: Experiment, - result: Result -) => void; - -export const onExperimentView: TrackingCallback = (experiment, result) => { - // TODO: track with Google Analytics, Segment, etc. - console.log("Viewed Experiment", { - experimentId: experiment.key, - variationId: result.key, - }); -}; - -export const gb = new GrowthBook({ - apiHost: process.env.NEXT_PUBLIC_GROWTHBOOK_API_HOST, - clientKey: process.env.NEXT_PUBLIC_GROWTHBOOK_CLIENT_KEY, - decryptionKey: process.env.NEXT_PUBLIC_GROWTHBOOK_DECRYPTION_KEY, - - // These are client-specific settings - backgroundSync: true, - subscribeToChanges: true, - trackingCallback: onExperimentView, - - // TODO: add an option to disable autoPrefetch behavior -}); - -// Helper component to track experiment views from server components -export function GrowthBookTracking({ data }: { data: TrackingData[] }) { - data.forEach(({ experiment, result }) => { - onExperimentView(experiment, result); - }); - - return null; -} - -// TODO: Swap this out with built-in SDK method from Edge work -export function setPayload(payload: { - features: Record>; - experiments: AutoExperiment[]; -}) { - gb.setFeatures(payload.features); - gb.setExperiments(payload.experiments); -} diff --git a/next-js/src/lib/growthbookServer.ts b/next-js/src/lib/growthbookServer.ts index 5920d25..d6116ba 100644 --- a/next-js/src/lib/growthbookServer.ts +++ b/next-js/src/lib/growthbookServer.ts @@ -1,48 +1,25 @@ -import { GrowthBook, setPolyfills } from "@growthbook/growthbook"; +import { setPolyfills, configureCache } from "@growthbook/growthbook"; -// Tag fetch requests so they can be revalidated on demand -setPolyfills({ - fetch: ( - url: Parameters[0], - opts: Parameters[1] - ) => - fetch(url, { - ...opts, - next: { - // Cache feature definitions for 1 minute - // Implement SDK webhooks to revalidate on demand (see gb-revalidate route handler) - revalidate: 60, - tags: ["growthbook"], - }, - }), -}); - -// It's important to create a new instance per request when server-side to prevent -// race condtions from occurring between different user requests. -export async function getInstance() { - const gb = new GrowthBook({ - apiHost: process.env.NEXT_PUBLIC_GROWTHBOOK_API_HOST, - clientKey: process.env.NEXT_PUBLIC_GROWTHBOOK_CLIENT_KEY, - decryptionKey: process.env.NEXT_PUBLIC_GROWTHBOOK_DECRYPTION_KEY, - - // These are server-specific settings - backgroundSync: false, - subscribeToChanges: false, +export function configureServerSideGrowthBook() { + // Tag fetch requests so they can be revalidated on demand + setPolyfills({ + fetch: ( + url: Parameters[0], + opts: Parameters[1] + ) => + fetch(url, { + ...opts, + next: { + // Cache feature definitions for 1 minute + // Implement SDK webhooks to revalidate on demand (see gb-revalidate route handler) + revalidate: 60, + tags: ["growthbook"], + }, + }), }); - await gb.loadFeatures({ - timeout: 1000, - // Rely on the Next.js fetch cache instead of the built-in one - skipCache: true, + // Disable the built-in cache since we're using Next.js's fetch cache instead + configureCache({ + disableCache: true, }); - - return gb; -} - -// TODO: Swap this out with built-in SDK method from Edge work -export function getPayload(gb: GrowthBook) { - return { - features: gb.getFeatures(), - experiments: gb.getExperiments(), - }; }