Before the Code, There’s a Domain

Digital advertising powers much of the modern internet. Behind every banner, sidebar promotion, or sponsored listing sits a campaign management system, software that decides which ad to show, to whom, and when. These systems are not reserved for Google or Meta. Any product that sells ad space, promotes internal content, or manages affiliate placements needs the same core abstractions.

This series explores the architecture of a complete dynamic ad campaign system in SvelteKit. Across seven articles, you will see how domain modeling, management interfaces, ad resolution pipelines, real-time analytics, A/B testing, and budget pacing fit together into a production-aware design. The code throughout is illustrative, designed to teach the patterns and trade-offs, not to be copied verbatim into a project.

This first article lays the groundwork: the domain, the data model, the database schema, and the project structure that the rest of the series depends on.

Series Overview

This is Part 1 of 7 in the Ad Campaign Architecture series:

  1. Foundations (you are here) - Domain modeling, schema design, project structure
  2. Campaign CRUD - Form action patterns, validation, status machines
  3. The Rendering Engine - Targeting pipeline, weighted selection, ad serving
  4. Real-Time Dashboard - Event tracking, SSE streaming, live metrics
  5. A/B Testing & Optimization - Traffic splitting, statistical significance
  6. Budget & Scheduling - Pacing algorithms, lifecycle automation
  7. Production Hardening - Database indexing, Redis caching, rate limiting, GDPR

What This Architecture Is Actually For

Before diving into the domain, it is worth being direct about the context in which this system makes sense and the context in which it does not. Web advertising has changed considerably over the last decade, and building an ad system today without understanding that landscape is building without a map.

The headline reality is that a significant share of users never see web ads at all. Ad blocker adoption on desktop sits somewhere between 30 and 45 percent depending on the survey, and among developer and technical audiences (the exact audience this site targets) that number is closer to 60 to 70 percent. This is not going away.

Browser-level tracking protection, Safari’s Intelligent Tracking Prevention, Firefox’s enhanced privacy defaults, and Chrome’s own Coalition for Better Ads enforcement have all eroded the infrastructure that third-party programmatic advertising depends on. The implicit deal that powered much of the web - free content in exchange for ad views - is broken on one side. The views are not happening.

Add to that the legal layer. GDPR in Europe and CPRA in California require explicit user consent before you collect behavioural data for advertising. The consent rates in European markets for personalised tracking typically run between 30 and 60 percent depending on how the consent banner is designed. If your users are global, you will lose the majority of your potential European inventory before serving a single impression.

So why does this series exist?

Two reasons. First, the patterns in this architecture transfer far beyond banner advertising. A system that resolves which creative to show based on device, location, schedule, frequency, and budget is the same system that powers feature flags with targeting, personalised content recommendations, A/B tests on any page element, internal house promotions, and sponsor acknowledgements. The domain model and resolution pipeline you build here are genuinely reusable.

Second, first-party advertising for specialist audiences still works. The formats that hold up are contextually relevant, non-interruptive, server-rendered (no layout shift, no async flash of content), and clearly labelled. Direct sponsorships from companies whose products the audience actually uses such as developer tools, infrastructure services, technical books, produce click-through rates an order of magnitude higher than generic display inventory, precisely because the match between audience and offer is real rather than inferred from surveillance data.

This site uses a version of this system. The architecture in this series is not hypothetical. Sponsorship placements are resolved server-side based on page context, served without JavaScript tracking dependencies, and measured using first-party analytics that do not require third-party cookies.

The consent story is straightforward: session-scoped frequency caps, no cross-site tracking, no personal data sold to third parties. The ad blocker problem shrinks considerably when the ad is not distinguishable from the editorial context by the heuristics blockers use, and when the content surrounding it is worth reading.

The system is most worth building if you own your inventory and have a direct relationship with your sponsors. It is least worth building if your plan is to plug into a programmatic exchange and sell remnant inventory (unsold ad space) at $0.50 CPM. This series is designed for the former.

Let’s get into the domain.


The Domain: How Ad Systems Work

Before writing any code, you need a clear mental model of how ad campaign systems operate. Even if you have never worked in ad tech, the concepts map cleanly to problems you have already solved - product catalogs, content management, feature flags. The vocabulary is different, but the patterns are familiar.

The Hierarchy

Every ad system organizes its data in a hierarchy. The exact depth varies by platform, but the core structure is remarkably consistent across the industry:

Loading diagram...

