Rendering dynamic variations for A/B testing, personalization, and scheduled content

You can set up “dynamic variations” for your pages and components to show different content for different users at different times. This is useful for running A/B tests, targeting specific segments of users, or scheduling content that shows up in the future.

This document describes the code changes necessary to show the right dynamic variations of your page. If you haven’t, please first read about how to set up dynamic variations from within Plasmic Studio.

This feature currently only works with the headless API, not codegen.

Rendering dynamic variations

First, you need a way to “pick” which variation to render for the current user. For A/B tests, it means throwing a dice and picking a bucket; for personalization, it means taking the traits defined for this user and matching them to the best segment; for scheduled content, it means checking whether the current time unlocks any new content.

Once you have picked the variation to use, you need to tell Plasmic which variation you’d like to be rendered, by passing it to <PlasmicRootProvider />.

Most of the complication lies in the first step — picking the right variation to use. How and where this happens depends on your application and what framework you’re using.

In general, we recommend that you pick the right variation to use on the server side. This makes it possible for the server to generate the right html content to start with, thus avoiding a “flash of wrong content” where the variation is picked and applied after hydration. It also makes it possible to cache the generated content at the edge, allowing faster page loads after initial generation.

In these docs, we’ll use Next.js as an example — but feel free to ask in the community if you want help with a different environment.

Next.js has three places where you can dynamically choose which variantion of a component you want to render. We recommend using Next.js middleware for the best results.

  1. In Next.js middleware, which can run in edge runtimes like Vercel’s
  2. In getServerSideProps, server-side
  3. Client-side, in the browser

We’ll examine each of these.

1. Middleware

Next.js has a new middleware feature, which allows you to write a middleware function that can rewrite the response based on the request. This allows you to dynamically pick the variation to use based on the request, but can still cache the generated for that specific variation for reuse later. This is the best performing option, but it is also the most complicated to set up.

You should consider using Next.js middleware if:

  • You prefer to statically generate and cache server-rendered html (that is, you use getStaticProps() where possible, instead of getServerSideProps()).
  • You are already using incremental static regeneration (aka revalidate).
  • You are set up with a catchall page for rendering Plasmic pages.
  • You can quickly look up the custom traits to use for a request (either because it is part of the url or set in cookies).
  • You are deployed on Vercel or other hosting services that support Next.js middleware at the edge.

The high-level sketch of a page request looks like this:

  1. The user requests a page, like /pricing.
  2. Your middleware intercepts the request, and “rewrites” the response as a different url, which includes the custom traits for the request (usually read off of request cookies). This rewritten url is still mapped to your catchall page.
  3. The getStaticProps() function of your catchall page parses the traits, fetches the Plasmic page data, and picks a specific variation. The fetched Plasmic page data and the variation to use is returned as pageProps.
  4. Your catchall page component renders <PlasmicRootProvider /> with the picked variation from pageProps.

Next.js will be cache the generated content, so you only have to generate a page once. The key of the cache is the page path, which includes the url, and the custom traits. Note that one of the “custom traits” will be a random number, which is used for picking the right bucket to use in A/B tests.

Here’s how your _middleware.ts might look:

_middleware.ts
Copy
import { NextRequest, NextResponse } from 'next/server';
import { getMiddlewareResponse } from '@plasmicapp/loader-nextjs/edge';
// Exclude paths that are definitely not Plasmic pages with variations
const excludePaths = ['/api', '/favicon', '/plasmic-host'];
export async function middleware(req: NextRequest) {
// Only pick a dynamic variation for GET requests, and for pages
// that we ideally know are Plasmic pages.
if (req.method !== 'GET' || excludePaths.some((x) => req.nextUrl.pathname.startsWith(x))) {
return;
}
const newUrl = req.nextUrl.clone();
// Rewrite to a new pathname that encodes the custom traits for
// this request, as well as some randomness for A/B tests
const { pathname, cookies } = getMiddlewareResponse({
path: newUrl.pathname,
traits: {
// Add values for custom traits that you are using; these are
// likely read off of the cookie.
},
cookies: req.cookies
});
// Rewrite the response to use this new pathname
newUrl.pathname = pathname;
const res = NextResponse.rewrite(newUrl);
// Save anything that needs to be saved in a cookie -- specifically,
// the custom trait that corresponds to the random seed. The same
// random seed will be used to pick the A/B test bucket each time
// the user visits, to ensure that a visitor will always see the
// same A/B test bucket.
cookies.forEach((cookie) => {
res.cookie(cookie.key, cookie.value);
});
return res;
}

Now you need to extend your catchall page to also deal with these rewritten pathnames that encode traits and a random seed:

