DEV Community

Cover image for A/B split your users with Supabase
Jack Bridger
Jack Bridger

Posted on • Updated on

A/B split your users with Supabase

Want to find out if one onboarding screen is better than another but want to build it yourself?

In this article, Nick Smet & I will show you how you can split your users into A and B variants using only Supabase. This gives you scope to run A/B tests and build features in a more data-driven way.

Notes:

  • This is not a production-ready - there are several limitations but it was fun to work on and we hope it gives you some ideas to think about.
  • We are assuming you have some familiarity with Supabase. If not, we suggest checking out the quick starts on Supabase and Edge functions.
  • We are not covering the analysis part of A/B testing

Step 1 - create a profiles table

The profiles table contains the non-authy side of users. Including their name etc. It’s connected to the auth user by id.

We used the Supabase User Management starter. You can find it within the SQL Editor → Quick Start.

If you run this SQL template, it will automatically create a user in the Profiles table when you create a user in the auth table.

Supabase quick start

-- Create a table for public profiles
create table profiles (
  id uuid references auth.users on delete cascade not null primary key,
  updated_at timestamp with time zone,
  username text unique,
  full_name text,
  avatar_url text,
  website text,

  constraint username_length check (char_length(username) >= 3)
);
-- Set up Row Level Security (RLS)
-- See https://supabase.com/docs/guides/auth/row-level-security for more details.
alter table profiles
  enable row level security;

create policy "Public profiles are viewable by everyone." on profiles
  for select using (true);

create policy "Users can insert their own profile." on profiles
  for insert with check (auth.uid() = id);

create policy "Users can update own profile." on profiles
  for update using (auth.uid() = id);

-- This trigger automatically creates a profile entry when a new user signs up via Supabase Auth.
-- See https://supabase.com/docs/guides/auth/managing-user-data#using-triggers for more details.
create function public.handle_new_user()
returns trigger as $$
begin
  insert into public.profiles (id, full_name, avatar_url)
  values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url');
  return new;
end;
$$ language plpgsql security definer;
create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

-- Set up Storage!
insert into storage.buckets (id, name)
  values ('avatars', 'avatars');

-- Set up access controls for storage.
-- See https://supabase.com/docs/guides/storage#policy-examples for more details.
create policy "Avatar images are publicly accessible." on storage.objects
  for select using (bucket_id = 'avatars');

create policy "Anyone can upload an avatar." on storage.objects
  for insert with check (bucket_id = 'avatars');
Enter fullscreen mode Exit fullscreen mode

Step 2 - create an experiments table in your Supabase database

We’ll want to run more than one experiment so we’re going to create an experiments table.

For instance we might first want to test a new screen on our onboarding flow. Then we might want to test our upsell page.

create table experiments (
  id uuid not null primary key DEFAULT uuid_generate_v1(),
  updated_at timestamp with time zone,
  inserted_at   timestamp with time zone default timezone('utc'::text, now()) not null,
  exp_number integer,
  title text,
  description text
);
Enter fullscreen mode Exit fullscreen mode

An example experiment could be an onboarding improvement.
We can run this code snippet in Supabase’s SQL editor to insert an experiment.

INSERT INTO experiments(exp_number, title, description)
VALUES (1, 'Onboarding revamp', 'nicer design in the onboarding');
Enter fullscreen mode Exit fullscreen mode

Step 3 - Create a table that links experiments, users and variants

We need a way to link a user with a variant for each experiment i.e. A or B.

Example: For experiment with exp_number == 1 (our onboarding revamp), we want to show user with id == e486cb46-283e-41bb-b65c-95dab7a39ed4 (jack) the variant of B (the new designed screen)

To do this we create a table that links user and experiment:

create table user_experiments (
  id uuid not null primary key DEFAULT uuid_generate_v1(),
  experiment_id uuid references experiments on delete cascade not null,
  user_id uuid references profiles,
  updated_at timestamp with time zone,
  inserted_at   timestamp with time zone default timezone('utc'::text, now()) not null,
  variant experiment_variants not null
);
Enter fullscreen mode Exit fullscreen mode

Notice that experiment_id and user_id are foreign keys.

Variant in our case is always either A or B. We can lock this in with an enum

create type experiment_variants as enum ('A', 'B');
Enter fullscreen mode Exit fullscreen mode

Step 4 - Setup Supabase Edge Functions

Now it’s time to play with Supabase’s Edge Functions. They are serverless functions and run Deno(a node variant).

Here is a basic template for a function