Let’s define each level precisely:

Advertiser - The entity paying for ads. In a self-serve platform, this is a registered business. In an internal system, this might be a department or product team. Every campaign belongs to an advertiser.

Campaign - A marketing initiative with a defined goal, budget, and time frame. “Summer Sale 2026” or “New Feature Launch” are campaigns. A campaign groups related ad groups under a shared budget and schedule.

Ad Group - A targeting strategy within a campaign. One campaign might have an ad group targeting mobile users in Europe and another targeting desktop users in North America. Ad groups control who sees the ads and where they appear.

Creative - The actual content shown to the user: an image, a text block, an HTML snippet, or a video. Each ad group can have multiple creatives, enabling A/B testing and rotation.

Placement - A named location on your site where ads can appear: “homepage-hero”, “sidebar-top”, “article-footer”. Placements are the supply side - the available inventory.

Think of It Like a File System

If the hierarchy feels abstract, think of it as a file system: Advertiser → Campaign → Ad Group → Creative is like Company → Project → Folder → File. Each level adds specificity and scope.

The Lifecycle of an Ad Request

When a user visits a page that has ad placements, the system must decide what to show. This decision happens in milliseconds and involves several steps:

Loading diagram...
  1. Request arrives - A user loads a page that includes one or more ad placements.
  2. Eligible ads are queried - The system finds all active campaigns that target the requested placement and match the user’s context (device, location, time).
  3. Targeting filters apply - Ads that do not match the user’s attributes or have exhausted their frequency caps are eliminated.
  4. Budget is checked - Ads from campaigns that have spent their daily or lifetime budget are excluded.
  5. A winner is selected - From the remaining candidates, one creative is chosen based on priority, weight, or A/B test allocation.
  6. The ad renders - The selected creative is included in the server-rendered page.
  7. Events are tracked - An impression is logged when the ad becomes visible. A click is logged if the user interacts.

This flow is the backbone of the entire series. Each subsequent article zooms into one or more of these steps.


Data Model Design

With the domain understood, let’s translate it into TypeScript types and a database schema. Good data modeling at this stage prevents painful migrations later.

Core Types

These are the TypeScript types that represent the domain entities. They will be used throughout the application - in load functions, form actions, API routes, and components.

// src/lib/types/ads.ts

/** Campaign status reflects the lifecycle state */
export type CampaignStatus = 'draft' | 'active' | 'paused' | 'completed' | 'archived'

/** How the budget is allocated */
export type BudgetType = 'daily' | 'lifetime'

/** What the campaign optimizes for */
export type CampaignGoal = 'impressions' | 'clicks' | 'conversions'

/** Supported creative formats */
export type CreativeFormat = 'image' | 'text' | 'html' | 'video'

/** Device targeting options */
export type DeviceType = 'desktop' | 'mobile' | 'tablet' | 'all'

// ─── Advertiser ────────────────────────────────────────

export interface Advertiser {
	id: string
	name: string
	email: string
	company: string
	createdAt: Date
	updatedAt: Date
}

// ─── Campaign ──────────────────────────────────────────

export interface Campaign {
	id: string
	advertiserId: string
	name: string
	description: string
	status: CampaignStatus
	goal: CampaignGoal
	budgetType: BudgetType
	budgetAmount: number // in cents to avoid floating-point issues
	budgetSpent: number // in cents
	startDate: Date
	endDate: Date | null // null means no end date
	createdAt: Date
	updatedAt: Date
}

// ─── Ad Group ──────────────────────────────────────────

export interface AdGroup {
	id: string
	campaignId: string
	name: string
	isActive: boolean
	targeting: TargetingRules
	priority: number // higher = more likely to win
	createdAt: Date
	updatedAt: Date
}

export interface TargetingRules {
	placements: string[] // e.g. ['homepage-hero', 'sidebar-top']
	devices: DeviceType[]
	geoCountries: string[] // ISO 3166-1 alpha-2 codes
	geoRegions: string[] // e.g. ['US-CA', 'US-NY']
	scheduleTimezone: string // IANA timezone
	scheduleDays: number[] // 0 = Sunday, 6 = Saturday
	scheduleHoursStart: number // 0-23
	scheduleHoursEnd: number // 0-23
	frequencyCap: number | null // max impressions per user per day
	urlPatterns: string[] // regex patterns to match page URLs
}

// ─── Creative ──────────────────────────────────────────

