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.
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:
$:) and stores (writable, tweened) eliminate boilerplate. You do not need useEffect, useState, or a state management library.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.
Before you start, make sure you have the following ready:
npm create svelte@latest seo-dashboard)Store your API key in a .env file at the project root:
SEOPEEK_API_KEY=sk_live_your_key_here
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.
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.
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.
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.
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.
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.
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.
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.
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.
await new Promise(r => setTimeout(r, 200)) between each call prevents throttling.+error.svelte component to show a graceful fallback when the API is unreachable.+page.server.js load functions if you want audit results to appear in the initial HTML for SEO (yes, even SEO dashboards need SEO).Once the basic dashboard is running, consider extending it with these features:
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 →