vatverify home
All guides

Validate VAT numbers in Next.js (App Router)

Add VAT validation to a Next.js 15 app using server actions, route handlers, and @vatverify/node. Includes form binding and error states.

TL;DR

  • Add a 'use server' action, call vat.validate({ vat_number: vatNumber }), return the result.
  • Your API key never touches the browser.
  • Works with useActionState for pending and error states.
SDK status

@vatverify/node is packaged and ships on npm at the API launch. Reading this before launch? The REST endpoint is live today. Inside the server action, swap vat.validate({ vat_number }) for a fetch('https://api.vatverify.dev/v1/validate?vat_number=...') call with an Authorization: Bearer header. The action wrapper, form binding, and error handling stay identical.

Why server actions beat client-side fetches for VAT validation

If VAT validation runs in a client component, your API key is exposed. Full stop. It appears as a header on every outgoing network request in the browser's DevTools, and any NEXT_PUBLIC_* env var is inlined into the client bundle at build time. A scraped key can be used by anyone to burn through your quota, trigger rate-limit bans on your account, or worse, if your key has write scopes elsewhere.

A server action with 'use server' runs exclusively on the server. The function body, the API key, and the network call to api.vatverify.dev all stay behind your Next.js process. The browser only ever sees a plain form submission and the typed response. No key, no risk.

The secondary benefit is co-location: the validation logic lives next to the form it belongs to, not in a separate API route you have to keep in sync. Less indirection, less state to reason about, easier to test.

Installation

npm install @vatverify/node

Add your API key to .env.local:

.env.local
VATVERIFY_API_KEY=vtv_live_xxxxxxxxxxxx

Server action pattern

The simplest approach: co-locate the validation logic with your form as a server action.

app/actions/validate-vat.ts
'use server';

import { Vatverify, VatverifyError } from '@vatverify/node';

const vat = new Vatverify(process.env.VATVERIFY_API_KEY!);

export type ValidateVatResult =
  | { success: true; companyName: string; country: string }
  | { success: false; error: string };

export async function validateVat(vatNumber: string): Promise<ValidateVatResult> {
  if (!vatNumber.trim()) {
    return { success: false, error: 'VAT number is required.' };
  }

  try {
    const result = await vat.validate({ vat_number: vatNumber.trim().toUpperCase() });
    if (!result.data.valid) {
      return { success: false, error: `${result.data.vat_number} is not registered.` };
    }
    return {
      success: true,
      companyName: result.data.company?.name ?? result.data.vat_number,
      country: result.data.country.code,
    };
  } catch (err) {
    if (err instanceof VatverifyError) {
      if (err.code === 'rate_limited') {
        return { success: false, error: 'Too many requests. Please try again shortly.' };
      }
    }
    return { success: false, error: 'Validation service unavailable.' };
  }
}

Route handler pattern

Use this when you need a REST endpoint, for example to validate from a mobile app, a third-party webhook, or a non-Next.js frontend:

app/api/validate-vat/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { Vatverify, VatverifyError } from '@vatverify/node';

const vat = new Vatverify(process.env.VATVERIFY_API_KEY!);

export async function POST(req: NextRequest) {
  const body = await req.json().catch(() => null);
  const vatNumber = body?.vatNumber;

  if (typeof vatNumber !== 'string' || !vatNumber.trim()) {
    return NextResponse.json({ error: 'vatNumber is required' }, { status: 400 });
  }

  try {
    const result = await vat.validate({ vat_number: vatNumber.trim().toUpperCase() });
    return NextResponse.json({
      valid: result.data.valid,
      vat_number: result.data.vat_number,
      country: result.data.country,
      company: result.data.company,
    });
  } catch (err) {
    if (err instanceof VatverifyError) {
      const status = err.code === 'rate_limited' ? 429 : 502;
      return NextResponse.json({ error: err.message }, { status });
    }
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
  }
}

Form with useActionState

A client component that wires the server action to a form, with pending, error, and success states:

app/checkout/vat-form.tsx
'use client';

import { useActionState } from 'react';
import { validateVat, ValidateVatResult } from '@/app/actions/validate-vat';

const initialState: ValidateVatResult | null = null;