export interface Creative {
	id: string
	adGroupId: string
	name: string
	format: CreativeFormat
	content: CreativeContent
	weight: number // relative weight for rotation (1-100)
	isActive: boolean
	clickUrl: string
	impressionUrl: string | null // optional third-party tracking pixel
	createdAt: Date
	updatedAt: Date
}

export type CreativeContent =
	| { type: 'image'; src: string; alt: string; width: number; height: number }
	| { type: 'text'; headline: string; body: string; ctaText: string }
	| { type: 'html'; markup: string }
	| { type: 'video'; src: string; poster: string; duration: number }

// ─── Placement ─────────────────────────────────────────

export interface Placement {
	id: string
	slug: string // unique identifier like 'homepage-hero'
	name: string // human-readable: 'Homepage Hero Banner'
	description: string
	width: number | null // suggested dimensions
	height: number | null
	allowedFormats: CreativeFormat[]
	isActive: boolean
}

// ─── Events ────────────────────────────────────────────

export interface AdEvent {
	id: string
	type: 'impression' | 'click' | 'conversion'
	campaignId: string
	adGroupId: string
	creativeId: string
	placementId: string
	userId: string | null // anonymous users have null
	sessionId: string
	timestamp: Date
	metadata: Record<string, string> // device, geo, referrer, etc.
}
Why Cents Instead of Dollars?

Budget amounts are stored in cents (integers) instead of dollars (floats). This is standard practice in financial systems because floating-point arithmetic produces rounding errors. 0.1 + 0.2 is 0.30000000000000004 in JavaScript. With cents, 10 + 20 is always 30. Display formatting happens at the UI layer.

Entity Relationships

The relationships between entities are straightforward but worth documenting visually:

Loading diagram...

Key observations about the relationships:

  • One-to-many dominates: an advertiser owns campaigns, a campaign contains ad groups, an ad group has creatives. This makes queries straightforward, you almost always traverse downward from campaign to creative.
  • Many-to-many appears between ad groups and placements: a single ad group can target multiple placements, and a single placement can receive ads from multiple ad groups. The targeting.placements array in the ad group handles this without a join table.
  • Events reference everything: each event records the full context of what happened - which campaign, ad group, creative, and placement were involved. This denormalization is intentional. Event tables grow fast, and you want to avoid joins when querying millions of rows for analytics.

Project Setup

With the domain and data model defined, let’s set up the SvelteKit project with everything needed for the rest of the series.

Directory Structure

The project follows a modular structure that separates concerns cleanly:

src/
├── lib/
│   ├── types/
│   │   └── ads.ts              ← Type definitions (above)
│   ├── server/
│   │   ├── db/
│   │   │   ├── schema.ts       ← Database schema (Drizzle)
│   │   │   ├── client.ts       ← Database client
│   │   │   └── seed.ts         ← Development seed data
│   │   ├── ads/
│   │   │   ├── campaigns.ts    ← Campaign repository
│   │   │   ├── ad-groups.ts    ← Ad group repository
│   │   │   ├── creatives.ts    ← Creative repository
│   │   │   ├── placements.ts   ← Placement repository
│   │   │   ├── resolver.ts     ← Ad selection engine
│   │   │   └── tracker.ts      ← Event tracking
│   │   └── validation/
│   │       └── ads.ts          ← Valibot schemas
│   ├── components/
│   │   └── ads/
│   │       ├── AdSlot.svelte        ← Renders an ad placement
│   │       ├── CampaignForm.svelte  ← Campaign create/edit form
│   │       ├── AdGroupForm.svelte   ← Ad group form
│   │       ├── CreativeForm.svelte  ← Creative form
│   │       ├── MetricsChart.svelte  ← Real-time metrics
│   │       └── ABTestPanel.svelte   ← A/B test controls
│   └── utils/
│       ├── format.ts           ← Currency, date, percentage formatting
│       └── targeting.ts        ← Client-side targeting helpers
├── routes/
│   ├── (app)/
│   │   └── ads/
│   │       ├── +page.svelte              ← Campaign dashboard
│   │       ├── +page.server.ts
│   │       ├── campaigns/
│   │       │   ├── new/
│   │       │   │   ├── +page.svelte      ← Create campaign
│   │       │   │   └── +page.server.ts
│   │       │   └── [id]/
│   │       │       ├── +page.svelte      ← Campaign detail
│   │       │       ├── +page.server.ts
│   │       │       └── ad-groups/
│   │       │           └── [groupId]/
│   │       │               ├── +page.svelte
│   │       │               └── +page.server.ts
│   │       └── placements/
│   │           ├── +page.svelte
│   │           └── +page.server.ts
│   └── api/
│       └── ads/
│           ├── serve/+server.ts          ← Ad serving endpoint
│           ├── track/+server.ts          ← Event tracking endpoint
│           └── metrics/+server.ts        ← Metrics SSE endpoint
Why the (app) Route Group?