[[...catchall]].tsx
Copy
import * as React from 'react';
import Error from 'next/error';
import { PlasmicComponent, ComponentRenderData, PlasmicRootProvider } from '@plasmicapp/loader-nextjs';
import { GetStaticPaths, GetStaticProps } from 'next';
import { PLASMIC } from '../init';
import { generateAllPaths, getActiveVariation, rewriteWithoutTraits } from '@plasmicapp/loader-nextjs/edge';
export default function CatchallPage(props: { plasmicData?: ComponentRenderData; variation?: any }) {
const { variation, externalIds, plasmicData } = props;
if (!plasmicData) {
return <Error statusCode={404} />;
}
// Render Plasmic page with the variation picked in `getStaticProps()`
return (
<PlasmicRootProvider
loader={PLASMIC}
prefetchedData={plasmicData}
variation={variation}
>
<PlasmicComponent component={plasmicData.entryCompMetas[0].name} />
</PlasmicRootProvider>
);
}
export const getStaticProps: GetStaticProps = async (context) => {
const { catchall } = context.params ?? {};
const rawPlasmicPath = `/${(catchall as string[]).join('/')}`;
// Parse the path, and extract the traits.
const { path: plasmicPath, traits } = rewriteWithoutTraits(rawPlasmicPath);
const plasmicData = await PLASMIC.maybeFetchComponentData(plasmicPath);
if (!plasmicData) {
// Non-Plasmic page
return { props: {} };
}
// Pick the variation to use based on the traits
const variation = getActiveVariation({
splits: PLASMIC.getActiveSplits(),
traits,
path: plasmicPath
});
return {
props: {
plasmicData,
variation
},
// We use revalidate here, so that if new A/B tests or segments have
// been defined and published, they will be automatically picked up
// and used here.
//
// Also, for scheduled content to work, you must use revalidate, as
// the "current time" is not part of the page cache key, and is
// actually the time when this is run. So we rely on revalidate to
// invalidate the cached page content and regenerate with a
// new timestamp.
revalidate: 60
};
};
export const getStaticPaths: GetStaticPaths = async () => {
const pageModules = await PLASMIC.fetchPages();
return {
paths: Array.from(gen()),
// We set `fallback:"blocking"` to generate page variations lazily.
// Once a page for a specific set of traits has been generated, it
// will be cached and reused.
fallback: 'blocking'
};
};

Cached page content considerations

This approach relies on your catchall page to lazily generate the page content for a specific set of custom traits. The generated content will then be cached by Next.js to those set of traits. There are a few considerations to be aware of here:

  • Page load will be slower the first time someone visits with a new set of custom traits, because Next.js will be rendering that page at request time. One of the custom traits, specifically, is a “random seed” that is used to pick an A/B test bucket. By default, we use 16 different seed values, which means there will be at least 16 “cache misses”.
  • The timestamp used for activating scheduled content is the time when getStaticProps() is called. That means if you are using scheduled content, then you must use revalidate, so that the cached content is invalidated and re-generated after some timeout. If you specify a large revalidate, then your server will be doing less work (fewer invalidations), but your scheduled content may not be activated at precisely the right time. For example, if your revalidate is 300 (5 minutes), then the page will only be regenerated once every ~5 minutes, so you may miss your scheduled time by at most 5 minutes. Therefore, you should pick a revalidate number that is right for your use case.

2. Server-side-rendered via getServerSideProps()

If you are already dynamically rendering on the server for every request — by using getServerSideProps() — then you can simply also pick the variation to use while you are rendering. Most of the complications with using middleware goes away.

getServerSideProps is simpler to set up, without requiring any change to your codebase organization.

pages/mypage.tsx
Copy
export async function getServerSideProps(context: GetServerSidePropsContext) {
// This is the main new addition to your existing Plasmic-loading code.
const variation = await PLASMIC.getActiveVariation({
req: context.req,
res: context.res,
// These are based on whatever custom traits you have defined.
// Often, this trait is derived from user info, such as in req.cookies
// or from your application database.
traits: {
age: 24,
color: 'red',
isLoggedIn: true
}
});
const plasmicData = await PLASMIC.fetchComponentData('MyPage');
return {
props: {
plasmicData,
variation
}
};
}
export function CatchallPage({ plasmicData, variation }) {
return (
<PlasmicRootProvider
loader={PLASMIC}
prefetchedData={plasmicData}
variation={variation}
>
<PlasmicComponent component={'Homepage'} />
</PlasmicRootProvider>
);
}

3. Client-side

You can pick the variant client-side, but this will degrade the performance of your aplication, since it will first render (flash) the base variant of the page/component, load the correct variant of the design from the Plasmic CDN, following by a render of the correct variant.

Copy
function MyPage() {
const [loading, setLoading] = React.useState(false);
const [variation, setVariation] = React.useState({});
const getVariation = async () => {
// This is async because it loads the bundle if it is not already loaded.
const activeVariation = await PLASMIC.getActiveVariation({
traits: {}
});
setVariation(activeVariation);
setLoading(false);
};
React.useEffect(() => {
getVariation();
}, []);
if (loading) {
return <p>Loading...</p>;
}
return (
<PlasmicRootProvider loader={PLASMIC} variation={variation}>
<PlasmicComponent component={'Homepage'} />
</PlasmicRootProvider>
);
}

Give feedback on this page