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:
Reading the diagramThe
altblock — 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 theelsebranch 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 OptionalIn 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 touse:for general DOM actions.use:enhancefrom$app/formsis a SvelteKit-specific function and has no direct{'{@attach}'}equivalent in the SvelteKit API. The alternative to avoid theuse:directive for forms entirely is anonsubmithandler usingdeserializeandapplyActionfrom$app/forms; the SvelteKit docs show this pattern in full. For everything covered in this series,use:enhanceremains 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 QueriesThe 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 PatternNotice 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:
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:
<!-- 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 CreativesThe 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.