MARCH 29, 2026 · 14 MIN READ · TUTORIAL

Build an SEO Audit Dashboard in SvelteKit with SEOPeek API

SvelteKit's server-side rendering, compiled reactivity, and built-in routing make it an ideal framework for building data-dense dashboards. In this tutorial, you'll build a complete Svelte SEO dashboard that audits any URL in real time, displays animated score rings, color-coded check results, batch scanning with progress tracking, and historical audit storage — all powered by the SEOPeek API.

In this article
  1. Why SvelteKit for SEO dashboards
  2. Project setup and SEOPeek API client
  3. Building the audit form with real-time validation
  4. Animated SVG score ring component
  5. Color-coded check results with Svelte transitions
  6. Batch scanning multiple URLs
  7. Historical audit tracking with SvelteKit stores
  8. Deploying to Firebase and Vercel

1. Why SvelteKit for SEO Dashboards

When you're building an SEO monitoring dashboard, framework choice directly impacts perceived performance. A sluggish dashboard defeats the purpose of measuring site speed. SvelteKit delivers on three fronts that matter for this use case:

Server-side rendering without the cost

SvelteKit renders pages on the server by default. Your dashboard's initial view — complete with audit results — arrives as fully-formed HTML. Unlike client-rendered React SPAs that show a spinner while fetching data, SvelteKit load functions resolve on the server before the page reaches the browser. For an SEO tool, this is especially fitting: your SEO dashboard itself has good SEO.

Compiled reactivity, no virtual DOM

Svelte compiles your components into surgical DOM updates at build time. When an audit returns 20 check results and you need to animate each one, there's no diffing overhead. Score ring animations, progress bars, and status transitions all run at 60fps because Svelte generates the exact JavaScript needed to update each element — nothing more.

Built-in server routes for API proxying

SvelteKit's +server.ts files give you API endpoints inside your project. This means you can proxy calls to the SEOPeek API through your own domain, keeping API keys server-side and adding caching, rate limiting, or response transformation without a separate backend.

Feature SvelteKit Next.js Nuxt
Bundle size (hello world) ~3 KB ~87 KB ~58 KB
Reactivity model Compiled, no VDOM Virtual DOM (React) Proxy-based (Vue)
Server routes Built-in API routes Server routes
Transitions/animations First-class Requires library Built-in (basic)
Learning curve Low Medium Medium

2. Project Setup and SEOPeek API Client

Scaffold a new SvelteKit project and install the only dependency you'll need beyond the framework itself:

# Create a new SvelteKit project
npx sv create seo-dashboard
cd seo-dashboard

# Select: Skeleton project, TypeScript, Prettier, ESLint

# Install dependencies
npm install

Environment variables

Store your SEOPeek API key in a .env file. SvelteKit only exposes variables prefixed with PUBLIC_ to the client. Since we want to keep the key server-side, we omit that prefix:

.env
# Server-only — not exposed to the browser
SEOPEEK_API_KEY=your_api_key_here
SEOPEEK_BASE_URL=https://us-central1-todd-agent-prod.cloudfunctions.net/seopeekApi

Creating the API client

Create a reusable module that wraps the SEOPeek API. This keeps all HTTP logic in one place and adds typed responses:

src/lib/seopeek.ts
import { SEOPEEK_API_KEY, SEOPEEK_BASE_URL } from '$env/static/private';

export interface SEOCheck {
  name: string;
  status: 'pass' | 'warn' | 'fail';
  message: string;
  details: string;
}

export interface AuditResult {
  url: string;
  score: number;       // 0-100
  grade: string;       // A-F
  checks: SEOCheck[];  // 20 checks
}

export async function auditUrl(url: string): Promise<AuditResult> {
  const endpoint = `${SEOPEEK_BASE_URL}/api/v1/audit`;
  const params = new URLSearchParams({ url });

  const response = await fetch(`${endpoint}?${params}`, {
    headers: {
      'X-API-Key': SEOPEEK_API_KEY
    }
  });

  if (!response.ok) {
    throw new Error(`SEOPeek API error: ${response.status}`);
  }

  return response.json();
}

Free tier: The SEOPeek API allows 50 audits per day without an API key. For production dashboards, the Starter plan at $9/month gives you 1,000 monthly audits — 3-5x cheaper than SEOptimer ($29/mo) or Seobility ($50/mo).

Server route for proxying

Create a SvelteKit server route that your client-side code can call. This keeps your API key hidden and lets you add caching later:

src/routes/api/audit/+server.ts
import { json } from '@sveltejs/kit';
import { auditUrl } from '$lib/seopeek';
import type { RequestHandler } from './$types';

