Back to Blog

How to Build a Creator-Brand Marketplace: Architecture and Challenges

How to Build a Creator-Brand Marketplace: Architecture and Challenges

Two-sided marketplaces are among the hardest products to build. You are not solving one problem — you are solving two simultaneously while making sure both sides find enough value to stick around. When we built Vincelio, a creator-brand marketplace for the Latin American influencer marketing space, we ran headfirst into every classic marketplace challenge plus a few that are unique to the region.

Here is what we learned building it.

The Two-Sided Problem

A marketplace is useless without supply and demand. Brands will not sign up if there are no creators. Creators will not sign up if there are no campaigns. This is the cold start problem, and it shapes every technical and product decision you make.

For Vincelio, we solved it by building the creator side first. The creator onboarding flow was designed to be valuable even without brands — creators got a public profile page, analytics on their social media presence, and a portfolio they could share with anyone. This meant we could recruit creators before a single brand was on the platform.

The brand side was built second, and it launched with enough supply (creator profiles) to feel like a real marketplace from day one.

This sequencing matters because it affects your data model. If you build both sides in parallel, you tend to create a symmetric data model where creators and brands are interchangeable “users.” If you build one side first, you create a model that is richer on that side — and that is actually correct, because the two sides of a marketplace have fundamentally different needs.

Data Architecture

Vincelio runs on SvelteKit with Supabase (PostgreSQL) on the backend. The core data model breaks down into four domains: profiles, campaigns, contracts, and payments.

-- Creator profiles are the supply side
create table creator_profiles (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) unique not null,
  display_name text not null,
  slug text unique not null,
  bio text,
  location_country text not null,
  location_city text,
  languages text[] not null default '{"es"}',
  categories text[] not null default '{}',
  instagram_handle text,
  tiktok_handle text,
  youtube_handle text,
  follower_count_total integer not null default 0,
  engagement_rate numeric(5,2),
  verified_at timestamptz,
  profile_complete boolean generated always as (
    display_name is not null
    and bio is not null
    and array_length(categories, 1) > 0
    and (instagram_handle is not null or tiktok_handle is not null or youtube_handle is not null)
  ) stored,
  created_at timestamptz not null default now()
);

-- Brand profiles are the demand side
create table brand_profiles (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) unique not null,
  company_name text not null,
  slug text unique not null,
  industry text not null,
  logo_url text,
  website_url text,
  billing_country text not null,
  verified_at timestamptz,
  created_at timestamptz not null default now()
);

-- Campaigns are how brands find creators
create table campaigns (
  id uuid primary key default gen_random_uuid(),
  brand_id uuid references brand_profiles(id) not null,
  title text not null,
  description text not null,
  deliverables jsonb not null default '[]',
  budget_min numeric(10,2),
  budget_max numeric(10,2),
  currency text not null default 'MXN',
  target_categories text[] not null,
  target_countries text[] not null,
  target_min_followers integer,
  status text not null default 'draft'
    check (status in ('draft', 'active', 'paused', 'completed', 'cancelled')),
  applications_count integer not null default 0,
  created_at timestamptz not null default now()
);

-- Applications connect creators to campaigns
create table campaign_applications (
  id uuid primary key default gen_random_uuid(),
  campaign_id uuid references campaigns(id) not null,
  creator_id uuid references creator_profiles(id) not null,
  proposed_rate numeric(10,2) not null,
  currency text not null default 'MXN',
  pitch text not null,
  status text not null default 'pending'
    check (status in ('pending', 'accepted', 'rejected', 'withdrawn')),
  created_at timestamptz not null default now(),
  unique (campaign_id, creator_id)
);

Notice the profile_complete generated column on creator profiles. This is a PostgreSQL 12+ feature that computes a boolean based on other columns in the same row. We use it to filter incomplete profiles out of search results without running a complex WHERE clause every time. Creators with incomplete profiles see a checklist prompting them to fill in missing fields; brands never see incomplete profiles in their search results.