The (app) directory is a SvelteKit route group. It does not affect the URL - /ads/campaigns/new not /(app)/ads/campaigns/new. Route groups let you apply a shared layout (with navigation, sidebar, etc.) to a set of pages without adding a URL segment. The ad management pages share a layout with navigation, while the API routes do not.

Database Schema

We use Drizzle ORM for type-safe database access. Here is the complete schema:

// src/lib/server/db/schema.ts

import { pgTable, text, integer, boolean, timestamp, json, pgEnum } from 'drizzle-orm/pg-core'
import { relations } from 'drizzle-orm'

// ─── Enums ─────────────────────────────────────────────

export const campaignStatusEnum = pgEnum('campaign_status', [
	'draft',
	'active',
	'paused',
	'completed',
	'archived'
])

export const budgetTypeEnum = pgEnum('budget_type', ['daily', 'lifetime'])

export const campaignGoalEnum = pgEnum('campaign_goal', ['impressions', 'clicks', 'conversions'])

export const creativeFormatEnum = pgEnum('creative_format', ['image', 'text', 'html', 'video'])

export const eventTypeEnum = pgEnum('event_type', ['impression', 'click', 'conversion'])

// ─── Tables ────────────────────────────────────────────

export const advertisers = pgTable('advertisers', {
	id: text('id').primaryKey(),
	name: text('name').notNull(),
	email: text('email').notNull().unique(),
	company: text('company').notNull(),
	createdAt: timestamp('created_at').defaultNow().notNull(),
	updatedAt: timestamp('updated_at').defaultNow().notNull()
})

export const campaigns = pgTable('campaigns', {
	id: text('id').primaryKey(),
	advertiserId: text('advertiser_id')
		.notNull()
		.references(() => advertisers.id),
	name: text('name').notNull(),
	description: text('description').notNull().default(''),
	status: campaignStatusEnum('status').notNull().default('draft'),
	goal: campaignGoalEnum('goal').notNull().default('impressions'),
	budgetType: budgetTypeEnum('budget_type').notNull().default('daily'),
	budgetAmount: integer('budget_amount').notNull().default(0),
	budgetSpent: integer('budget_spent').notNull().default(0),
	startDate: timestamp('start_date').notNull(),
	endDate: timestamp('end_date'),
	createdAt: timestamp('created_at').defaultNow().notNull(),
	updatedAt: timestamp('updated_at').defaultNow().notNull()
})

export const adGroups = pgTable('ad_groups', {
	id: text('id').primaryKey(),
	campaignId: text('campaign_id')
		.notNull()
		.references(() => campaigns.id, { onDelete: 'cascade' }),
	name: text('name').notNull(),
	isActive: boolean('is_active').notNull().default(true),
	targeting: json('targeting').notNull().$type<TargetingRules>(),
	priority: integer('priority').notNull().default(1),
	createdAt: timestamp('created_at').defaultNow().notNull(),
	updatedAt: timestamp('updated_at').defaultNow().notNull()
})

export const creatives = pgTable('creatives', {
	id: text('id').primaryKey(),
	adGroupId: text('ad_group_id')
		.notNull()
		.references(() => adGroups.id, { onDelete: 'cascade' }),
	name: text('name').notNull(),
	format: creativeFormatEnum('format').notNull(),
	content: json('content').notNull().$type<CreativeContent>(),
	weight: integer('weight').notNull().default(50),
	isActive: boolean('is_active').notNull().default(true),
	clickUrl: text('click_url').notNull(),
	impressionUrl: text('impression_url'),
	createdAt: timestamp('created_at').defaultNow().notNull(),
	updatedAt: timestamp('updated_at').defaultNow().notNull()
})

export const placements = pgTable('placements', {
	id: text('id').primaryKey(),
	slug: text('slug').notNull().unique(),
	name: text('name').notNull(),
	description: text('description').notNull().default(''),
	width: integer('width'),
	height: integer('height'),
	allowedFormats: json('allowed_formats').notNull().$type<string[]>().default([]),
	isActive: boolean('is_active').notNull().default(true)
})

