Skip to main content
Developer Docs
Framework integration

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.

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 verification

TypeScript 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: