Next.js Integration Guide
Complete guide to integrating Vertaa UX audits in Next.js applications, covering App Router, Pages Router, webhook delivery, polling/backoff, and caching patterns.
Expected integration time: under 30 minutes.
Router focus
Toggle to view App Router or Pages Router examples.
Quick navigation
1. Initial Setup
Environment Variables
Add your Vertaa API key to .env.local:
# .env.local
VERTAA_API_KEY=your_api_key_here
VERTAA_WEBHOOK_SECRET=your_webhook_secret_here # Optional, for webhook verificationTypeScript Types
Create type definitions for Vertaa API responses:
// types/vertaa.ts
export type AuditStatus = 'pending' | 'running' | 'completed' | 'failed';
export type AuditMode = 'basic' | 'deep';
export interface AuditJob {
job_id: string;
status: AuditStatus;
url: string;
mode: AuditMode;
created_at: string;
completed_at?: string;
result?: AuditResult;
error?: string;
}
export interface AuditResult {
score: number;
issues: Issue[];
metrics: {
accessibility_score: number;
usability_score: number;
conversion_score: number;
};
}
export interface Issue {
id: string;
severity: 'critical' | 'high' | 'medium' | 'low';
category: string;
title: string;
description: string;
element?: string;
recommendation: string;
}2. Creating an Audit (App Router)
Route Handler (API Route)
Create app/api/audits/create/route.ts:
// app/api/audits/create/route.ts
import { NextRequest, NextResponse } from 'next/server';
import type { AuditJob } from '@/types/vertaa';
const VERTAA_API_URL = 'https://vertaaux.ai/v1';
export async function POST(request: NextRequest) {
try {
const { url, mode = 'basic' } = await request.json();
if (!url) {
return NextResponse.json(
{ error: 'URL is required' },
{ status: 400 }
);
}
const response = await fetch(`${VERTAA_API_URL}/audit`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': 'your_api_key_here',
},
body: JSON.stringify({ url, mode }),
});
if (!response.ok) {
const error = await response.json();
return NextResponse.json(error, { status: response.status });
}
const job: AuditJob = await response.json();
return NextResponse.json(job);
} catch (error) {
console.error('Failed to create audit:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}Server Action (Alternative)
Use Server Actions for form-based submissions:
// app/actions/audit.ts
'use server';
import type { AuditJob } from '@/types/vertaa';
const VERTAA_API_URL = 'https://vertaaux.ai/v1';
export async function createAudit(
url: string,
mode: 'basic' | 'deep' = 'basic'
): Promise<AuditJob> {
const response = await fetch(`${VERTAA_API_URL}/audit`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': 'your_api_key_here',
},
body: JSON.stringify({ url, mode }),
});
if (!response.ok) {
throw new Error(`Failed to create audit: ${response.statusText}`);
}
return response.json();
}Client Component Usage
// app/components/audit-form.tsx
'use client';
import { useState } from 'react';
import { createAudit } from '@/app/actions/audit';
export function AuditForm() {
const [url, setUrl] = useState('');
const [loading, setLoading] = useState(false);
const [jobId, setJobId] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const job = await createAudit(url, 'basic');
setJobId(job.job_id);
} catch (error) {
console.error('Failed to create audit:', error);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com"
required
/>
<button type="submit" disabled={loading}>
{loading ? 'Creating...' : 'Start Audit'}
</button>
{jobId && <p>Job ID: {jobId}</p>}
</form>
);
}3. Webhook Handler
💡 Why Webhooks?
Webhooks save 95% of API quota vs polling. Instead of checking status every 2 seconds (30+ requests), receive a single callback when the audit completes.
App Router Webhook Handler
Create app/api/webhooks/vertaa/route.ts:
// app/api/webhooks/vertaa/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createHmac } from 'crypto';
import type { AuditJob } from '@/types/vertaa';
function verifySignature(payload: string, signature: string): boolean {
const secret = 'your_webhook_secret_here';
if (!secret) return false;
const hmac = createHmac('sha256', secret);
hmac.update(payload);
const expected = hmac.digest('hex');
return signature === expected;
}
export async function POST(request: NextRequest) {
try {
const signature = request.headers.get('x-vertaa-signature');
const rawBody = await request.text();
// Verify webhook signature
if (!signature || !verifySignature(rawBody, signature)) {
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 401 }
);
}
const webhook: {
event: 'audit.completed' | 'audit.failed';
data: AuditJob;
} = JSON.parse(rawBody);
// Handle the webhook event
if (webhook.event === 'audit.completed') {
const { job_id, result } = webhook.data;
// Store result in your database
// await db.audit.update({
// where: { jobId: job_id },
// data: { status: 'completed', result },
// });
// Send notification to user
// await sendNotification(job_id, result);
console.log(`Audit ${job_id} completed with score ${result?.score}`);
} else if (webhook.event === 'audit.failed') {
const { job_id, error } = webhook.data;
console.error(`Audit ${job_id} failed: ${error}`);
// Update database and notify user of failure
}
return NextResponse.json({ received: true });
} catch (error) {
console.error('Webhook processing error:', error);
return NextResponse.json(
{ error: 'Webhook processing failed' },
{ status: 500 }
);
}
}
// Disable body parsing to get raw body for signature verification
export const config = {
api: {
bodyParser: false,
},
};Register Webhook Endpoint
Register your webhook URL via the API or dashboard:
curl -X POST https://vertaaux.ai/v1/webhooks \
-H "X-API-Key: your_api_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourdomain.com/api/webhooks/vertaa",
"events": ["audit.completed", "audit.failed"]
}'4. Polling with Exponential Backoff
Use Webhooks First
Only use polling when webhooks aren't available (e.g., local development). Polling consumes significantly more API quota.
Client-Side Polling Hook
// hooks/use-audit-polling.ts
import { useState, useEffect, useCallback } from 'react';
import type { AuditJob } from '@/types/vertaa';
const MAX_RETRIES = 30; // 30 attempts
const INITIAL_DELAY = 1000; // 1 second
const MAX_DELAY = 10000; // 10 seconds
export function useAuditPolling(jobId: string | null) {
const [audit, setAudit] = useState<AuditJob | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const pollAudit = useCallback(async (
jobId: string,
attempt = 0,
delay = INITIAL_DELAY
) => {
if (attempt >= MAX_RETRIES) {
setError('Polling timeout - please check status manually');
setLoading(false);
return;
}
try {
const response = await fetch(`/api/audits/${jobId}`);
if (!response.ok) {
throw new Error(`Failed to fetch audit status`);
}
const job: AuditJob = await response.json();
setAudit(job);
if (job.status === 'completed' || job.status === 'failed') {
setLoading(false);
return;
}
// Exponential backoff: 1s → 2s → 4s → 8s → 10s (max)
const nextDelay = Math.min(delay * 2, MAX_DELAY);
setTimeout(() => {
pollAudit(jobId, attempt + 1, nextDelay);
}, delay);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
setLoading(false);
}
}, []);
useEffect(() => {
if (jobId) {
setLoading(true);
setError(null);
pollAudit(jobId);
}
}, [jobId, pollAudit]);
return { audit, loading, error };
}Usage Example
'use client';
import { useAuditPolling } from '@/hooks/use-audit-polling';
export function AuditStatus({ jobId }: { jobId: string }) {
const { audit, loading, error } = useAuditPolling(jobId);
if (loading) {
return <div>Polling audit status...</div>;
}
if (error) {
return <div>Error: {error}</div>;
}
if (!audit) {
return null;
}
return (
<div>
<p>Status: {audit.status}</p>
{audit.result && (
<>
<p>Score: {audit.result.score}/100</p>
<p>Issues found: {audit.result.issues.length}</p>
</>
)}
</div>
);
}5. Caching Patterns
Next.js Data Cache (App Router)
Use Next.js built-in caching for Server Components:
// app/audits/[jobId]/page.tsx
import type { AuditJob } from '@/types/vertaa';
const VERTAA_API_URL = 'https://vertaaux.ai/v1';
async function getAudit(jobId: string): Promise<AuditJob> {
const response = await fetch(`${VERTAA_API_URL}/audit/${jobId}`, {
headers: {
'X-API-Key': 'your_api_key_here',
},
next: {
revalidate: 3600, // Cache for 1 hour
tags: [`audit-${jobId}`], // For on-demand revalidation
},
});
if (!response.ok) {
throw new Error('Failed to fetch audit');
}
return response.json();
}
export default async function AuditPage({
params,
}: {
params: { jobId: string };
}) {
const audit = await getAudit(params.jobId);
return (
<div>
<h1>Audit Results</h1>
<p>Score: {audit.result?.score}</p>
{/* Render audit results */}
</div>
);
}
// Optional: Revalidate on-demand
// import { revalidateTag } from 'next/cache';
// revalidateTag(`audit-${jobId}`);Redis Caching (Both Routers)
// lib/cache.ts
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
const CACHE_TTL = 3600; // 1 hour
export async function getCachedAudit(jobId: string) {
const cached = await redis.get(`audit:${jobId}`);
if (cached) {
return JSON.parse(cached as string);
}
return null;
}
export async function setCachedAudit(jobId: string, data: unknown) {
await redis.setex(
`audit:${jobId}`,
CACHE_TTL,
JSON.stringify(data)
);
}
// Usage in API route
export async function GET(
request: NextRequest,
{ params }: { params: { jobId: string } }
) {
// Try cache first
const cached = await getCachedAudit(params.jobId);
if (cached) {
return NextResponse.json(cached);
}
// Fetch from Vertaa API
const response = await fetch(
`https://vertaaux.ai/v1/audit/${params.jobId}`,
{
headers: { 'X-API-Key': 'your_api_key_here' },
}
);
const data = await response.json();
// Cache completed audits only
if (data.status === 'completed') {
await setCachedAudit(params.jobId, data);
}
return NextResponse.json(data);
}In-Memory LRU Cache
For simpler use cases without external dependencies:
// lib/memory-cache.ts
import { LRUCache } from 'lru-cache';
import type { AuditJob } from '@/types/vertaa';
const cache = new LRUCache<string, AuditJob>({
max: 500, // Maximum 500 items
ttl: 1000 * 60 * 60, // 1 hour TTL
});
export function getCached(jobId: string): AuditJob | undefined {
return cache.get(jobId);
}
export function setCache(jobId: string, data: AuditJob): void {
// Only cache completed audits
if (data.status === 'completed') {
cache.set(jobId, data);
}
}6. Server Components Pattern
Server Component with Streaming
Load audit data on the server and stream to the client:
// app/dashboard/audits/page.tsx
import { Suspense } from 'react';
import type { AuditJob } from '@/types/vertaa';
async function getRecentAudits(): Promise<AuditJob[]> {
const response = await fetch('https://vertaaux.ai/v1/audits', {
headers: {
'X-API-Key': 'your_api_key_here',
},
next: { revalidate: 60 }, // Revalidate every minute
});
if (!response.ok) throw new Error('Failed to fetch audits');
return response.json();
}
async function AuditList() {
const audits = await getRecentAudits();
return (
<ul>
{audits.map((audit) => (
<li key={audit.job_id}>
<a href={`/audits/${audit.job_id}`}>
{audit.url} - {audit.status}
</a>
</li>
))}
</ul>
);
}
export default function AuditsPage() {
return (
<div>
<h1>Recent Audits</h1>
<Suspense fallback={<div>Loading audits...</div>}>
<AuditList />
</Suspense>
</div>
);
}7. Best Practices & Security
Do's
- Always verify webhook signatures using HMAC-SHA256
- Use webhooks over polling to save 95% API quota
- Cache completed audits for 1-24 hours (results don't change)
- Implement exponential backoff when polling (1s → 2s → 4s → 8s)
- Keep API keys in environment variables, never in client code
- Use basic mode when possible (10x faster than deep)
- Monitor rate limit headers (X-RateLimit-Remaining)
- Handle errors gracefully with user-friendly messages
Don'ts
- Never expose API keys in client-side code or public repos
- Don't poll without backoff - you'll hit rate limits quickly
- Don't cache pending/running audits - only completed ones
- Don't ignore webhook signatures - verify all incoming webhooks
- Don't use deep mode for everything - it's slower and costs more quota
- Don't make parallel requests beyond your tier's concurrency limit
Environment-Specific Configuration
// lib/config.ts
export const config = {
vertaa: {
apiUrl: process.env.VERTAA_API_URL || 'https://vertaaux.ai/v1',
apiKey: 'your_api_key_here',
webhookSecret: 'your_webhook_secret_here',
// Development: use polling, Production: use webhooks
useWebhooks: process.env.NODE_ENV === 'production',
// Adjust cache TTL by environment
cacheTTL: process.env.NODE_ENV === 'production' ? 3600 : 300,
},
};
// Validate required environment variables
if (!config.vertaa.apiKey) {
throw new Error('VERTAA_API_KEY is required');
}
if (config.vertaa.useWebhooks && !config.vertaa.webhookSecret) {
console.warn('VERTAA_WEBHOOK_SECRET not set - webhook verification disabled');
}8. Quick Reference
Common Operations
Create an audit
POST /v1/audit → { job_id }Check audit status
GET /v1/audit/:job_id → { status, result }List audits
GET /v1/audits → AuditJob[]Register webhook
POST /v1/webhooks → { id, url, events }Check quota
GET /v1/usage → { used, limit, remaining }Next Steps
Need Help?
If you run into issues or have questions about the integration:
- Email: support@vertaaux.ai
- Full API Docs: Developer Documentation
- Troubleshooting: Common Issues