When Forms Need to Talk to the Server

A data model is only as useful as the interface that lets people interact with it. In Part 1 we designed the entities - campaigns, ad groups, creatives, and placements. Now we build the management layer: the forms, server actions, and pages through which campaigns, ad groups, and creatives are created, edited, and removed.

SvelteKit’s form actions provide a uniquely elegant approach to server-side mutations. Unlike traditional SPAs that require separate API endpoints and client-side fetch calls for every operation, form actions let you colocate your mutation logic with the page that triggers it. The result is less code, better progressive enhancement, and a smoother developer experience.

This article covers form architecture for complex nested data, server-side validation with Valibot, SvelteKit form actions for create, update, and delete, progressive enhancement that works without JavaScript, error handling and user feedback, and reusable form component patterns.


Form Architecture

Before building individual forms, let’s establish the patterns that every form in this system will follow.

The Action-Form Loop

SvelteKit form actions create a tight feedback loop between the server and the page. Understanding this loop is essential:

Loading diagram...
Reading the diagram

The alt block — the dashed rectangle labelled “alt” — is a UML combined fragment. It means exactly one of its branches will execute depending on the outcome of the preceding step. The border signals conditionality: any messages inside it are optional from the system’s perspective. The dashed line across the middle marks where the else branch begins — validation passes above it, fails below it.

The key insight is that the form never talks to the database directly. The action function is the gatekeeper - it validates, transforms, persists, and returns the result. The form component only needs to display data and collect input.

Progressive Enhancement with use:enhance

SvelteKit forms work without JavaScript by default, they submit as standard HTML forms. Adding use:enhance upgrades them to submit via fetch without a full page reload, while keeping the same server-side logic:

<!-- With enhance: seamless client-side submission -->
<script>
	import { enhance } from '$app/forms'
</script>

<!-- Without enhance: full page reload on submit -->
<form method="POST" action="?/create">
	<!-- fields -->
</form>

<form method="POST" action="?/create" use:enhance>
	<!-- same fields, better UX -->
</form>
Progressive Enhancement is Not Optional

In ad management tools, users often have poor connectivity or use older browsers. Progressive enhancement ensures the tool works everywhere. The JavaScript-enhanced version simply makes it smoother.

The Two-Phase Callback

Without any argument, use:enhance mirrors the browser’s native form behaviour - it resets the form, re-runs load functions, and handles redirects - just without a full page reload. Every form in this system uses the callback form instead, and it is worth understanding precisely how it works because the two phases are easy to confuse.

<form
	method="POST"
	action="?/create"
	use:enhance={({ formElement, formData, action, cancel }) => {
		// ─ Phase 1 - runs synchronously BEFORE the POST is sent. ─────────────
		// Set loading state, run client-side preflight checks, or call
		// cancel() to abort the submission entirely without a network request.
		submitting = true

		return async ({ result, update }) => {
			// ─ Phase 2 - runs AFTER the server has responded. ────────────────
			// `result` is the ActionResult from your form action:
			// { type: 'success' | 'failure' | 'redirect' | 'error', ... }
			//
			// Call update() to trigger SvelteKit's default post-submission logic:
			//   • re-runs the page's load function with fresh server data
			//   • applies the action's return value to the form prop
			//   • resets the form fields on a successful response
			//
			// If you do NOT call update(), you take full control. Nothing
			// updates automatically. This lets you show custom UI, skip the
			// form reset (update({ reset: false })), or handle the result type
			// yourself before deciding what to refresh.
			submitting = false
			await update()
		}
	}}
>

The returned async function itself is optional. If the outer function returns nothing, SvelteKit’s default behaviour runs as if you had written use:enhance with no argument. Once you return the async function, you own the post-response logic and must call update() to get the defaults back.

use:enhance and Svelte 5.29's {@attach}

Svelte 5.29 introduced {'{@attach}'} as a more composable alternative to use: for general DOM actions. use:enhance from $app/forms is a SvelteKit-specific function and has no direct {'{@attach}'} equivalent in the SvelteKit API. The alternative to avoid the use: directive for forms entirely is an onsubmit handler using deserialize and applyAction from $app/forms; the SvelteKit docs show this pattern in full. For everything covered in this series, use:enhance remains the idiomatic and recommended choice.


The Campaign Repository

Before building forms, we need a data access layer. This repository pattern keeps database logic out of form actions:

// src/lib/server/ads/campaigns.ts

import { db } from '$lib/server/db/client'
import { campaigns } from '$lib/server/db/schema'
import { eq, desc } from 'drizzle-orm'
import crypto from 'node:crypto'
import type { Campaign, CampaignStatus } from '$lib/types/ads'