Social media marketing analytics and engagement metrics on a dashboard

Creator Verification

Trust is critical in a marketplace. Brands need to know that the creator they are paying actually has the audience they claim. We built a multi-step verification system:

Step 1: Email verification. Standard, handled by Supabase Auth.

Step 2: Social media connection. Creators connect at least one social media account via OAuth. We pull follower counts, engagement rates, and recent content directly from the platform APIs. This prevents creators from inflating their numbers.

Step 3: Identity verification. For creators above a certain follower threshold (or who want a “verified” badge), we collect government ID and run a basic identity check. This is important in the LATAM market where influencer fraud is a known issue.

The verification status is stored as a nullable timestamp (verified_at). Null means unverified. A timestamp means verified as of that date. We re-verify social media metrics monthly via a scheduled job:

// src/lib/server/jobs/refresh-creator-metrics.ts
import { createClient } from '@supabase/supabase-js';

export async function refreshCreatorMetrics() {
  const supabase = createClient(
    process.env.SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!
  );

  // Fetch creators who need a metrics refresh (last updated > 30 days ago)
  const { data: creators } = await supabase
    .from('creator_profiles')
    .select('id, instagram_handle, tiktok_handle, youtube_handle')
    .not('verified_at', 'is', null)
    .lt('metrics_updated_at', new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString());

  if (!creators) return;

  for (const creator of creators) {
    const metrics = await fetchSocialMetrics(creator);

    await supabase
      .from('creator_profiles')
      .update({
        follower_count_total: metrics.totalFollowers,
        engagement_rate: metrics.engagementRate,
        metrics_updated_at: new Date().toISOString(),
      })
      .eq('id', creator.id);
  }
}

Campaign Workflow

The campaign lifecycle is the core business process. Here is how it flows:

  1. Brand creates a campaign with deliverables (e.g., “2 Instagram Reels, 1 Story”), budget range, target audience criteria, and timeline.

  2. Creators discover and apply. Campaigns appear in creator search results filtered by their categories, location, and follower count. Creators submit a pitch and proposed rate.

  3. Brand reviews applications and accepts or rejects them. Acceptance creates a contract.

  4. Contract execution. The creator produces content, submits it for review, the brand approves or requests revisions, and the content goes live.

  5. Payment release. Once the brand confirms deliverables are complete, payment is released to the creator.

Each step has its own state machine and validation rules. The contract alone has seven possible states:

type ContractStatus =
  | 'pending'      // Just created from accepted application
  | 'active'       // Both parties confirmed terms
  | 'in_progress'  // Creator is producing content
  | 'in_review'    // Content submitted for brand review
  | 'revision'     // Brand requested changes
  | 'completed'    // Deliverables approved, payment released
  | 'disputed';    // One party raised a dispute

// Valid state transitions
const transitions: Record<ContractStatus, ContractStatus[]> = {
  pending:     ['active', 'disputed'],
  active:      ['in_progress', 'disputed'],
  in_progress: ['in_review', 'disputed'],
  in_review:   ['completed', 'revision', 'disputed'],
  revision:    ['in_review', 'disputed'],
  completed:   [],
  disputed:    ['completed'], // Only after resolution
};

We enforce these transitions in the database with a trigger that rejects invalid state changes. This prevents bugs in the application layer from corrupting the contract state.

Brand and creator shaking hands in a collaborative partnership meeting

Payments with Stripe Connect

Marketplace payments are complex because money flows from the buyer (brand) through the platform to the seller (creator), and the platform takes a cut. Stripe Connect handles this, but the integration is not trivial.

We use the “destination charges” model:

// src/lib/server/payments/create-charge.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function createCampaignCharge(
  contractId: string,
  amount: number,
  currency: string,
  creatorStripeAccountId: string,
  platformFeePercent: number = 10
) {
  const platformFee = Math.round(amount * (platformFeePercent / 100));

  const paymentIntent = await stripe.paymentIntents.create({
    amount,
    currency: currency.toLowerCase(),
    transfer_data: {
      destination: creatorStripeAccountId,
    },
    application_fee_amount: platformFee,
    metadata: {
      contract_id: contractId,
    },
  });

  return paymentIntent;
}