export const adEvents = pgTable('ad_events', {
	id: text('id').primaryKey(),
	type: eventTypeEnum('type').notNull(),
	campaignId: text('campaign_id')
		.notNull()
		.references(() => campaigns.id),
	adGroupId: text('ad_group_id')
		.notNull()
		.references(() => adGroups.id),
	creativeId: text('creative_id')
		.notNull()
		.references(() => creatives.id),
	placementId: text('placement_id')
		.notNull()
		.references(() => placements.id),
	userId: text('user_id'),
	sessionId: text('session_id').notNull(),
	timestamp: timestamp('timestamp').defaultNow().notNull(),
	metadata: json('metadata').notNull().$type<Record<string, string>>().default({})
})

// ─── Relations ─────────────────────────────────────────

export const advertiserRelations = relations(advertisers, ({ many }) => ({
	campaigns: many(campaigns)
}))

export const campaignRelations = relations(campaigns, ({ one, many }) => ({
	advertiser: one(advertisers, {
		fields: [campaigns.advertiserId],
		references: [advertisers.id]
	}),
	adGroups: many(adGroups),
	events: many(adEvents)
}))

export const adGroupRelations = relations(adGroups, ({ one, many }) => ({
	campaign: one(campaigns, {
		fields: [adGroups.campaignId],
		references: [campaigns.id]
	}),
	creatives: many(creatives)
}))

export const creativeRelations = relations(creatives, ({ one }) => ({
	adGroup: one(adGroups, {
		fields: [creatives.adGroupId],
		references: [adGroups.id]
	})
}))

// Import types used in JSON columns
import type { TargetingRules, CreativeContent } from '$lib/types/ads'

Validation Schemas

Every form submission in this system will be validated on the server using Valibot. Here are the core schemas:

// src/lib/server/validation/ads.ts

import * as v from 'valibot'

export const CampaignSchema = v.object({
	name: v.pipe(
		v.string(),
		v.minLength(3, 'Campaign name must be at least 3 characters'),
		v.maxLength(100, 'Campaign name must be under 100 characters')
	),
	description: v.optional(v.pipe(v.string(), v.maxLength(500))),
	goal: v.picklist(['impressions', 'clicks', 'conversions']),
	budgetType: v.picklist(['daily', 'lifetime']),
	budgetAmount: v.pipe(v.number(), v.minValue(100, 'Minimum budget is $1.00 (100 cents)')),
	startDate: v.pipe(v.string(), v.isoTimestamp()),
	endDate: v.optional(v.pipe(v.string(), v.isoTimestamp()))
})

export const AdGroupSchema = v.object({
	name: v.pipe(
		v.string(),
		v.minLength(2, 'Ad group name must be at least 2 characters'),
		v.maxLength(100)
	),
	priority: v.pipe(v.number(), v.minValue(1), v.maxValue(100)),
	targeting: v.object({
		placements: v.pipe(v.array(v.string()), v.minLength(1, 'Select at least one placement')),
		devices: v.array(v.picklist(['desktop', 'mobile', 'tablet', 'all'])),
		geoCountries: v.array(v.pipe(v.string(), v.length(2))),
		geoRegions: v.array(v.string()),
		scheduleTimezone: v.string(),
		scheduleDays: v.array(v.pipe(v.number(), v.minValue(0), v.maxValue(6))),
		scheduleHoursStart: v.pipe(v.number(), v.minValue(0), v.maxValue(23)),
		scheduleHoursEnd: v.pipe(v.number(), v.minValue(0), v.maxValue(23)),
		frequencyCap: v.nullable(v.pipe(v.number(), v.minValue(1))),
		urlPatterns: v.array(v.string())
	})
})