export const GET: RequestHandler = async ({ url }) => {
  const targetUrl = url.searchParams.get('url');

  if (!targetUrl) {
    return json(
      { error: 'Missing url parameter' },
      { status: 400 }
    );
  }

  try {
    const result = await auditUrl(targetUrl);
    return json(result);
  } catch (err) {
    return json(
      { error: 'Audit failed' },
      { status: 500 }
    );
  }
};

3. Building the Audit Form with Real-Time Validation

The entry point for your SvelteKit SEO audit dashboard is the URL input form. We'll add client-side URL validation that runs as the user types, debounced to avoid excessive checks.

src/lib/components/AuditForm.svelte
<script lang="ts">
  import { createEventDispatcher } from 'svelte';
  import { fly } from 'svelte/transition';

  const dispatch = createEventDispatcher<{
    submit: { url: string }
  }>();

  let url = '';
  let error = '';
  let loading = false;
  let debounceTimer: ReturnType<typeof setTimeout>;

  function validateUrl(value: string): string | null {
    if (!value) return null;
    try {
      const parsed = new URL(value);
      if (!['http:', 'https:'].includes(parsed.protocol)) {
        return 'URL must start with http:// or https://';
      }
      return null;
    } catch {
      return 'Enter a valid URL (e.g. https://example.com)';
    }
  }

  function handleInput() {
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(() => {
      error = validateUrl(url) ?? '';
    }, 300);
  }

  function handleSubmit() {
    const validationError = validateUrl(url);
    if (validationError) {
      error = validationError;
      return;
    }
    loading = true;
    dispatch('submit', { url });
  }

  export function reset() {
    loading = false;
  }
</script>

