Svelte + API

Build an SEO Audit Dashboard in Svelte with SEOPeek API

Published March 29, 2026 · 12 min read

Svelte has earned its reputation as the framework that ships less JavaScript, compiles faster, and makes reactivity feel native. If you are building internal tools, client dashboards, or SaaS products that need SEO auditing, SvelteKit paired with the SEOPeek API is one of the cleanest stacks you can choose.

In this guide you will build a complete SEO audit dashboard from scratch. It will include a writable store for managing audit state, a SvelteKit page component that calls the SEOPeek API, an animated circular score gauge rendered with SVG, color-coded pass/warn/fail check results, and a bulk URL scanner that handles dozens of URLs concurrently. Every code example is copy-paste ready.

Why Svelte for an SEO Dashboard

Most SEO dashboards are data-heavy. They display dozens of checks per URL, render score gauges, and update frequently. Svelte handles this better than React or Vue for three reasons:

SvelteKit adds server-side load functions, file-based routing, and API routes out of the box. It is a natural fit for calling the SEOPeek API on the server and streaming results to the client.

Prerequisites

Before you start, make sure you have the following ready:

Store your API key in a .env file at the project root:

SEOPEEK_API_KEY=sk_live_your_key_here

Step 1: Create the Audit Store

Svelte stores are the backbone of your dashboard state. Create a file at src/lib/stores/audit.js that holds audit results, loading state, and error messages in a single writable store:

// src/lib/stores/audit.js
import { writable, derived } from 'svelte/store';

function createAuditStore() {
  const { subscribe, set, update } = writable({
    results: [],
    loading: false,
    error: null,
    currentUrl: ''
  });

  return {
    subscribe,
    startAudit(url) {
      update(state => ({
        ...state,
        loading: true,
        error: null,
        currentUrl: url
      }));
    },
    addResult(result) {
      update(state => ({
        ...state,
        results: [result, ...state.results],
        loading: false
      }));
    },
    setError(error) {
      update(state => ({
        ...state,
        error: error.message || error,
        loading: false
      }));
    },
    clearResults() {
      set({ results: [], loading: false, error: null, currentUrl: '' });
    }
  };
}

export const auditStore = createAuditStore();

// Derived store: average score across all audits
export const averageScore = derived(auditStore, $store => {
  if ($store.results.length === 0) return 0;
  const total = $store.results.reduce((sum, r) => sum + r.score, 0);
  return Math.round(total / $store.results.length);
});

This pattern wraps the raw writable in a custom store with named methods. Components call auditStore.startAudit(url) instead of manually updating state objects. The derived averageScore store recalculates automatically whenever results change.

Step 2: Build the API Client

Create a thin wrapper around the SEOPeek API at src/lib/api/seopeek.js. This keeps your fetch logic in one place and handles errors consistently:

// src/lib/api/seopeek.js
const BASE_URL = 'https://seopeek.web.app/api/v1';

export async function auditUrl(url, apiKey) {
  const response = await fetch(
    `${BASE_URL}/audit?url=${encodeURIComponent(url)}`,
    {
      headers: {
        'Authorization': `Bearer ${apiKey}`,
        'Content-Type': 'application/json'
      }
    }
  );

  if (!response.ok) {
    const body = await response.json().catch(() => ({}));
    throw new Error(body.error || `Audit failed: ${response.status}`);
  }

  return response.json();
}

export async function bulkAudit(urls, apiKey, onProgress) {
  const promises = urls.map(async (url, index) => {
    try {
      const result = await auditUrl(url, apiKey);
      onProgress?.({ url, result, index, status: 'fulfilled' });
      return { status: 'fulfilled', url, value: result };
    } catch (error) {
      onProgress?.({ url, error, index, status: 'rejected' });
      return { status: 'rejected', url, reason: error.message };
    }
  });

  return Promise.allSettled(promises);
}

The bulkAudit function uses Promise.allSettled so that one failing URL does not cancel the entire batch. The onProgress callback fires after each URL completes, which lets you update the UI incrementally rather than waiting for every URL to finish.