export const CreativeSchema = v.object({
	name: v.pipe(v.string(), v.minLength(2), v.maxLength(100)),
	format: v.picklist(['image', 'text', 'html', 'video']),
	weight: v.pipe(v.number(), v.minValue(1), v.maxValue(100)),
	clickUrl: v.pipe(v.string(), v.url('Must be a valid URL')),
	impressionUrl: v.optional(v.pipe(v.string(), v.url())),
	content: v.union([
		v.object({
			type: v.literal('image'),
			src: v.pipe(v.string(), v.url()),
			alt: v.pipe(v.string(), v.minLength(1)),
			width: v.pipe(v.number(), v.minValue(1)),
			height: v.pipe(v.number(), v.minValue(1))
		}),
		v.object({
			type: v.literal('text'),
			headline: v.pipe(v.string(), v.minLength(1), v.maxLength(100)),
			body: v.pipe(v.string(), v.minLength(1), v.maxLength(500)),
			ctaText: v.pipe(v.string(), v.minLength(1), v.maxLength(50))
		}),
		v.object({
			type: v.literal('html'),
			markup: v.pipe(v.string(), v.minLength(1))
		}),
		v.object({
			type: v.literal('video'),
			src: v.pipe(v.string(), v.url()),
			poster: v.pipe(v.string(), v.url()),
			duration: v.pipe(v.number(), v.minValue(1))
		})
	])
})

export type CampaignInput = v.InferOutput<typeof CampaignSchema>
export type AdGroupInput = v.InferOutput<typeof AdGroupSchema>
export type CreativeInput = v.InferOutput<typeof CreativeSchema>
Why Valibot?

Valibot offers a modular, tree-shakeable approach to schema validation. Unlike monolithic alternatives, each validation function is an independent import, so your production bundle includes only what you actually use. For a system that validates multiple entity types with different rules, this keeps the overhead minimal.


Utility Functions

A few utility functions will be used across the entire system. Let’s define them now:

// src/lib/utils/format.ts

/**
 * Format cents as a display currency string.
 * 1500 → "$15.00"
 */
export function formatCurrency(cents: number, currency = 'USD'): string {
	return new Intl.NumberFormat('en-US', {
		style: 'currency',
		currency
	}).format(cents / 100)
}

/**
 * Format a number as a percentage with specified decimal places.
 * 0.156 → "15.6%"
 */
export function formatPercent(value: number, decimals = 1): string {
	return `${(value * 100).toFixed(decimals)}%`
}

/**
 * Calculate Click-Through Rate.
 * Returns 0 if there are no impressions (avoids division by zero).
 */
export function calculateCTR(clicks: number, impressions: number): number {
	if (impressions === 0) return 0
	return clicks / impressions
}

/**
 * Format a date for display in campaign lists.
 */
export function formatDate(date: Date): string {
	return new Intl.DateTimeFormat('en-US', {
		month: 'short',
		day: 'numeric',
		year: 'numeric'
	}).format(date)
}

/**
 * Calculate budget utilization as a decimal (0–1).
 */
export function budgetUtilization(spent: number, total: number): number {
	if (total === 0) return 0
	return Math.min(spent / total, 1)
}

The Ad Resolution Pipeline

The most critical piece of the system is the ad resolver; the function that takes a placement and user context and returns the best creative to show. Here is the conceptual pipeline that we will implement fully in Part 3:

Loading diagram...

Each stage is a filter that narrows the candidate set. This funnel-shaped design is intentional, it eliminates the most candidates in the cheapest stages (database query and placement match) before moving to more expensive checks (frequency caps, which require per-user lookups).

Performance Matters

In production ad systems, the resolution pipeline runs on every page load. If your site gets 10,000 page views per minute and each page has 3 placements, that is 30,000 ad decisions per minute. The pipeline must be fast, ideally under 10ms per decision. We will address caching, query optimization, and edge-case handling in Part 3.


Development Seed Data

To develop and test the system, we need realistic seed data. Here is a seed script that creates a complete scenario:

// src/lib/server/db/seed.ts

import { db } from './client'
import { advertisers, campaigns, adGroups, creatives, placements } from './schema'
import crypto from 'node:crypto'

const id = () => crypto.randomUUID()