A few challenges specific to the LATAM market:

Multi-currency. Campaigns can be priced in MXN (Mexican pesos), COP (Colombian pesos), BRL (Brazilian reais), or USD. Stripe handles the currency conversion, but we need to display amounts in the correct currency throughout the UI and handle edge cases like a Mexican creator working with a Colombian brand.

Payout timing. Stripe payouts to LATAM bank accounts can take 5-7 business days versus 2 days in the US. We set clear expectations in the UI so creators are not surprised by the delay.

Tax implications. In Mexico, influencer income is taxable and many creators need to issue an “invoice” (factura) for tax purposes. We integrated with a local e-invoicing provider so creators can generate tax-compliant invoices directly from the platform.

Spanish-First UX

Vincelio targets the Latin American market, which means the primary language is Spanish. This sounds obvious, but it has real technical implications that go beyond translation.

Content direction and formatting. Spanish text tends to be 15-25% longer than English. UI components designed with English text in mind will overflow or look cramped with Spanish translations. We designed every component with Spanish as the primary language and only then verified it worked in English.

Locale-aware formatting. Dates, numbers, and currencies all format differently. In Mexico, the date format is DD/MM/YYYY, numbers use commas for thousands and periods for decimals (opposite of the US), and the currency symbol goes before the number with a space: $ 1,500.00 MXN.

// src/lib/utils/format.ts
export function formatCurrency(amount: number, currency: string, locale = 'es-MX'): string {
  return new Intl.NumberFormat(locale, {
    style: 'currency',
    currency,
    minimumFractionDigits: 2,
  }).format(amount / 100); // amounts stored in cents
}

export function formatDate(date: string | Date, locale = 'es-MX'): string {
  return new Intl.DateTimeFormat(locale, {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  }).format(new Date(date));
}

// formatCurrency(150000, 'MXN') => "$1,500.00"
// formatDate('2026-02-26') => "26 de febrero de 2026"

Cultural nuances in UX copy. Spanish has formal (usted) and informal (tu) address. We chose “tu” for creator-facing copy (casual, peer-to-peer) and “usted” for brand-facing copy (professional). This distinction does not exist in English and is easy to get wrong if your team does not include native speakers. We worked with a LATAM-based copywriter for all UI text.

Messaging and Notifications

Communication between brands and creators happens through an in-app messaging system built on Supabase Realtime. We chose this over a third-party chat service because message threads are tightly coupled to contracts and campaigns — scoping messages to a contract keeps conversations focused and gives us a complete audit trail for dispute resolution.

Notifications are multi-channel: in-app (Supabase Realtime), email (via Resend), and eventually push for the mobile app. We use a dispatch pattern that decouples events from delivery channels — each notification type checks user preferences before sending via email, so users control their own notification volume.

Content creator recording video with professional equipment for a brand campaign

Lessons from Building a Two-Sided Marketplace

Build one side at a time. Trying to build both sides of the marketplace simultaneously leads to a product that is mediocre for everyone. Pick the harder side (usually supply), make it great, and then build the demand side.

State machines everywhere. Campaigns, contracts, applications, and payments all have complex lifecycles. Explicit state machines with defined transitions catch bugs that free-form status updates would miss.

Localization is not translation. Building for LATAM meant rethinking UI spacing, date formats, currency handling, and even the tone of voice. It is a design problem, not just a string replacement problem.

Payments are the hardest part. Multi-currency, cross-border payments with platform fees, tax compliance, and payout delays consumed about 30% of the total development effort. If you are building a marketplace, budget heavily for payments.

We wrote about our broader approach to projects like this in Why We Do Not Do Fixed-Price Contracts — Vincelio was a perfect example of a project where scope evolved significantly as we learned about the LATAM market.

If you are building a marketplace and want to talk through the architecture, reach out at [email protected].