Skip to Content

Examples

Nuxt

Server side A/B testing through Nuxt server middleware

3 min read

This example runs a server side A/B test with the Nuxt SDK. A server middleware decides the variant before the page renders, so the first paint already matches the visitor's variant: no layout shift. The Vue SDK is still used in the browser to record the exposure and post analytics.

Install

npm i @obelism/improve-sdk @obelism/improve-sdk-nuxt @obelism/improve-sdk-vue

Shared config

A small shared config keeps the organization and environment in one place for both the middleware and the provider. Nuxt's shared/ folder is for code both sides import.

shared/improveConfig.ts
import type { ImproveSetupArgs } from '@obelism/improve-sdk/types'

export const IMPROVE_CONFIG: ImproveSetupArgs = {
	organizationId: 'org_MJFL46Z0WXGQ5OHW1ZXSM3Q88S',
	environment: 'staging',
}

The API token stays on the server, in the private runtime config, filled from NUXT_IMPROVE_API_TOKEN:

nuxt.config.ts
export default defineNuxtConfig({
	runtimeConfig: {
		improveApiToken: '',
	},
})

Middleware

The middleware creates an ImproveServerSDK and hands it to generateImproveNuxtMiddleware, with the route each server test runs on. It fetches the datafile on the first request.

server/middleware/improve.ts
import { ImproveServerSDK } from '@obelism/improve-sdk/server'
import { generateImproveNuxtMiddleware } from '@obelism/improve-sdk-nuxt'

import { IMPROVE_CONFIG } from '#shared/improveConfig'

const improveSdk = new ImproveServerSDK({
	...IMPROVE_CONFIG,
	token: () => useRuntimeConfig().improveApiToken,
	// Pages wait for the datafile: fail fast rather than retry.
	configRetries: 0,
})

export default generateImproveNuxtMiddleware({
	improveSdk,
	serverABtests: [{ slug: 'startpage-visual', routeHandler: '/' }],
})

Client provider

The provider loads the client SDK and its config after mount. Generate it once and export what the pages use:

app/improve.ts
import { generateImproveProvider } from '@obelism/improve-sdk-vue'

import { IMPROVE_CONFIG } from '#shared/improveConfig'

export const { ImproveProvider, useImproveStatus, useTestValue, usePostAnalytic } =
	generateImproveProvider(IMPROVE_CONFIG)

Wrap the app with the provider:

app/app.vue
<script setup lang="ts">
import { ImproveProvider } from './improve'
</script>

<template>
	<ImproveProvider>
		<NuxtPage />
	</ImproveProvider>
</template>

The page

Unlike Next.js, there is no route per variant: Nuxt hydrates the route the browser shows, so the page renders the variant itself. useState hands the server's decision to the browser, and useTestValue renders it: the server's value as the fallback, then the same value read from the cookie once the SDK is set up. After a client-side navigation no request reached the middleware, and the browser decides.

app/pages/index.vue
<script setup lang="ts">
import { getImproveContext } from '@obelism/improve-sdk-nuxt'

import { useTestValue } from '~/improve'

const decided = useState('improve:startpage-visual', () => {
	const event = useRequestEvent()
	return (event && getImproveContext(event)['startpage-visual']) || null
})
// Reading the variant in the browser records the exposure events are attributed to.
const variant = useTestValue('startpage-visual', decided.value ?? 'control')

useTrackPageView('homepage')
</script>

<template>
	<main>
		<Visual :variant="variant === 'variation' ? 'variation' : 'control'" />
	</main>
</template>

Tracking

The page posts analytics with usePostAnalytic. Events are test-independent: call it with an event name and a required scope and the server attributes the event to whichever tests/flags the visitor was exposed to. useTrackPageView reports a page load once the SDK is set up, Visual reports a click on the variant.

app/composables/useTrackPageView.ts
import { useImproveStatus, usePostAnalytic } from '~/improve'

export const useTrackPageView = (page: string) => {
	const status = useImproveStatus()
	const postAnalytic = usePostAnalytic()

	// Nothing is sent before the SDK is set up.
	watch(
		status,
		(current) => {
			if (current === 'setup') postAnalytic('page_view', page)
		},
		{ immediate: true },
	)
}
app/components/Visual.vue
<script setup lang="ts">
import { usePostAnalytic } from '~/improve'

defineProps<{ variant: 'control' | 'variation' }>()
const postAnalytic = usePostAnalytic()
</script>

<template>
	<img
		src="/nuxt.svg"
		alt="Nuxt logo"
		width="180"
		height="37"
		:style="variant === 'control' ? {} : { transform: 'scaleX(-1)' }"
		@click="postAnalytic('visual_click', 'homepage')"
	/>
</template>

Keep tested routes out of prerendering and route caches (prerender, swr, isr in routeRules): a cached page shows one variant to everyone. The middleware marks a tested page Cache-Control: private for the same reason.

On this page

Marks in the margin

  • Note
  • Watch out
  • Updated

Scribes marked doubtful lines in the margin. That practice is called obelism.