export const campaignRepository = {
	async findAll(advertiserId: string): Promise<Campaign[]> {
		return db.query.campaigns.findMany({
			where: eq(campaigns.advertiserId, advertiserId),
			orderBy: [desc(campaigns.updatedAt)]
		})
	},

	async findById(id: string): Promise<Campaign | undefined> {
		return db.query.campaigns.findFirst({
			where: eq(campaigns.id, id)
		})
	},

	async create(data: {
		advertiserId: string
		name: string
		description?: string
		goal: string
		budgetType: string
		budgetAmount: number
		startDate: string
		endDate?: string
	}): Promise<Campaign> {
		const id = crypto.randomUUID()
		const now = new Date()

		const [campaign] = await db
			.insert(campaigns)
			.values({
				id,
				advertiserId: data.advertiserId,
				name: data.name,
				description: data.description ?? '',
				goal: data.goal as Campaign['goal'],
				budgetType: data.budgetType as Campaign['budgetType'],
				budgetAmount: data.budgetAmount,
				budgetSpent: 0,
				status: 'draft',
				startDate: new Date(data.startDate),
				endDate: data.endDate ? new Date(data.endDate) : null,
				createdAt: now,
				updatedAt: now
			})
			.returning()

		return campaign
	},

	async update(
		id: string,
		data: Partial<{
			name: string
			description: string
			goal: string
			budgetType: string
			budgetAmount: number
			status: CampaignStatus
			startDate: string
			endDate: string | null
		}>
	): Promise<Campaign> {
		const updateData: Record<string, unknown> = { updatedAt: new Date() }

		if (data.name !== undefined) updateData.name = data.name
		if (data.description !== undefined) updateData.description = data.description
		if (data.goal !== undefined) updateData.goal = data.goal
		if (data.budgetType !== undefined) updateData.budgetType = data.budgetType
		if (data.budgetAmount !== undefined) updateData.budgetAmount = data.budgetAmount
		if (data.status !== undefined) updateData.status = data.status
		if (data.startDate !== undefined) updateData.startDate = new Date(data.startDate)
		if (data.endDate !== undefined) {
			updateData.endDate = data.endDate ? new Date(data.endDate) : null
		}

		const [campaign] = await db
			.update(campaigns)
			.set(updateData)
			.where(eq(campaigns.id, id))
			.returning()

		return campaign
	},

	async delete(id: string): Promise<void> {
		await db.delete(campaigns).where(eq(campaigns.id, id))
	},

	async updateStatus(id: string, status: CampaignStatus): Promise<Campaign> {
		const [campaign] = await db
			.update(campaigns)
			.set({ status, updatedAt: new Date() })
			.where(eq(campaigns.id, id))
			.returning()

		return campaign
	}
}
Repository vs. Direct Queries

The repository pattern adds a thin layer between form actions and the database. This is not over-engineering, it gives you a single place to add caching, logging, or authorization checks later. Every form action calls the repository, never the database directly.


Building the Campaign List Page

The campaign dashboard is the entry point. It displays all campaigns with their status, budget utilization, and key metrics.

Load Function

// src/routes/(app)/ads/+page.server.ts

import type { PageServerLoad } from './$types'
import { campaignRepository } from '$lib/server/ads/campaigns'

export const load: PageServerLoad = async ({ locals }) => {
	// In a real app, get advertiserId from the authenticated session
	const advertiserId = locals.advertiserId

	const campaigns = await campaignRepository.findAll(advertiserId)

	return { campaigns }
}

Campaign List Component

<!-- src/routes/(app)/ads/+page.svelte -->

<script lang="ts">
	import type { PageProps } from './$types'
	import { formatCurrency, formatDate, budgetUtilization } from '$lib/utils/format'

	let { data }: PageProps = $props()

	const statusColors: Record<string, string> = {
		draft: 'var(--text-muted)',
		active: 'var(--accent-green-base)',
		paused: 'var(--accent-yellow-base)',
		completed: 'var(--accent-blue-base)',
		archived: 'var(--text-muted)'
	}
</script>