Step 3: The Dashboard Page Component

Now build the main page at src/routes/+page.svelte. This component ties together the store, the API client, and the UI:

<!-- src/routes/+page.svelte -->
<script>
  import { auditStore, averageScore } from '$lib/stores/audit';
  import { auditUrl } from '$lib/api/seopeek';
  import ScoreGauge from '$lib/components/ScoreGauge.svelte';
  import CheckResult from '$lib/components/CheckResult.svelte';
  import { env } from '$env/dynamic/private';

  let urlInput = '';

  async function handleAudit() {
    if (!urlInput.trim()) return;
    const url = urlInput.trim();
    auditStore.startAudit(url);

    try {
      const result = await auditUrl(url, env.SEOPEEK_API_KEY);
      auditStore.addResult({ url, ...result });
    } catch (err) {
      auditStore.setError(err);
    }
  }
</script>

<div class="dashboard">
  <h1>SEO Audit Dashboard</h1>

  <form on:submit|preventDefault={handleAudit}>
    <input
      type="url"
      bind:value={urlInput}
      placeholder="https://example.com"
      disabled={$auditStore.loading}
    />
    <button type="submit" disabled={$auditStore.loading}>
      {$auditStore.loading ? 'Scanning...' : 'Run Audit'}
    </button>
  </form>

  {#if $auditStore.error}
    <div class="error">{$auditStore.error}</div>
  {/if}

  {#if $auditStore.results.length > 0}
    <div class="summary">
      <ScoreGauge score={$averageScore} label="Average" />
      <p>{$auditStore.results.length} URLs audited</p>
    </div>

    {#each $auditStore.results as result (result.url)}
      <div class="audit-card">
        <div class="card-header">
          <ScoreGauge score={result.score} size={64} />
          <h2>{result.url}</h2>
        </div>
        {#each result.checks as check}
          <CheckResult {check} />
        {/each}
      </div>
    {/each}
  {/if}
</div>

Notice how little glue code is needed. The $auditStore auto-subscription means the UI updates the moment the store changes. The on:submit|preventDefault modifier replaces the usual event.preventDefault() boilerplate. And the {#each ... (result.url)} keyed block ensures efficient re-renders when the results array changes.

Step 4: Animated Circular Score Gauge

The score gauge is the visual centerpiece of any SEO dashboard. This component uses an SVG circle with stroke-dashoffset animation and Svelte's built-in tweened store for buttery-smooth transitions:

<!-- src/lib/components/ScoreGauge.svelte -->
<script>
  import { tweened } from 'svelte/motion';
  import { cubicOut } from 'svelte/easing';

  export let score = 0;
  export let size = 120;
  export let strokeWidth = 8;

  const radius = (size - strokeWidth) / 2;
  const circumference = 2 * Math.PI * radius;

  const animatedScore = tweened(0, {
    duration: 800,
    easing: cubicOut
  });

  $: animatedScore.set(score);
  $: offset = circumference - ($animatedScore / 100) * circumference;
  $: color = $animatedScore >= 80 ? '#10B981'
           : $animatedScore >= 50 ? '#F59E0B'
           : '#EF4444';
</script>

<div class="gauge" style="width: {size}px; height: {size}px;">
  <svg viewBox="0 0 {size} {size}">
    <circle
      cx={size / 2}
      cy={size / 2}
      r={radius}
      fill="none"
      stroke="#27272A"
      stroke-width={strokeWidth}
    />
    <circle
      cx={size / 2}
      cy={size / 2}
      r={radius}
      fill="none"
      stroke={color}
      stroke-width={strokeWidth}
      stroke-linecap="round"
      stroke-dasharray={circumference}
      stroke-dashoffset={offset}
      transform="rotate(-90 {size / 2} {size / 2})"
      style="transition: stroke 0.3s ease;"
    />
  </svg>
  <span class="score-label" style="color: {color};">
    {Math.round($animatedScore)}
  </span>
</div>

<style>
  .gauge {
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }
  svg {
    position: absolute;
    top: 0;
    left: 0;
  }
  .score-label {
    font-family: 'Sora', sans-serif;
    font-weight: 700;
    font-size: 1.5rem;
    z-index: 1;
  }
</style>

Here is how it works. The tweened store interpolates from the old score to the new one over 800 milliseconds using a cubic-out easing curve. The reactive declaration $: offset = ... recalculates the stroke-dashoffset on every tick of the animation. The color shifts from red (below 50) to amber (50-79) to emerald green (80+) as the score animates up. No external animation library needed.

Step 5: Color-Coded Check Results

Each SEOPeek audit returns an array of individual checks (title tag length, meta description, heading structure, image alt text, and more). Display them with a clear pass/warn/fail visual hierarchy:

<!-- src/lib/components/CheckResult.svelte -->
<script>
  export let check;

  const statusConfig = {
    pass: { icon: '✓', color: '#10B981', bg: 'rgba(16,185,129,0.1)' },
    warn: { icon: '!', color: '#F59E0B', bg: 'rgba(245,158,11,0.1)' },
    fail: { icon: '✗', color: '#EF4444', bg: 'rgba(239,68,68,0.1)' }
  };

  $: config = statusConfig[check.status] || statusConfig.warn;
</script>

<div class="check" style="border-left: 3px solid {config.color};">
  <span
    class="status-badge"
    style="background: {config.bg}; color: {config.color};"
  >
    {config.icon}
  </span>
  <div class="check-content">
    <strong>{check.name}</strong>
    <p>{check.message}</p>
  </div>
</div>

<style>
  .check {
    display: flex;
    align-items: flex-start;
    gap: 0.75rem;
    padding: 0.75rem 1rem;
    margin-bottom: 0.5rem;
    background: #18181B;
    border-radius: 6px;
  }
  .status-badge {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 28px;
    height: 28px;
    border-radius: 50%;
    font-weight: 700;
    font-size: 0.85rem;
    flex-shrink: 0;
  }
  .check-content p {
    color: #A1A1AA;
    font-size: 0.875rem;
    margin: 0.25rem 0 0 0;
  }
</style>

The border-left color gives instant visual scanning. Users can glance at a list of 20 checks and immediately spot the red lines that need attention. The reactive $: config declaration means the component updates automatically if the check status changes after a re-audit.

Step 6: Bulk URL Scanning

The real power of an SEO dashboard comes from auditing multiple URLs at once. Here is a bulk scanning component that accepts a newline-separated list of URLs and processes them concurrently with progress tracking:

<!-- src/lib/components/BulkScanner.svelte -->
<script>
  import { auditStore } from '$lib/stores/audit';
  import { bulkAudit } from '$lib/api/seopeek';

  let urlText = '';
  let scanning = false;
  let completed = 0;
  let total = 0;

  $: progress = total > 0 ? Math.round((completed / total) * 100) : 0;

  async function handleBulkScan() {
    const urls = urlText
      .split('\n')
      .map(u => u.trim())
      .filter(u => u.startsWith('http'));

    if (urls.length === 0) return;

    scanning = true;
    completed = 0;
    total = urls.length;

    const results = await bulkAudit(
      urls,
      import.meta.env.VITE_SEOPEEK_API_KEY,
      ({ url, result, status }) => {
        completed++;
        if (status === 'fulfilled') {
          auditStore.addResult({ url, ...result });
        }
      }
    );

    const failed = results.filter(r => r.status === 'rejected');
    if (failed.length > 0) {
      auditStore.setError(
        `${failed.length} of ${total} URLs failed to audit`
      );
    }

    scanning = false;
  }
</script>

<div class="bulk-scanner">
  <h3>Bulk URL Scanner</h3>
  <textarea
    bind:value={urlText}
    placeholder="Paste URLs (one per line)
https://example.com
https://example.com/about"
    rows="6"
    disabled={scanning}
  />

  {#if scanning}
    <div class="progress-bar">
      <div class="progress-fill" style="width: {progress}%"></div>
    </div>
    <p class="progress-text">{completed} / {total} URLs scanned ({progress}%)</p>
  {/if}

  <button on:click={handleBulkScan} disabled={scanning}>
    {scanning ? `Scanning ${completed}/${total}...` : 'Scan All URLs'}
  </button>
</div>

The onProgress callback fires after each individual URL resolves. Because completed is a regular Svelte variable, incrementing it triggers a reactive update of the progress bar and percentage text. The user sees results streaming in one by one rather than staring at a spinner.

Step 7: Server-Side API Route

For production use, you should never expose your API key to the client. Create a SvelteKit API route at src/routes/api/audit/+server.js that proxies requests through your server:

// src/routes/api/audit/+server.js
import { json } from '@sveltejs/kit';
import { SEOPEEK_API_KEY } from '$env/static/private';

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

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

  const response = await fetch(
    `https://seopeek.web.app/api/v1/audit?url=${encodeURIComponent(targetUrl)}`,
    {
      headers: {
        'Authorization': `Bearer ${SEOPEEK_API_KEY}`
      }
    }
  );

  const data = await response.json();

  if (!response.ok) {
    return json(
      { error: data.error || 'Audit failed' },
      { status: response.status }
    );
  }

  return json(data, {
    headers: {
      'Cache-Control': 'public, max-age=3600'
    }
  });
}

Now your client-side code calls /api/audit?url=... instead of the SEOPeek API directly. The API key stays on the server, and you get free caching with the Cache-Control header to avoid burning through your audit quota on repeat scans of the same URL.

Pricing: SEOPeek vs SEOptimer

If you are choosing an API to power your Svelte dashboard, pricing matters. Here is how SEOPeek stacks up against SEOptimer, one of the most common alternatives:

Feature SEOPeek Starter SEOptimer API
Monthly price $9/mo $29/mo
Audits included 1,000 500
JSON API access Yes Yes
Individual check scores Yes (30+ checks) Limited
Response time <2 seconds 3-5 seconds
Free tier 50 audits/day No
Cost per audit $0.009 $0.058

At $9/month for 1,000 audits, SEOPeek costs 69% less than SEOptimer's comparable plan while delivering more audits, faster response times, and a generous free tier for development. For a Svelte dashboard that might scan 20-50 URLs per client report, the Starter plan covers most small agencies comfortably.

Putting It All Together

Your project structure should now look like this:

src/
  lib/
    api/
      seopeek.js          # API client wrapper
    components/
      ScoreGauge.svelte    # Animated circular gauge
      CheckResult.svelte   # Pass/warn/fail check row
      BulkScanner.svelte   # Multi-URL scanner
    stores/
      audit.js             # Writable + derived stores
  routes/
    +page.svelte           # Main dashboard page
    api/
      audit/
        +server.js         # Server-side API proxy

Run npm run dev and open your browser to see the dashboard. Enter a URL, hit Run Audit, and watch the score gauge animate from zero to the final score while check results populate below it. Switch to the bulk scanner tab, paste a list of URLs, and watch the progress bar fill as each audit completes.

Production Tips

Next Steps

Once the basic dashboard is running, consider extending it with these features:

  1. Historical tracking — Store each audit in a database (Supabase, PocketBase, or even a JSON file) and chart score trends over time with a library like LayerCake.
  2. PDF export — Use jsPDF or html2canvas to let users download branded audit reports for their clients.
  3. Scheduled scans — Set up a cron job or SvelteKit server hook that re-audits saved URLs weekly and alerts the user when scores drop.
  4. Webhook integration — Pipe audit results to Slack, Discord, or email when a critical check fails.

Svelte's reactivity and SEOPeek's fast, affordable API make a powerful combination for building SEO tools that feel polished and respond instantly. The entire dashboard above is under 300 lines of code. No state management library, no animation framework, no CSS-in-JS. Just Svelte doing what it does best.

Start building your Svelte SEO dashboard today

Free tier includes 50 audits/day. No credit card required.

Get Your Free API Key →