<form on:submit|preventDefault={handleSubmit} class="audit-form">
  <div class="input-group">
    <input
      type="url"
      bind:value={url}
      on:input={handleInput}
      placeholder="https://example.com"
      class:invalid={error}
      disabled={loading}
    />
    <button type="submit" disabled={loading || !!error}>
      {#if loading}
        Auditing...
      {:else}
        Run Audit
      {/if}
    </button>
  </div>
  {#if error}
    <p class="error" transition:fly={{ y: -8, duration: 200 }}>
      {error}
    </p>
  {/if}
</form>

The fly transition on the error message is a small touch that makes the validation feedback feel polished. The 300ms debounce prevents validation from firing on every keystroke. Svelte handles binding the input value reactively — no useState or ref boilerplate needed.

4. Animated SVG Score Ring

The centerpiece of any SEO dashboard is the score visualization. We'll build an animated SVG ring that fills based on the audit score (0–100) and changes color based on the grade. Svelte's tweened store makes the animation trivial.

src/lib/components/ScoreRing.svelte
<script lang="ts">
  import { tweened } from 'svelte/motion';
  import { cubicOut } from 'svelte/easing';

  export let score: number = 0;
  export let grade: string = '—';
  export let size: number = 180;

  const radius = 70;
  const circumference = 2 * Math.PI * radius;

  const progress = tweened(0, {
    duration: 1200,
    easing: cubicOut
  });

  // React to score changes
  $: progress.set(score);
  $: offset = circumference - ($progress / 100) * circumference;

  $: color = score >= 90 ? '#10B981'  // A — emerald
    : score >= 80 ? '#34D399'             // B — light emerald
    : score >= 60 ? '#FBBF24'             // C — amber
    : score >= 40 ? '#F97316'             // D — orange
    : '#EF4444';                            // F — red
</script>

<div class="score-ring">
  <svg width={size} height={size} viewBox="0 0 180 180">
    <!-- Background ring -->
    <circle
      cx="90" cy="90" r={radius}
      fill="none"
      stroke="rgba(255,255,255,0.05)"
      stroke-width="8"
    />
    <!-- Progress ring -->
    <circle
      cx="90" cy="90" r={radius}
      fill="none"
      stroke={color}
      stroke-width="8"
      stroke-linecap="round"
      stroke-dasharray={circumference}
      stroke-dashoffset={offset}
      transform="rotate(-90 90 90)"
      style="transition: stroke 0.4s ease"
    />
  </svg>
  <div class="score-label">
    <span class="score-number" style="color: {color}">
      {Math.round($progress)}
    </span>
    <span class="score-grade">{grade}</span>
  </div>
</div>

<style>
  .score-ring {
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }
  .score-label {
    position: absolute;
    text-align: center;
  }
  .score-number {
    font-size: 42px;
    font-weight: 800;
    font-family: 'Sora', sans-serif;
    display: block;
    line-height: 1;
  }
  .score-grade {
    font-size: 14px;
    font-weight: 500;
    color: #9B9BA7;
    letter-spacing: 0.06em;
    text-transform: uppercase;
  }
</style>

The key insight here is tweened. When the score prop changes, the progress store interpolates from the old value to the new one over 1.2 seconds with an ease-out curve. The reactive statement $: offset = ... recomputes the SVG stroke-dashoffset on every animation frame, producing a smooth fill animation with zero manual requestAnimationFrame code.

5. Color-Coded Check Results with Svelte Transitions

Each SEOPeek audit returns 20 checks with a status of pass, warn, or fail. Let's build a component that renders them as a list with staggered entrance animations and color-coded status badges.

src/lib/components/CheckList.svelte
<script lang="ts">
  import { fly } from 'svelte/transition';
  import type { SEOCheck } from '$lib/seopeek';

  export let checks: SEOCheck[] = [];

  const statusConfig = {
    pass: { label: 'Pass', color: '#10B981', bg: 'rgba(16,185,129,0.1)' },
    warn: { label: 'Warn', color: '#FBBF24', bg: 'rgba(251,191,36,0.1)' },
    fail: { label: 'Fail', color: '#EF4444', bg: 'rgba(239,68,68,0.1)' }
  };

  // Sort: failures first, then warnings, then passes
  $: sorted = [...checks].sort((a, b) => {
    const order = { fail: 0, warn: 1, pass: 2 };
    return order[a.status] - order[b.status];
  });
</script>

<div class="check-list">
  {#each sorted as check, i (check.name)}
    <div
      class="check-item"
      transition:fly={{ y: 20, duration: 300, delay: i * 40 }}
    >
      <span
        class="status-badge"
        style="color: {statusConfig[check.status].color};
               background: {statusConfig[check.status].bg}"
      >
        {statusConfig[check.status].label}
      </span>
      <div class="check-content">
        <strong>{check.name}</strong>
        <p>{check.message}</p>
        {#if check.details}
          <p class="detail">{check.details}</p>
        {/if}
      </div>
    </div>
  {/each}
</div>

The delay: i * 40 in the fly transition creates a staggered entrance effect — each check item flies in 40ms after the previous one. With 20 checks, the full cascade takes 800ms, creating a satisfying waterfall that makes the dashboard feel alive.

Sorting failures to the top ensures the most actionable information is immediately visible. The color system uses red for failures, amber for warnings, and emerald for passes — universally understood and accessible even to users with partial color vision when combined with the text labels.

Wiring the page together

Now connect the form, score ring, and check list in the main page component:

src/routes/+page.svelte
<script lang="ts">
  import AuditForm from '$lib/components/AuditForm.svelte';
  import ScoreRing from '$lib/components/ScoreRing.svelte';
  import CheckList from '$lib/components/CheckList.svelte';
  import type { AuditResult } from '$lib/seopeek';

  let result: AuditResult | null = null;
  let auditForm: AuditForm;

  async function handleAudit(event: CustomEvent<{ url: string }>) {
    const { url } = event.detail;
    const params = new URLSearchParams({ url });
    const res = await fetch(`/api/audit?${params}`);
    result = await res.json();
    auditForm.reset();
  }
</script>

<main>
  <h1>SEO Audit Dashboard</h1>

  <AuditForm
    bind:this={auditForm}
    on:submit={handleAudit}
  />

  {#if result}
    <section class="results">
      <div class="score-section">
        <ScoreRing
          score={result.score}
          grade={result.grade}
        />
        <p class="audited-url">{result.url}</p>
      </div>

      <CheckList checks={result.checks} />
    </section>
  {/if}
</main>

Why proxy through a server route? Calling /api/audit instead of hitting the SEOPeek API directly keeps your API key out of the browser's network tab. It also lets you add server-side caching with setHeaders to avoid redundant audits for the same URL.

6. Batch Scanning Multiple URLs

A practical Svelte SEO monitoring tool needs to audit multiple pages at once. Let's build a batch scanner that processes a list of URLs with progress tracking, concurrency control, and per-URL status.

src/lib/components/BatchScanner.svelte
<script lang="ts">
  import type { AuditResult } from '$lib/seopeek';
  import { tweened } from 'svelte/motion';
  import ScoreRing from './ScoreRing.svelte';

  let urlText = '';
  let results: (AuditResult & { status: 'pending'|'loading'|'done'|'error' })[] = [];
  let scanning = false;

  const CONCURRENCY = 3;
  const progress = tweened(0, { duration: 400 });

  function parseUrls(text: string): string[] {
    return text
      .split(/\n/)
      .map(line => line.trim())
      .filter(line => {
        try { new URL(line); return true; }
        catch { return false; }
      });
  }

  async function auditSingle(index: number) {
    results[index].status = 'loading';
    try {
      const params = new URLSearchParams({
        url: results[index].url
      });
      const res = await fetch(`/api/audit?${params}`);
      const data = await res.json();
      results[index] = { ...data, status: 'done' };
    } catch {
      results[index].status = 'error';
    }
  }

  async function startBatch() {
    const urls = parseUrls(urlText);
    if (!urls.length) return;

    scanning = true;
    progress.set(0);

    // Initialize result placeholders
    results = urls.map(url => ({
      url, score: 0, grade: '—',
      checks: [], status: 'pending'
    }));

    // Process with concurrency limit
    let completed = 0;
    const queue = [...results.keys()];

    async function worker() {
      while (queue.length) {
        const idx = queue.shift()!;
        await auditSingle(idx);
        completed++;
        progress.set((completed / urls.length) * 100);
      }
    }

    await Promise.all(
      Array.from({ length: CONCURRENCY }, () => worker())
    );

    scanning = false;
  }
</script>

<div class="batch-scanner">
  <textarea
    bind:value={urlText}
    placeholder="Paste URLs, one per line..."
    rows="6"
    disabled={scanning}
  />

  <button on:click={startBatch} disabled={scanning}>
    {scanning ? 'Scanning...' : 'Scan All URLs'}
  </button>

  {#if scanning}
    <div class="progress-bar">
      <div
        class="progress-fill"
        style="width: {$progress}%"
      />
    </div>
    <p class="progress-text">
      {Math.round($progress)}% complete
    </p>
  {/if}

  <div class="results-grid">
    {#each results as r}
      <div class="result-card {r.status}">
        {#if r.status === 'done'}
          <ScoreRing score={r.score} grade={r.grade} size={100} />
        {:else if r.status === 'loading'}
          <div class="spinner" />
        {:else if r.status === 'error'}
          <span class="error-icon">!</span>
        {/if}
        <p class="result-url">{r.url}</p>
      </div>
    {/each}
  </div>
</div>

The concurrency limit of 3 prevents hammering the API while still completing batches quickly. The worker pattern — multiple async functions pulling from a shared queue — is a proven approach that works with the SEOPeek API rate limits (50/day free, 1,000/mo on Starter, 10,000/mo on Pro).

7. Historical Audit Tracking with SvelteKit Stores

A Svelte SEO dashboard becomes genuinely useful when it tracks audits over time. Let's build a writable store backed by localStorage that persists audit history across sessions.

src/lib/stores/auditHistory.ts
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
import type { AuditResult } from '$lib/seopeek';

export interface HistoryEntry {
  id: string;
  timestamp: number;
  result: AuditResult;
}

function createHistory() {
  const STORAGE_KEY = 'seopeek-audit-history';

  // Load from localStorage on init (browser only)
  const initial: HistoryEntry[] = browser
    ? JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]')
    : [];

  const { subscribe, update } = writable<HistoryEntry[]>(initial);

  return {
    subscribe,

    add(result: AuditResult) {
      const entry: HistoryEntry = {
        id: crypto.randomUUID(),
        timestamp: Date.now(),
        result
      };

      update(entries => {
        const updated = [entry, ...entries].slice(0, 100);
        if (browser) {
          localStorage.setItem(STORAGE_KEY,
            JSON.stringify(updated)
          );
        }
        return updated;
      });
    },

    clear() {
      update(() => {
        if (browser) localStorage.removeItem(STORAGE_KEY);
        return [];
      });
    },

    getByUrl(url: string): HistoryEntry[] {
      let entries: HistoryEntry[] = [];
      subscribe(e => entries = e)();
      return entries.filter(e => e.result.url === url);
    }
  };
}

export const auditHistory = createHistory();

Score trend chart

With history in place, build a minimal trend chart that shows how a URL's score changes over time. This uses raw SVG with reactive polyline points:

src/lib/components/ScoreTrend.svelte
<script lang="ts">
  import { auditHistory } from '$lib/stores/auditHistory';

  export let url: string;
  const WIDTH = 400, HEIGHT = 120, PADDING = 20;

  $: entries = $auditHistory
    .filter(e => e.result.url === url)
    .reverse();  // oldest first

  $: points = entries.map((e, i) => {
    const x = PADDING + (i / Math.max(entries.length - 1, 1))
      * (WIDTH - 2 * PADDING);
    const y = HEIGHT - PADDING
      - (e.result.score / 100) * (HEIGHT - 2 * PADDING);
    return `${x},${y}`;
  }).join(' ');
</script>

{#if entries.length > 1}
  <svg viewBox="0 0 {WIDTH} {HEIGHT}" class="trend-chart">
    <polyline
      points={points}
      fill="none"
      stroke="#10B981"
      stroke-width="2"
      stroke-linecap="round"
      stroke-linejoin="round"
    />
    {#each entries as e, i}
      <circle
        cx={PADDING + (i / Math.max(entries.length - 1, 1))
            * (WIDTH - 2 * PADDING)}
        cy={HEIGHT - PADDING
            - (e.result.score / 100) * (HEIGHT - 2 * PADDING)}
        r="4"
        fill="#10B981"
      />
    {/each}
  </svg>
{:else}
  <p class="no-data">
    Run more audits to see the score trend.
  </p>
{/if}

To connect the history store to your audit flow, add a single line to the handleAudit function in +page.svelte:

import { auditHistory } from '$lib/stores/auditHistory';

// Inside handleAudit, after getting the result:
result = await res.json();
auditHistory.add(result);  // Persist to history

The history store caps at 100 entries to keep localStorage lean. The getByUrl method filters entries for the trend chart, and the clear method wipes everything — useful for a "Reset history" button.

Power your dashboard with SEOPeek

50 free audits/day. Paid plans from $9/month. No credit card required to start.

View API Pricing →

8. Deploying to Firebase and Vercel

Your SvelteKit SEO audit dashboard is ready. Let's deploy it. Both Firebase and Vercel are solid options, each with a different adapter.

Option A: Deploy to Firebase

Firebase Hosting handles static assets while Cloud Functions run your server routes. Install the Firebase adapter and CLI:

npm install -D svelte-adapter-firebase
npm install -g firebase-tools

Update svelte.config.js to use the Firebase adapter:

svelte.config.js
import adapter from 'svelte-adapter-firebase';

export default {
  kit: {
    adapter({
      esbuildBuildOptions(defaultOptions) {
        defaultOptions.platform = 'node';
        return defaultOptions;
      }
    })
  }
};

Then configure firebase.json:

firebase.json
{
  "hosting": {
    "public": "build",
    "ignore": ["firebase.json", "**/node_modules/**"],
    "rewrites": [{
      "source": "**",
      "function": "svelteKit"
    }]
  },
  "functions": {
    "source": "build/functions"
  }
}

Set environment variables in Firebase:

firebase functions:config:set \
  seopeek.api_key="your_api_key" \
  seopeek.base_url="https://us-central1-todd-agent-prod.cloudfunctions.net/seopeekApi"

# Build and deploy
npm run build
firebase deploy --only hosting,functions

Option B: Deploy to Vercel

Vercel has first-class SvelteKit support through adapter-auto, which detects the platform automatically:

# Install the auto adapter (default in new projects)
npm install -D @sveltejs/adapter-auto

# Set environment variables in Vercel dashboard or CLI
vercel env add SEOPEEK_API_KEY
vercel env add SEOPEEK_BASE_URL

# Deploy
vercel --prod

Environment variables: Both platforms support encrypted environment variables. Never commit API keys to your repository. SvelteKit's $env/static/private import ensures keys are only available in server-side code (+server.ts, +page.server.ts, and +layout.server.ts files).

Summary of the complete file structure

src/
  lib/
    seopeek.ts                # API client with types
    components/
      AuditForm.svelte        # URL input with validation
      ScoreRing.svelte         # Animated SVG score
      CheckList.svelte         # Color-coded check results
      BatchScanner.svelte      # Multi-URL scanner
      ScoreTrend.svelte        # History trend chart
    stores/
      auditHistory.ts          # localStorage-backed store
  routes/
    +page.svelte               # Main dashboard page
    api/
      audit/
        +server.ts             # API proxy route

What to Build Next

With the core dashboard working, here are natural extensions:

The SEOPeek API handles the heavy lifting of crawling pages and running 20 SEO checks. Your SvelteKit dashboard provides the fast, reactive interface. Combined, they give you a Svelte SEO monitoring tool that's cheaper to run than SEOptimer or Seobility, and fully under your control.

Start Building Today

Get 50 free audits per day. No API key required to start. Paid plans from $9/month when you're ready to scale.

Try SEOPeek Free →

Related reading: Automate SEO checks with an API · SEO monitoring for website health · Bulk SEO audits at scale · SEO audits in CI/CD