<div class="campaigns-page">
	<header class="page-header">
		<h1>Campaigns</h1>
		<a href="/ads/campaigns/new" class="btn btn-primary"> New Campaign </a>
	</header>

	{#if data.campaigns.length === 0}
		<div class="empty-state">
			<p>No campaigns yet. Create your first campaign to get started.</p>
			<a href="/ads/campaigns/new" class="btn btn-primary">Create Campaign</a>
		</div>
	{:else}
		<ul class="campaign-list">
			{#each data.campaigns as campaign (campaign.id)}
				{@const utilization = budgetUtilization(campaign.budgetSpent, campaign.budgetAmount)}
				<li class="campaign-card">
					<a href="/ads/campaigns/{campaign.id}" class="campaign-link">
						<div class="campaign-header">
							<h2>{campaign.name}</h2>
							<span class="status-badge" style="color: {statusColors[campaign.status]}">
								{campaign.status}
							</span>
						</div>

						<p class="campaign-description">{campaign.description}</p>

						<div class="campaign-meta">
							<div class="meta-item">
								<span class="meta-label">Budget</span>
								<span class="meta-value">
									{formatCurrency(campaign.budgetAmount)}/{campaign.budgetType}
								</span>
							</div>
							<div class="meta-item">
								<span class="meta-label">Spent</span>
								<span class="meta-value">{formatCurrency(campaign.budgetSpent)}</span>
							</div>
							<div class="meta-item">
								<span class="meta-label">Goal</span>
								<span class="meta-value">{campaign.goal}</span>
							</div>
							<div class="meta-item">
								<span class="meta-label">Dates</span>
								<span class="meta-value">
									{formatDate(campaign.startDate)}
									{#if campaign.endDate}
										- {formatDate(campaign.endDate)}
									{:else}
										- No end date
									{/if}
								</span>
							</div>
						</div>

						<!-- Budget utilization bar -->
						<div class="budget-bar">
							<div
								class="budget-bar-fill"
								style="width: {utilization * 100}%"
								class:over-budget={utilization > 0.9}
							></div>
						</div>
					</a>
				</li>
			{/each}
		</ul>
	{/if}
</div>

Creating a Campaign

The create form is where the Valibot validation schemas meet SvelteKit form actions.

The Form Action

// src/routes/(app)/ads/campaigns/new/+page.server.ts

import type { Actions, PageServerLoad } from './$types'
import { fail, redirect } from '@sveltejs/kit'
import * as v from 'valibot'
import { CampaignSchema } from '$lib/server/validation/ads'
import { campaignRepository } from '$lib/server/ads/campaigns'

export const load: PageServerLoad = async () => {
	// Provide default values for the form
	return {
		form: {
			name: '',
			description: '',
			goal: 'impressions',
			budgetType: 'daily',
			budgetAmount: '',
			startDate: '',
			endDate: ''
		}
	}
}

export const actions: Actions = {
	create: async ({ request, locals }) => {
		const formData = await request.formData()

		// Extract and coerce form values
		const rawData = {
			name: formData.get('name') as string,
			description: formData.get('description') as string,
			goal: formData.get('goal') as string,
			budgetType: formData.get('budgetType') as string,
			budgetAmount: Number(formData.get('budgetAmount')),
			startDate: formData.get('startDate') as string,
			endDate: (formData.get('endDate') as string) || undefined
		}

		// Validate with Valibot
		const result = v.safeParse(CampaignSchema, rawData)

		if (!result.success) {
			// Transform Valibot issues into a field → message map
			const errors: Record<string, string> = {}
			for (const issue of result.issues) {
				const path = issue.path?.map((p) => p.key).join('.') ?? 'form'
				errors[path] = issue.message
			}

			return fail(400, {
				errors,
				values: rawData
			})
		}

		// Persist the validated campaign
		const campaign = await campaignRepository.create({
			advertiserId: locals.advertiserId,
			...result.output
		})

		// Redirect to the new campaign's detail page
		redirect(303, `/ads/campaigns/${campaign.id}`)
	}
}
The Validation Pattern

Notice the pattern: extract → validate → fail or persist → redirect. This three-step flow is the same for every form action in the system. The only things that change are the schema, the repository method, and the redirect target.

The Form Component

The form component handles display, user input, and error presentation. It receives either default values (create mode) or existing data (edit mode):

<!-- src/routes/(app)/ads/campaigns/new/+page.svelte -->

<script lang="ts">
	import type { PageProps } from './$types'
	import { enhance } from '$app/forms'

	let { data, form: actionData }: PageProps = $props()

	// Use action data (after failed submission) or load data (initial render)
	let values = $derived(actionData?.values ?? data.form)
	let errors = $derived(actionData?.errors ?? {})

	let submitting = $state(false)
</script>

<div class="campaign-form-page">
	<h1>Create Campaign</h1>

	<form
		method="POST"
		action="?/create"
		use:enhance={() => {
			submitting = true
			return async ({ update }) => {
				submitting = false
				await update()
			}
		}}
	>
		<!-- Campaign Name -->
		<div class="field" class:has-error={errors.name}>
			<label for="name">Campaign Name</label>
			<input
				id="name"
				name="name"
				type="text"
				value={values.name}
				placeholder="e.g. Summer Sale 2026"
				required
				minlength="3"
				maxlength="100"
			/>
			{#if errors.name}
				<p class="field-error">{errors.name}</p>
			{/if}
		</div>

		<!-- Description -->
		<div class="field">
			<label for="description">Description</label>
			<textarea
				id="description"
				name="description"
				rows="3"
				maxlength="500"
				placeholder="Brief description of this campaign's purpose">{values.description}</textarea
			>
		</div>

		<!-- Campaign Goal -->
		<div class="field" class:has-error={errors.goal}>
			<label for="goal">Campaign Goal</label>
			<select id="goal" name="goal" required>
				<option value="impressions" selected={values.goal === 'impressions'}>
					Impressions - Maximize visibility
				</option>
				<option value="clicks" selected={values.goal === 'clicks'}> Clicks - Drive traffic </option>
				<option value="conversions" selected={values.goal === 'conversions'}>
					Conversions - Generate actions
				</option>
			</select>
			{#if errors.goal}
				<p class="field-error">{errors.goal}</p>
			{/if}
			<p class="field-hint">This determines how the system optimizes ad delivery.</p>
		</div>

		<!-- Budget Section -->
		<fieldset class="field-group">
			<legend>Budget</legend>

			<div class="field-row">
				<div class="field" class:has-error={errors.budgetType}>
					<label for="budgetType">Budget Type</label>
					<select id="budgetType" name="budgetType" required>
						<option value="daily" selected={values.budgetType === 'daily'}>
							Daily - Reset each day
						</option>
						<option value="lifetime" selected={values.budgetType === 'lifetime'}>
							Lifetime - Total for campaign
						</option>
					</select>
				</div>

				<div class="field" class:has-error={errors.budgetAmount}>
					<label for="budgetAmount">Amount (cents)</label>
					<input
						id="budgetAmount"
						name="budgetAmount"
						type="number"
						value={values.budgetAmount}
						min="100"
						step="1"
						placeholder="5000"
						required
					/>
					{#if errors.budgetAmount}
						<p class="field-error">{errors.budgetAmount}</p>
					{/if}
					<p class="field-hint">Enter amount in cents. 5000 = $50.00</p>
				</div>
			</div>
		</fieldset>

		<!-- Schedule Section -->
		<fieldset class="field-group">
			<legend>Schedule</legend>

			<div class="field-row">
				<div class="field" class:has-error={errors.startDate}>
					<label for="startDate">Start Date</label>
					<input
						id="startDate"
						name="startDate"
						type="datetime-local"
						value={values.startDate}
						required
					/>
					{#if errors.startDate}
						<p class="field-error">{errors.startDate}</p>
					{/if}
				</div>

				<div class="field" class:has-error={errors.endDate}>
					<label for="endDate">End Date (optional)</label>
					<input id="endDate" name="endDate" type="datetime-local" value={values.endDate} />
					{#if errors.endDate}
						<p class="field-error">{errors.endDate}</p>
					{/if}
					<p class="field-hint">Leave empty for an open-ended campaign.</p>
				</div>
			</div>
		</fieldset>

		<!-- Submit -->
		<div class="form-actions">
			<a href="/ads" class="btn btn-secondary">Cancel</a>
			<button type="submit" class="btn btn-primary" disabled={submitting}>
				{#if submitting}
					Creating...
				{:else}
					Create Campaign
				{/if}
			</button>
		</div>
	</form>
</div>

Editing a Campaign

Editing reuses the same form structure but pre-populates with existing data and uses a different action.

The Edit Action

// src/routes/(app)/ads/campaigns/[id]/+page.server.ts

import type { Actions, PageServerLoad } from './$types'
import { fail, error, redirect } from '@sveltejs/kit'
import * as v from 'valibot'
import { CampaignSchema } from '$lib/server/validation/ads'
import { campaignRepository } from '$lib/server/ads/campaigns'

export const load: PageServerLoad = async ({ params }) => {
	const campaign = await campaignRepository.findById(params.id)

	if (!campaign) {
		error(404, 'Campaign not found')
	}

	return { campaign }
}

export const actions: Actions = {
	update: async ({ request, params }) => {
		const formData = await request.formData()

		const rawData = {
			name: formData.get('name') as string,
			description: formData.get('description') as string,
			goal: formData.get('goal') as string,
			budgetType: formData.get('budgetType') as string,
			budgetAmount: Number(formData.get('budgetAmount')),
			startDate: formData.get('startDate') as string,
			endDate: (formData.get('endDate') as string) || undefined
		}

		const result = v.safeParse(CampaignSchema, rawData)

		if (!result.success) {
			const errors: Record<string, string> = {}
			for (const issue of result.issues) {
				const path = issue.path?.map((p) => p.key).join('.') ?? 'form'
				errors[path] = issue.message
			}
			return fail(400, { errors, values: rawData })
		}

		await campaignRepository.update(params.id, result.output)

		return { success: true }
	},

	updateStatus: async ({ request, params }) => {
		const formData = await request.formData()
		const status = formData.get('status') as string

		const validStatuses = ['draft', 'active', 'paused', 'completed', 'archived']
		if (!validStatuses.includes(status)) {
			return fail(400, { statusError: 'Invalid status' })
		}

		await campaignRepository.updateStatus(params.id, status as any)

		return { success: true }
	},

	delete: async ({ params }) => {
		await campaignRepository.delete(params.id)
		redirect(303, '/ads')
	}
}

Status Transition Rules

Not every status change is valid. A campaign should follow a controlled lifecycle:

Loading diagram...

We enforce these transitions in a helper function:

// src/lib/ads/status-machine.ts

import type { CampaignStatus } from '$lib/types/ads'

const VALID_TRANSITIONS: Record<CampaignStatus, CampaignStatus[]> = {
	draft: ['active', 'archived'],
	active: ['paused', 'completed'],
	paused: ['active', 'completed', 'archived'],
	completed: ['archived'],
	archived: []
}

export function canTransition(from: CampaignStatus, to: CampaignStatus): boolean {
	return VALID_TRANSITIONS[from]?.includes(to) ?? false
}

export function getValidTransitions(current: CampaignStatus): CampaignStatus[] {
	return VALID_TRANSITIONS[current] ?? []
}

The Campaign Detail Page

The detail page combines the edit form with status controls and a quick overview:

<!-- src/routes/(app)/ads/campaigns/[id]/+page.svelte -->

<script lang="ts">
	import type { PageProps } from './$types'
	import { enhance } from '$app/forms'
	import { getValidTransitions } from '$lib/ads/status-machine'
	import { formatCurrency, formatDate, budgetUtilization, formatPercent } from '$lib/utils/format'

	let { data, form: actionData }: PageProps = $props()

	let campaign = $derived(data.campaign)
	let validTransitions = $derived(getValidTransitions(campaign.status))
	let utilization = $derived(budgetUtilization(campaign.budgetSpent, campaign.budgetAmount))
</script>

<div class="campaign-detail">
	<!-- Status Bar -->
	<div class="status-bar">
		<h1>{campaign.name}</h1>
		<div class="status-controls">
			<span class="current-status">{campaign.status}</span>
			{#each validTransitions as targetStatus}
				<form method="POST" action="?/updateStatus" use:enhance>
					<input type="hidden" name="status" value={targetStatus} />
					<button type="submit" class="btn btn-sm">
						{targetStatus === 'active' ? '▶ Activate' : ''}
						{targetStatus === 'paused' ? '⏸ Pause' : ''}
						{targetStatus === 'completed' ? '⏹ Complete' : ''}
						{targetStatus === 'archived' ? '📦 Archive' : ''}
					</button>
				</form>
			{/each}
		</div>
	</div>

	{#if actionData?.success}
		<div class="alert alert-success">Campaign updated successfully.</div>
	{/if}

	<!-- Campaign Overview -->
	<div class="overview-grid">
		<div class="overview-card">
			<span class="overview-label">Budget</span>
			<span class="overview-value">
				{formatCurrency(campaign.budgetAmount)}/{campaign.budgetType}
			</span>
			<div class="budget-bar">
				<div class="budget-bar-fill" style="width: {utilization * 100}%"></div>
			</div>
			<span class="overview-detail">{formatPercent(utilization)} utilized</span>
		</div>

		<div class="overview-card">
			<span class="overview-label">Goal</span>
			<span class="overview-value">{campaign.goal}</span>
		</div>

		<div class="overview-card">
			<span class="overview-label">Schedule</span>
			<span class="overview-value">
				{formatDate(campaign.startDate)}
				{campaign.endDate ? ` - ${formatDate(campaign.endDate)}` : ' - Ongoing'}
			</span>
		</div>
	</div>

	<!-- Edit Form -->
	<details class="edit-section">
		<summary>Edit Campaign Details</summary>
		<form method="POST" action="?/update" use:enhance>
			<!-- Same fields as the create form, pre-populated with campaign data -->
			<div class="field">
				<label for="name">Campaign Name</label>
				<input id="name" name="name" type="text" value={campaign.name} required />
			</div>

			<div class="field">
				<label for="description">Description</label>
				<textarea id="description" name="description" rows="3">{campaign.description}</textarea>
			</div>

			<!-- ...remaining fields same as create form... -->

			<div class="form-actions">
				<button type="submit" class="btn btn-primary">Save Changes</button>
			</div>
		</form>
	</details>

	<!-- Danger Zone -->
	<details class="danger-zone">
		<summary>Danger Zone</summary>
		<form
			method="POST"
			action="?/delete"
			use:enhance={() => {
				const confirmed = confirm(
					'Are you sure? This will delete the campaign and all its ad groups and creatives.'
				)
				if (!confirmed) return ({ cancel }) => cancel()
				return async ({ update }) => await update()
			}}
		>
			<p>Permanently delete this campaign and all associated ad groups, creatives, and events.</p>
			<button type="submit" class="btn btn-danger">Delete Campaign</button>
		</form>
	</details>
</div>

The Ad Group Form

Before building the form, it is worth being clear on what an ad group actually does, because its purpose shapes every field in the interface.

A campaign answers the high-level questions: what are we promoting, what is the goal, and how much are we spending? An ad group answers the operational questions: who should see these ads and where?

Those two concerns are kept separate deliberately. A single “Summer Sale 2026” campaign might need to reach desktop users in the United States during business hours, and separately reach mobile users globally at any time of day - with different creative approaches for each audience.

Merging that into a single campaign form would make targeting a tangle of conditional logic and unnamed exceptions. Instead, each distinct audience strategy gets its own ad group, with its own placement targets, device filters, geographic rules, schedule, frequency cap, and priority. The campaign holds the budget and the goal; the ad group holds the precision.

This separation is also why the form is genuinely more complex than the campaign form. A campaign collects a handful of scalar values: a name, a budget amount, a date range. An ad group collects a structured targeting configuration that is itself a nested object - an array of placement slugs, an array of device types, arrays of country codes, a schedule with timezone and hour ranges, an optional per-user frequency cap.

HTML forms do not natively represent nested objects; they produce flat key-value pairs. Getting from a flat form submission to a validated TargetingRules object requires explicit conventions and a small parsing step, which is exactly what the section below addresses.

Handling Nested Form Data

HTML forms naturally produce flat key-value pairs. Nested objects like targeting.placements require a naming convention:

<!-- Nested fields use dot notation in the name attribute -->
<input name="targeting.scheduleTimezone" value="America/New_York" />

<!-- Arrays use bracket notation -->
<input name="targeting.placements" value="homepage-hero" type="checkbox" />
<input name="targeting.placements" value="sidebar-top" type="checkbox" />

We need a helper to reconstruct nested objects from flat FormData:

// src/lib/utils/form-data.ts

/**
 * Parse flat FormData into a nested object structure.
 * Handles dot notation (targeting.timezone) and array fields
 * (multiple values with the same name).
 */
export function parseFormData(formData: FormData): Record<string, unknown> {
	const result: Record<string, unknown> = {}

	// Group entries by key to detect arrays
	const entries = new Map<string, string[]>()
	for (const [key, value] of formData.entries()) {
		if (typeof value !== 'string') continue
		const existing = entries.get(key)
		if (existing) {
			existing.push(value)
		} else {
			entries.set(key, [value])
		}
	}

	for (const [key, values] of entries) {
		const parts = key.split('.')
		let current: Record<string, unknown> = result

		for (let i = 0; i < parts.length - 1; i++) {
			const part = parts[i]
			if (!(part in current) || typeof current[part] !== 'object') {
				current[part] = {}
			}
			current = current[part] as Record<string, unknown>
		}

		const finalKey = parts[parts.length - 1]
		// If multiple values exist for the same key, treat as array
		current[finalKey] = values.length > 1 ? values : values[0]
	}

	return result
}

The Ad Group Form Action

The action file does two jobs. The load function runs first: it fetches all active placements from the database so the form can render the placement checkboxes, and it provides a default targeting configuration so every field starts with a sensible value rather than empty.

The create action runs when the form is submitted: it hands the raw FormData to parseFormData to reconstruct the nested targeting object, coerces the numeric fields that HTML forms always deliver as strings, validates the result against AdGroupSchema, and on success persists the new ad group and redirects back to the parent campaign.

If validation fails, it returns the errors alongside the submitted values so the form can re-render with both the error messages and the user’s input preserved.

// src/routes/(app)/ads/campaigns/[id]/ad-groups/new/+page.server.ts

import type { Actions, PageServerLoad } from './$types'
import { fail, redirect } from '@sveltejs/kit'
import * as v from 'valibot'
import { AdGroupSchema } from '$lib/server/validation/ads'
import { adGroupRepository } from '$lib/server/ads/ad-groups'
import { placementRepository } from '$lib/server/ads/placements'
import { parseFormData } from '$lib/utils/form-data'

export const load: PageServerLoad = async ({ params }) => {
	const placements = await placementRepository.findAllActive()

	return {
		campaignId: params.id,
		placements,
		form: {
			name: '',
			priority: 5,
			targeting: {
				placements: [],
				devices: ['all'],
				geoCountries: [],
				geoRegions: [],
				scheduleTimezone: 'UTC',
				scheduleDays: [0, 1, 2, 3, 4, 5, 6],
				scheduleHoursStart: 0,
				scheduleHoursEnd: 23,
				frequencyCap: null,
				urlPatterns: []
			}
		}
	}
}

export const actions: Actions = {
	create: async ({ request, params }) => {
		const formData = await request.formData()
		const rawData = parseFormData(formData)

		// Coerce numeric fields
		if (rawData.priority) rawData.priority = Number(rawData.priority)
		const targeting = rawData.targeting as Record<string, unknown>
		if (targeting) {
			targeting.scheduleDays = Array.isArray(targeting.scheduleDays)
				? targeting.scheduleDays.map(Number)
				: [Number(targeting.scheduleDays)]
			targeting.scheduleHoursStart = Number(targeting.scheduleHoursStart)
			targeting.scheduleHoursEnd = Number(targeting.scheduleHoursEnd)
			targeting.frequencyCap = targeting.frequencyCap ? Number(targeting.frequencyCap) : null
			// Ensure arrays
			if (!Array.isArray(targeting.placements)) {
				targeting.placements = targeting.placements ? [targeting.placements] : []
			}
			if (!Array.isArray(targeting.devices)) {
				targeting.devices = targeting.devices ? [targeting.devices] : []
			}
			if (!Array.isArray(targeting.geoCountries)) {
				targeting.geoCountries = targeting.geoCountries ? [targeting.geoCountries] : []
			}
		}

		const result = v.safeParse(AdGroupSchema, rawData)

		if (!result.success) {
			const errors: Record<string, string> = {}
			for (const issue of result.issues) {
				const path = issue.path?.map((p) => p.key).join('.') ?? 'form'
				errors[path] = issue.message
			}
			return fail(400, { errors, values: rawData })
		}

		const adGroup = await adGroupRepository.create({
			campaignId: params.id,
			...result.output
		})

		redirect(303, `/ads/campaigns/${params.id}`)
	}
}

The Ad Group Form Component

<!-- src/lib/components/ads/AdGroupForm.svelte -->

<script lang="ts">
	import type { Placement } from '$lib/types/ads'
	import { enhance } from '$app/forms'

	interface Props {
		placements: Placement[]
		values: Record<string, any>
		errors: Record<string, string>
		action: string
		submitLabel: string
		cancelHref: string
	}

	let { placements, values, errors, action, submitLabel, cancelHref }: Props = $props()

	let submitting = $state(false)

	const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
	const DEVICES = [
		{ value: 'desktop', label: 'Desktop' },
		{ value: 'mobile', label: 'Mobile' },
		{ value: 'tablet', label: 'Tablet' },
		{ value: 'all', label: 'All Devices' }
	]
</script>

<form
	method="POST"
	{action}
	use:enhance={() => {
		submitting = true
		return async ({ update }) => {
			submitting = false
			await update()
		}
	}}
>
	<!-- Name & Priority -->
	<div class="field-row">
		<div class="field" class:has-error={errors.name}>
			<label for="name">Ad Group Name</label>
			<input id="name" name="name" type="text" value={values.name} required />
			{#if errors.name}
				<p class="field-error">{errors.name}</p>
			{/if}
		</div>

		<div class="field" class:has-error={errors.priority}>
			<label for="priority">Priority (1–100)</label>
			<input
				id="priority"
				name="priority"
				type="number"
				value={values.priority}
				min="1"
				max="100"
				required
			/>
			{#if errors.priority}
				<p class="field-error">{errors.priority}</p>
			{/if}
			<p class="field-hint">Higher priority ad groups win ties during ad selection.</p>
		</div>
	</div>

	<!-- Placement Targeting -->
	<fieldset class="field-group" class:has-error={errors['targeting.placements']}>
		<legend>Placement Targeting</legend>
		<p class="field-hint">Select where this ad group's creatives should appear.</p>

		<div class="checkbox-grid">
			{#each placements as placement (placement.id)}
				<label class="checkbox-label">
					<input
						type="checkbox"
						name="targeting.placements"
						value={placement.slug}
						checked={values.targeting?.placements?.includes(placement.slug)}
					/>
					<span>
						<strong>{placement.name}</strong>
						<small>{placement.slug}{placement.width}×{placement.height}</small>
					</span>
				</label>
			{/each}
		</div>
		{#if errors['targeting.placements']}
			<p class="field-error">{errors['targeting.placements']}</p>
		{/if}
	</fieldset>

	<!-- Device Targeting -->
	<fieldset class="field-group">
		<legend>Device Targeting</legend>
		<div class="checkbox-grid">
			{#each DEVICES as device}
				<label class="checkbox-label">
					<input
						type="checkbox"
						name="targeting.devices"
						value={device.value}
						checked={values.targeting?.devices?.includes(device.value)}
					/>
					<span>{device.label}</span>
				</label>
			{/each}
		</div>
	</fieldset>

	<!-- Schedule Targeting -->
	<fieldset class="field-group">
		<legend>Schedule</legend>

		<div class="field">
			<label for="timezone">Timezone</label>
			<input
				id="timezone"
				name="targeting.scheduleTimezone"
				type="text"
				value={values.targeting?.scheduleTimezone ?? 'UTC'}
				placeholder="America/New_York"
			/>
		</div>

		<div class="field">
			<label>Active Days</label>
			<div class="checkbox-grid compact">
				{#each DAYS as day, index}
					<label class="checkbox-label">
						<input
							type="checkbox"
							name="targeting.scheduleDays"
							value={index}
							checked={values.targeting?.scheduleDays?.includes(index)}
						/>
						<span>{day}</span>
					</label>
				{/each}
			</div>
		</div>

		<div class="field-row">
			<div class="field">
				<label for="hoursStart">Start Hour (0–23)</label>
				<input
					id="hoursStart"
					name="targeting.scheduleHoursStart"
					type="number"
					min="0"
					max="23"
					value={values.targeting?.scheduleHoursStart ?? 0}
				/>
			</div>
			<div class="field">
				<label for="hoursEnd">End Hour (0–23)</label>
				<input
					id="hoursEnd"
					name="targeting.scheduleHoursEnd"
					type="number"
					min="0"
					max="23"
					value={values.targeting?.scheduleHoursEnd ?? 23}
				/>
			</div>
		</div>
	</fieldset>

	<!-- Frequency Cap -->
	<div class="field">
		<label for="frequencyCap">Frequency Cap (optional)</label>
		<input
			id="frequencyCap"
			name="targeting.frequencyCap"
			type="number"
			min="1"
			value={values.targeting?.frequencyCap ?? ''}
			placeholder="e.g. 3"
		/>
		<p class="field-hint">
			Maximum number of times a single user sees ads from this group per day. Leave empty for no
			limit.
		</p>
	</div>

	<!-- Submit -->
	<div class="form-actions">
		<a href={cancelHref} class="btn btn-secondary">Cancel</a>
		<button type="submit" class="btn btn-primary" disabled={submitting}>
			{#if submitting}Saving...{:else}{submitLabel}{/if}
		</button>
	</div>
</form>

The Creative Form

Creatives are the most complex form because the content fields change based on the selected format and the validation rules vary accordingly.

Dynamic Content Fields

The creative form uses conditional rendering to show different fields based on the selected format:

Loading diagram...
<!-- src/lib/components/ads/CreativeForm.svelte -->

<script lang="ts">
	import type { CreativeFormat } from '$lib/types/ads'
	import { enhance } from '$app/forms'

	interface Props {
		values: Record<string, any>
		errors: Record<string, string>
		action: string
		submitLabel: string
		cancelHref: string
	}

	let { values, errors, action, submitLabel, cancelHref }: Props = $props()

	let selectedFormat = $state<CreativeFormat>(values.format ?? 'image')
	let submitting = $state(false)
</script>

<form
	method="POST"
	{action}
	use:enhance={() => {
		submitting = true
		return async ({ update }) => {
			submitting = false
			await update()
		}
	}}
>
	<!-- Basic Fields -->
	<div class="field">
		<label for="name">Creative Name</label>
		<input id="name" name="name" type="text" value={values.name} required />
		{#if errors.name}<p class="field-error">{errors.name}</p>{/if}
	</div>

	<div class="field-row">
		<div class="field">
			<label for="format">Format</label>
			<select id="format" name="format" bind:value={selectedFormat}>
				<option value="image">Image</option>
				<option value="text">Text</option>
				<option value="html">HTML</option>
				<option value="video">Video</option>
			</select>
		</div>

		<div class="field">
			<label for="weight">Rotation Weight (1–100)</label>
			<input
				id="weight"
				name="weight"
				type="number"
				min="1"
				max="100"
				value={values.weight ?? 50}
				required
			/>
			<p class="field-hint">
				Higher weight = shown more often relative to other creatives in this ad group.
			</p>
		</div>
	</div>

	<!-- Click & Impression URLs -->
	<div class="field">
		<label for="clickUrl">Click URL</label>
		<input
			id="clickUrl"
			name="clickUrl"
			type="url"
			value={values.clickUrl}
			placeholder="https://example.com/landing-page"
			required
		/>
		{#if errors.clickUrl}<p class="field-error">{errors.clickUrl}</p>{/if}
	</div>

	<div class="field">
		<label for="impressionUrl">Impression Tracking URL (optional)</label>
		<input
			id="impressionUrl"
			name="impressionUrl"
			type="url"
			value={values.impressionUrl ?? ''}
			placeholder="https://tracker.example.com/pixel"
		/>
		<p class="field-hint">Third-party impression pixel for external tracking.</p>
	</div>

	<!-- Format-Specific Content Fields -->
	<fieldset class="field-group">
		<legend>Creative Content - {selectedFormat}</legend>

		<input type="hidden" name="content.type" value={selectedFormat} />

		{#if selectedFormat === 'image'}
			<div class="field">
				<label for="content-src">Image URL</label>
				<input
					id="content-src"
					name="content.src"
					type="url"
					value={values.content?.src ?? ''}
					required
				/>
			</div>
			<div class="field">
				<label for="content-alt">Alt Text</label>
				<input
					id="content-alt"
					name="content.alt"
					type="text"
					value={values.content?.alt ?? ''}
					required
				/>
			</div>
			<div class="field-row">
				<div class="field">
					<label for="content-width">Width (px)</label>
					<input
						id="content-width"
						name="content.width"
						type="number"
						min="1"
						value={values.content?.width ?? ''}
						required
					/>
				</div>
				<div class="field">
					<label for="content-height">Height (px)</label>
					<input
						id="content-height"
						name="content.height"
						type="number"
						min="1"
						value={values.content?.height ?? ''}
						required
					/>
				</div>
			</div>
		{:else if selectedFormat === 'text'}
			<div class="field">
				<label for="content-headline">Headline</label>
				<input
					id="content-headline"
					name="content.headline"
					type="text"
					value={values.content?.headline ?? ''}
					maxlength="100"
					required
				/>
			</div>
			<div class="field">
				<label for="content-body">Body</label>
				<textarea id="content-body" name="content.body" rows="3" maxlength="500" required
					>{values.content?.body ?? ''}</textarea
				>
			</div>
			<div class="field">
				<label for="content-cta">Call to Action</label>
				<input
					id="content-cta"
					name="content.ctaText"
					type="text"
					value={values.content?.ctaText ?? ''}
					maxlength="50"
					placeholder="Shop Now"
					required
				/>
			</div>
		{:else if selectedFormat === 'html'}
			<div class="field">
				<label for="content-markup">HTML Markup</label>
				<textarea id="content-markup" name="content.markup" rows="10" class="monospace" required
					>{values.content?.markup ?? ''}</textarea
				>
				<p class="field-hint">Custom HTML for rich ad creatives. Scripts will be sandboxed.</p>
			</div>
		{:else if selectedFormat === 'video'}
			<div class="field">
				<label for="content-video-src">Video URL</label>
				<input
					id="content-video-src"
					name="content.src"
					type="url"
					value={values.content?.src ?? ''}
					required
				/>
			</div>
			<div class="field">
				<label for="content-poster">Poster Image URL</label>
				<input
					id="content-poster"
					name="content.poster"
					type="url"
					value={values.content?.poster ?? ''}
					required
				/>
			</div>
			<div class="field">
				<label for="content-duration">Duration (seconds)</label>
				<input
					id="content-duration"
					name="content.duration"
					type="number"
					min="1"
					value={values.content?.duration ?? ''}
					required
				/>
			</div>
		{/if}
	</fieldset>

	<div class="form-actions">
		<a href={cancelHref} class="btn btn-secondary">Cancel</a>
		<button type="submit" class="btn btn-primary" disabled={submitting}>
			{#if submitting}Saving...{:else}{submitLabel}{/if}
		</button>
	</div>
</form>
Security: HTML Creatives

The HTML creative format accepts arbitrary markup. In a production system, you must sanitize this content before rendering. Use a library like DOMPurify to strip dangerous elements (scripts, iframes with untrusted sources). We’ll address this in Part 3 when we build the ad renderer.


Error Handling Patterns

When a Valibot validation fails, it returns an array of issues with paths and messages. We need to transform that into a format the form can use to display errors next to the relevant fields.

Extracting Valibot Errors

The error extraction pattern is used in every action. Here is a reusable helper:

// src/lib/server/validation/extract-errors.ts

import type { BaseIssue } from 'valibot'

/**
 * Transform Valibot validation issues into a flat
 * field path → error message record for form display.
 */
export function extractErrors(issues: BaseIssue<unknown>[]): Record<string, string> {
	const errors: Record<string, string> = {}

	for (const issue of issues) {
		const path = issue.path?.map((segment) => segment.key).join('.')
		const key = path || '_form'

		// Only keep the first error per field
		if (!errors[key]) {
			errors[key] = issue.message
		}
	}

	return errors
}

Displaying Field Errors

A reusable field error component keeps error display consistent:

<!-- src/lib/components/ads/FieldError.svelte -->

<script lang="ts">
	interface Props {
		error: string | undefined
	}

	let { error }: Props = $props()
</script>

{#if error}
	<p class="field-error" role="alert">
		{error}
	</p>
{/if}

<style>
	.field-error {
		color: var(--accent-red-base, #dc2626);
		font-size: 0.85rem;
		margin-top: 0.25rem;
		margin-bottom: 0;
	}
</style>

What’s Next

You now have a complete CRUD layer for the ad campaign system:

  • Campaign list with status badges and budget utilization
  • Create and edit forms with server-side Valibot validation
  • Status management with controlled state machine transitions
  • Ad group forms with nested targeting rule handling
  • Creative forms with dynamic format-specific content fields
  • Reusable patterns for error extraction, form data parsing, and progressive enhancement

Every form works without JavaScript and degrades gracefully. Every mutation is validated on the server. Every error maps to a specific field.