import { serve } from "https://deno.land/std@0.168.0/http/server.ts"
import { createClient, SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2"

serve(async (req) => {
  const supabaseClient = createClient(
    // Supabase API URL - env var exported by default.
    Deno.env.get('SUPABASE_URL') ?? '',
      // Supabase API ANON KEY - env var exported by default.
    Deno.env.get('SUPABASE_ANON_KEY') ?? '',
  );

  // If you want to use Authentication
  // const {
  //   data: { user },
  // } = await supabaseClient.auth.getUser()

  const body = await req.json()

  return new Response(
    JSON.stringify({
      'TODO'
    }),
    { headers: { "Content-Type": "application/json" } },
  )
})
Enter fullscreen mode Exit fullscreen mode

First off we create our client using the environment credentials.

Then we parse our body for later use and have an empty return statement to fill out later with the variant.

Step 5 - Fetch the experiment using the experiment number

async function getExpId(supabaseClient: SupabaseClient, expNumber: number) {
  const { data, error } = await supabaseClient
    .from('experiments')
    .select('id')
    .eq('exp_number', expNumber)
    .limit(1)
    .maybeSingle();

  if (error) throw error
  return  data
}
Enter fullscreen mode Exit fullscreen mode

In the app we will do an API call with exp_number as a param. Why use the exp_number because it is easier to read than a UUID. But feel free to use your experiment ID if you want to.

Step 6 - Get the number of variants assigned to our users to determine which version this user should get

async function getCountsForExperimentNumber(supabaseClient: SupabaseClient, expId: string) {
  const variantA = await supabaseClient
    .from('user_experiments')
    .select('experiment_id,variant', { count: 'exact', head: true})
    .eq('experiment_id', expId)
    .eq('variant', 'A')
  const variantB = await supabaseClient
    .from('user_experiments')
    .select('experiment_id,variant', { count: 'exact', head: true})
    .eq('experiment_id', expId)
    .eq('variant', 'B')

  if (variantA.error || variantB.error) throw variantA.error || variantB.error;

  return {
    A: variantA.count || 0,
    B: variantB.count || 0,
  };
}
Enter fullscreen mode Exit fullscreen mode

In this case, we have two variants for each experiment.

We do a simple count on both versions and return one object to our parent function containing both counts.

Step 7 - Call our variants counter function and store the users version

async function getUserVariant(supabaseClient: SupabaseClient, expId: string, userId: string) {
  let expVariantCounts = await getCountsForExperimentNumber(supabaseClient, expId);
  variant = expVariantCounts.A >= expVariantCounts.B ? 'B' : 'A'
  await storeUserVariant(supabaseClient, expId, userId, variant);
  return variant;
}

async function storeUserVariant(supabaseClient: SupabaseClient, expId: string, userId: string, variant: string) {
  await supabaseClient
    .from('user_experiments')
    .insert({
      experiment_id: expId,
      user_id: userId,
      variant
    })
}
Enter fullscreen mode Exit fullscreen mode

Now we can use our getCountsForExperimentNumber function ****to work out which variant should be allocated to our user.

If version A’s count is greater than or equal to version B’s count → we allocate version B, otherwise we allocate version A.

Allocating versions logic

Then once we have our users requested experiment variant, save it to our database.

Step 8 - Optional

async function getUserVariant(supabaseClient: SupabaseClient, expId: string, userId: string) {
  // OPTIONAL - you can use an extra call to see if the user has already been given a variant. But you could save the variant locally so you don't need to fetch it everytime 
  let variant = await getUserStoredVariant(supabaseClient, expId, userId);

  if (variant) return variant;

  let expVariantCounts = await getCountsForExperimentNumber(supabaseClient, expId);
  variant = expVariantCounts.A >= expVariantCounts.B ? 'B' : 'A'
  await storeUserVariant(supabaseClient, expId, userId, variant);
  return variant;
}

async function getUserStoredVariant(supabaseClient: SupabaseClient, expId: string, userId: string) {
  const { data, error } = await supabaseClient
    .from('user_experiments')
    .select('variant')
    .eq('experiment_id', expId)
    .eq('user_id', userId)
    .limit(1)
    .maybeSingle();

    if (error) throw error
    return  data?.variant
}
Enter fullscreen mode Exit fullscreen mode

It is good practice to check if a user already made this request for an experiment version.

If they did → return the original variant, if not → assign a new one.

Why is this optional?

On the device you could easily store what version the user was given before and not perform the same call again.

Step 9 - Update your main function

serve(async (req) => {
  const supabaseClient = createClient(
    // Supabase API URL - env var exported by default.
    Deno.env.get('SUPABASE_URL') ?? '',
      // Supabase API ANON KEY - env var exported by default.
    Deno.env.get('SUPABASE_ANON_KEY') ?? '',
  );

  // If you want to use Authentication
  // const {
  //   data: { user },
  // } = await supabaseClient.auth.getUser()

  const body = await req.json()
  const experiment = await getExpId(supabaseClient, body.exp_number);
  const variant = await getUserVariant(supabaseClient, experiment.id, body.user_id);

  return new Response(
    JSON.stringify({
      variant
    }),
    { headers: { "Content-Type": "application/json" } },
  )
})
Enter fullscreen mode Exit fullscreen mode

Now tie it all together. You get the existing experiment ID based on the experiment number sent in the API call from the users device.

Then we request a variant for that user and return it in our Response for our user to store on their device.

Step 10 - Deploy

Refer to https://supabase.com/docs/guides/functions/quickstart on how deploy edge functions.

Step 11 - Make the request

Make request

Not covered - Success metrics

In this tutorial, we are constrained on time so we are not going to cover analysis in too much detail.

But to analyse how well each experiment variation performs, you would want to trigger success events that know which variant of the app the user has.

If you are doing this in Supabase you might have a table that looks something like this

create table events (
  id uuid not null primary key DEFAULT uuid_generate_v1(),
  user_id uuid references profiles on delete cascade,
  title text,
  inserted_at timestamp with time zone default timezone('utc'::text, now()) not null
);
Enter fullscreen mode Exit fullscreen mode

From here you could decide what constitutes a success, for example a sign_up event or.

Then you could count how many sign_up you get for the A variant and B variant.

Combining this with some calculations that take into account statistical significance (here’s a calculator), you should be able to work out whether A or B performed better (or if there is no significant difference)

If there’s interest, we’ll to add more detail on this in a future post.

Limitations:

  • This code doesn’t work with authentication out of the box, although there is some boilerplate code in the serve function for you to try out
  • Not randomly allocating A or B - it’s rotating (maybe a limitation)
  • Only supports 2 variants

Full code

// Follow this setup guide to integrate the Deno language server with your editor:
// https://deno.land/manual/getting_started/setup_your_environment
// This enables autocomplete, go to definition, etc.
import { serve } from "https://deno.land/std@0.168.0/http/server.ts"
import { createClient, SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2"

serve(async (req) => {
  const supabaseClient = createClient(
    // Supabase API URL - env var exported by default.
    Deno.env.get('SUPABASE_URL') ?? '',
      // Supabase API ANON KEY - env var exported by default.
    Deno.env.get('SUPABASE_ANON_KEY') ?? '',
  );

  // If you want to use Authentication
  // const {
  //   data: { user },
  // } = await supabaseClient.auth.getUser()

  const body = await req.json()
  const experiment = await getExpId(supabaseClient, body.exp_number);
  const variant = await getUserVariant(supabaseClient, experiment.id, body.user_id);

  return new Response(
    JSON.stringify({
      variant
    }),
    { headers: { "Content-Type": "application/json" } },
  )
})

async function storeUserVariant(supabaseClient: SupabaseClient, expId: string, userId: string, variant: string) {
  await supabaseClient
    .from('user_experiments')
    .insert({
      experiment_id: expId,
      user_id: userId,
      variant
    })
}

async function getExpId(supabaseClient: SupabaseClient, expNumber: number) {
  const { data, error } = await supabaseClient
    .from('experiments')
    .select('id')
    .eq('exp_number', expNumber)
    .limit(1)
    .maybeSingle();

  if (error) throw error
  return  data
}

async function getUserVariant(supabaseClient: SupabaseClient, expId: string, userId: string) {
  // OPTIONAL - you can use an extra call to see if the user has already been given a variant. But you could save the variant locally so you don't need to fetch it everytime 
  let variant = await getUserStoredVariant(supabaseClient, expId, userId);

  if (variant) return variant;

  let expVariantCounts = await getCountsForExperimentNumber(supabaseClient, expId);
  variant = expVariantCounts.A >= expVariantCounts.B ? 'B' : 'A'
  await storeUserVariant(supabaseClient, expId, userId, variant);
  return variant;
}

async function getUserStoredVariant(supabaseClient: SupabaseClient, expId: string, userId: string) {
  const { data, error } = await supabaseClient
    .from('user_experiments')
    .select('variant')
    .eq('experiment_id', expId)
    .eq('user_id', userId)
    .limit(1)
    .maybeSingle();

    if (error) throw error
    return  data?.variant
}

async function getCountsForExperimentNumber(supabaseClient: SupabaseClient, expId: string) {
  const variantA = await supabaseClient
    .from('user_experiments')
    .select('experiment_id,variant', { count: 'exact', head: true})
    .eq('experiment_id', expId)
    .eq('variant', 'A')
  const variantB = await supabaseClient
    .from('user_experiments')
    .select('experiment_id,variant', { count: 'exact', head: true})
    .eq('experiment_id', expId)
    .eq('variant', 'B')

  if (variantA.error || variantB.error) throw variantA.error || variantB.error;

  return {
    A: variantA.count || 0,
    B: variantB.count || 0,
  };
}
Enter fullscreen mode Exit fullscreen mode

This was written by Jack Bridger & Nick Smet

If you like this article, I also host a podcast on developer tools.

Top comments (0)