export function VatForm() {
  const [state, action, isPending] = useActionState(
    async (_prev: ValidateVatResult | null, formData: FormData) => {
      const vatNumber = formData.get('vatNumber') as string;
      return validateVat(vatNumber);
    },
    initialState
  );

  return (
    <form action={action} className="space-y-4">
      <div>
        <label htmlFor="vatNumber" className="block text-sm font-medium mb-1">
          VAT number
        </label>
        <input
          id="vatNumber"
          name="vatNumber"
          type="text"
          placeholder="IE6388047V"
          className="border rounded px-3 py-2 w-full font-mono"
          disabled={isPending}
        />
      </div>

      <button
        type="submit"
        disabled={isPending}
        className="bg-black text-white px-4 py-2 rounded disabled:opacity-50"
      >
        {isPending ? 'Checking…' : 'Validate'}
      </button>

      {state?.success === false && (
        <p className="text-red-600 text-sm">{state.error}</p>
      )}
      {state?.success === true && (
        <p className="text-green-600 text-sm">
{state.companyName} ({state.country})
        </p>
      )}
    </form>
  );
}

Edge vs Node runtime

The @vatverify/node SDK works on both the Node.js and Edge runtimes. If you need Node-only APIs (file system, crypto, etc.) alongside VAT validation, pin the route to Node explicitly:

app/api/validate-vat/route.ts
export const runtime = 'nodejs';

Otherwise the default Edge runtime is fine and gives you lower cold-start latency on Vercel.

Streaming / optimistic UI

For a snappier feel, combine useOptimistic with useActionState to render a provisional result the moment the user submits, before the server action has returned. While the request is in flight, the UI shows a "Checking IE6388047V…" skeleton keyed on the entered number; when the real response lands, React swaps the optimistic state for the actual result. Pairs naturally with the isPending state already exposed by useActionState:

app/checkout/vat-form-optimistic.tsx
'use client';

import { useActionState, useOptimistic } from 'react';
import { validateVat, ValidateVatResult } from '@/app/actions/validate-vat';

export function VatForm() {
  const [state, action, isPending] = useActionState(
    async (_prev: ValidateVatResult | null, formData: FormData) =>
      validateVat(formData.get('vatNumber') as string),
    null,
  );
  const [optimistic, addOptimistic] = useOptimistic<{ vat: string } | null>(null);

  return (
    <form
      action={async (formData) => {
        addOptimistic({ vat: formData.get('vatNumber') as string });
        await action(formData);
      }}
    >
      <input name="vatNumber" defaultValue="" />
      <button type="submit" disabled={isPending}>Validate</button>
      {isPending && optimistic && <p>Checking {optimistic.vat}…</p>}
      {!isPending && state?.success && <p>✓ {state.companyName}</p>}
    </form>
  );
}

The optimistic state is automatically discarded once useActionState commits the real response. No cleanup needed.

Testing with Playwright

Smoke test that the form validates a known VAT number end-to-end:

tests/vat-form.spec.ts
import { test, expect } from '@playwright/test';

test('validates a known Irish VAT number', async ({ page }) => {
  await page.goto('/checkout');

  await page.fill('[name="vatNumber"]', 'IE6388047V');
  await page.click('button[type="submit"]');

  // The action uses vtv_test_* in CI via VATVERIFY_API_KEY env var
  await expect(page.locator('text=✓')).toBeVisible({ timeout: 5000 });
});

Set VATVERIFY_API_KEY=vtv_test_xxx in your CI environment to get deterministic fixture responses.

FAQ

Does this work with the Pages Router?

Yes, use an API route (pages/api/validate-vat.ts) and fetch from the client, or use getServerSideProps. Server actions are App Router only, but the @vatverify/node SDK works in both.

Is the Edge runtime fully supported?

Yes. The SDK makes standard fetch calls and has no Node-only dependencies, so it runs on the Edge runtime without any configuration.

Should I use middleware or a route handler to validate VAT on every request?

Neither. VAT validation belongs at the point of user input (checkout form, billing settings), not in middleware. Middleware runs on every request and adding a network call there will hurt your TTFB significantly.

Can I cache VAT validation results in Next.js?

The API already returns 30-day cached results for registered numbers. If you want an additional in-process cache, use unstable_cache from next/cache keyed on the normalised VAT number string.

Where should I put VATVERIFY_API_KEY?

In .env.local for development, and in your hosting provider's environment variable settings for production. Never prefix it with NEXT_PUBLIC_. That would expose it to the browser.

Validate VAT in three lines.

Free up to 500 requests per month. No credit card.

Start free