export async function seed() {
	console.log('🌱 Seeding ad campaign data...')

	// ── Placements ──────────────────────────────────────
	const placementData = [
		{
			id: id(),
			slug: 'homepage-hero',
			name: 'Homepage Hero Banner',
			description: 'Full-width banner at the top of the homepage',
			width: 1200,
			height: 400,
			allowedFormats: ['image', 'html'],
			isActive: true
		},
		{
			id: id(),
			slug: 'sidebar-top',
			name: 'Sidebar Top',
			description: 'Top position in the sidebar',
			width: 300,
			height: 250,
			allowedFormats: ['image', 'text'],
			isActive: true
		},
		{
			id: id(),
			slug: 'article-footer',
			name: 'Article Footer',
			description: 'Banner below article content',
			width: 728,
			height: 90,
			allowedFormats: ['image', 'text', 'html'],
			isActive: true
		}
	]

	await db.insert(placements).values(placementData)

	// ── Advertiser ──────────────────────────────────────
	const advId = id()
	await db.insert(advertisers).values({
		id: advId,
		name: 'Acme Corp',
		email: 'ads@acme.example.com',
		company: 'Acme Corporation'
	})

	// ── Campaign ────────────────────────────────────────
	const campId = id()
	await db.insert(campaigns).values({
		id: campId,
		advertiserId: advId,
		name: 'Summer Sale 2026',
		description: 'Promote our summer product line across the site',
		status: 'active',
		goal: 'clicks',
		budgetType: 'daily',
		budgetAmount: 5000, // $50.00/day
		budgetSpent: 0,
		startDate: new Date('2026-06-01'),
		endDate: new Date('2026-08-31')
	})

	// ── Ad Groups ───────────────────────────────────────
	const desktopGroupId = id()
	const mobileGroupId = id()

	await db.insert(adGroups).values([
		{
			id: desktopGroupId,
			campaignId: campId,
			name: 'Desktop Users - US',
			isActive: true,
			priority: 10,
			targeting: {
				placements: ['homepage-hero', 'sidebar-top'],
				devices: ['desktop'],
				geoCountries: ['US'],
				geoRegions: [],
				scheduleTimezone: 'America/New_York',
				scheduleDays: [1, 2, 3, 4, 5],
				scheduleHoursStart: 8,
				scheduleHoursEnd: 22,
				frequencyCap: 3,
				urlPatterns: []
			}
		},
		{
			id: mobileGroupId,
			campaignId: campId,
			name: 'Mobile Users - Global',
			isActive: true,
			priority: 8,
			targeting: {
				placements: ['homepage-hero', 'article-footer'],
				devices: ['mobile', 'tablet'],
				geoCountries: [],
				geoRegions: [],
				scheduleTimezone: 'UTC',
				scheduleDays: [0, 1, 2, 3, 4, 5, 6],
				scheduleHoursStart: 0,
				scheduleHoursEnd: 23,
				frequencyCap: 5,
				urlPatterns: []
			}
		}
	])

	// ── Creatives ───────────────────────────────────────
	await db.insert(creatives).values([
		{
			id: id(),
			adGroupId: desktopGroupId,
			name: 'Summer Hero - Variant A',
			format: 'image',
			content: {
				type: 'image',
				src: '/ads/summer-hero-a.jpg',
				alt: 'Summer Sale - Up to 50% off',
				width: 1200,
				height: 400
			},
			weight: 60,
			isActive: true,
			clickUrl: 'https://acme.example.com/summer-sale?utm_source=site&utm_creative=hero-a',
			impressionUrl: null
		},
		{
			id: id(),
			adGroupId: desktopGroupId,
			name: 'Summer Hero - Variant B',
			format: 'image',
			content: {
				type: 'image',
				src: '/ads/summer-hero-b.jpg',
				alt: 'Summer Collection Now Available',
				width: 1200,
				height: 400
			},
			weight: 40,
			isActive: true,
			clickUrl: 'https://acme.example.com/summer-sale?utm_source=site&utm_creative=hero-b',
			impressionUrl: null
		},
		{
			id: id(),
			adGroupId: mobileGroupId,
			name: 'Mobile Text Ad',
			format: 'text',
			content: {
				type: 'text',
				headline: 'Summer Sale is Here!',
				body: 'Get up to 50% off our entire summer collection. Free shipping on orders over $50.',
				ctaText: 'Shop Now'
			},
			weight: 100,
			isActive: true,
			clickUrl: 'https://acme.example.com/summer-sale?utm_source=site&utm_creative=mobile-text',
			impressionUrl: null
		}
	])

	console.log('✅ Seed data inserted successfully')
}

What’s Next

You now have the complete foundation for a dynamic ad campaign system:

  • Domain understanding - The hierarchy of advertisers, campaigns, ad groups, creatives, and placements
  • Data model - TypeScript types and a Drizzle database schema that faithfully represent the domain
  • Validation - Valibot schemas that enforce business rules on every form submission
  • Utilities - Formatting and calculation helpers used throughout the system
  • Seed data - A realistic development scenario to build and test against
Following Along

Each article in this series includes reference code that illustrates the design for that layer. The code is production-aware but intentionally incomplete - glue code, configuration, and some repositories are left as exercises. A full build-along implementation is available as a separate project.