VAT Validation in Next.js Server Actions

28 Oct 2026

VAT Validation in Next.js Server Actions

This is the implementation for validating a VAT ID inside a Next.js Server Action at form submission: a useActionState hook, a server-only call to vatnode, and a response mapping that distinguishes an invalid number from a check that didn’t complete. For validation that runs live as the customer types, see Real-Time VAT Validation in a React Checkout Form instead – this post is about validating once, on submit.

Why not as-you-type here

The Next.js Server Actions and Mutations guide (checked 2 Oct 2026, docs for Next.js 16) says Next.js dispatches Server Actions from a given client one at a time: a second invocation waits for the first to resolve before it’s sent. Fine for a single submit button, not for a debounced per-keystroke check – a vatnode call can take several seconds when VIES itself is slow, so the action for the ninth keystroke queues behind the one for the fifth, and the UI falls further behind the input with every character typed.

A route handler doesn’t have this restriction – each fetch is an independent HTTP request, which is why the React checkout form post uses one for live validation. A Server Action is the right tool for the submit, the moment that sets the order’s VAT treatment (subject to the re-check before invoicing).

A Server Action is a public POST endpoint

Per the same guide, a Server Action is a route any client sending the matching POST request can reach, whether or not your UI renders a button pointing at it. Next.js checks the request’s Origin against its Host as a CSRF defense, but that’s not application-level authorization – it stops a cross-site form from POSTing to your action, not a well-formed request from a tool that isn’t your form.

For a VAT check, that means an action calling vatnode is a proxy to your API quota. Three things follow:

  • Validate the input shape locally before making the upstream call – a malformed string shouldn’t reach vatnode at all.
  • If the form sits behind a login, protect it with your app’s own session check, the same as any other mutation.
  • Return a shaped result, not whatever vatnode’s response body happens to contain – the Next.js docs call this ‘constrain return values.’

Keep the key server-only

lib/vatnode.ts holds the call and nothing touches it from client code:

// lib/vatnode.ts
import 'server-only'

export interface VatValidationResult {
  valid: boolean
  companyName: string | null
  consultationNumber: string | null
  source: string
  checkId: string
  verifiedAt: string
}

interface VatnodeErrorEnvelope {
  error: {
    code: string
    message: string
    requestId: string
  }
}

type VatCheckResponse =
  | { ok: true; result: VatValidationResult }
  | { ok: false; code: string; message: string }

export async function checkVat(vatId: string): Promise<VatCheckResponse> {
  const apiKey = process.env.VATNODE_API_KEY
  if (!apiKey) {
    throw new Error('VATNODE_API_KEY is not set')
  }

  let res: Response
  let body: unknown
  try {
    res = await fetch(`https://api.vatnode.dev/v1/vat/${encodeURIComponent(vatId)}`, {
      headers: { Authorization: `Bearer ${apiKey}` },
      cache: 'no-store',
      // Keep this above vatnode's own upstream budget, not below it — a
      // short timeout here aborts checks vatnode would have completed.
      signal: AbortSignal.timeout(35_000),
    })
    body = await res.json()
  } catch {
    // Network failure, the timeout firing, or a non-JSON body from a proxy.
    return { ok: false, code: 'NETWORK_ERROR', message: 'Could not reach vatnode.' }
  }

  if (res.ok) {
    return { ok: true, result: body as VatValidationResult }
  }

  const envelope = body as VatnodeErrorEnvelope
  return {
    ok: false,
    code: envelope.error?.code ?? 'UNKNOWN',
    message: envelope.error?.message ?? 'Unknown error',
  }
}

The server-only import turns an accidental client-component import of this module into a build error. process.env.VATNODE_API_KEY – no NEXT_PUBLIC_ prefix – stays out of the browser bundle entirely. If it’s missing, throw at call time; there’s no sane fallback for a missing credential.

Local format check without breaking test mode

Normalize the raw input the same way vatnode’s own API does – trim, uppercase, strip spaces, hyphens, and dots – then run it past validateFormat from the npm package eu-vat-rates-data:

function normalize(raw: string): string {
  return raw
    .trim()
    .toUpperCase()
    .replace(/[\s\-.]/g, '')
}
import { validateFormat } from 'eu-vat-rates-data'

validateFormat('DE123456789') // true
validateFormat('XX0000001') // false

validateFormat runs the ID against per-country regex patterns, and XX isn’t a country – it’s vatnode’s test-mode prefix with no pattern of its own, so it fails the check. Let it through explicitly:

if (!(vatId.startsWith('XX') || validateFormat(vatId))) {
  // reject locally
}

Skipping this check wouldn’t break anything: a vat_live_ key sent an XX id still gets 400 INVALID_FORMAT back from vatnode, and a malformed ID sent to a vat_test_ key gets the same 400 without hitting a fixture. The local check just avoids the round trip. Format-valid is not the same as registered – Greek VAT numbers use the prefix EL, not GR. Northern Ireland uses XI, for goods only.

The action, wired to useActionState

React 19’s useActionState(action, initialState) – imported from react, not react-dom – returns [state, formAction, pending], and the action itself receives (prevState, formData). A file with 'use server' at the top can only export async functions, so the state type and its initial value live in a separate module:

// app/checkout/types.ts
export interface CheckoutState {
  status: 'idle' | 'valid' | 'invalid' | 'unverified' | 'error'
  vatId: string
  message: string | null
  companyName?: string | null
}

export const initialCheckoutState: CheckoutState = {
  status: 'idle',
  vatId: '',
  message: null,
}
// app/checkout/actions.ts
'use server'

import { validateFormat } from 'eu-vat-rates-data'
import { checkVat } from '@/lib/vatnode'
import type { CheckoutState } from './types'

function normalize(raw: string): string {
  return raw
    .trim()
    .toUpperCase()
    .replace(/[\s\-.]/g, '')
}

// Stub — replace with your own persistence. Shown here only so the snippet
// compiles; the point is that it runs regardless of the VAT verdict.
async function saveBillingDetails(vatId: string, verified: boolean): Promise<void> {
  void vatId
  void verified
}

const OPERATOR_ERRORS = new Set([
  'UNAUTHORIZED',
  'INVALID_API_KEY',
  'INVALID_REQUESTER',
  'RATE_LIMITED',
])

const UNVERIFIED_MESSAGE =
  "We couldn't verify your VAT number right now. You can continue; VAT will be charged until it's confirmed."

export async function checkVatAction(
  _prevState: CheckoutState,
  formData: FormData
): Promise<CheckoutState> {
  const vatId = normalize(String(formData.get('vatId') ?? ''))

  if (vatId.length === 0) {
    return { status: 'invalid', vatId, message: 'Enter a VAT number.' }
  }

  if (!(vatId.startsWith('XX') || validateFormat(vatId))) {
    return { status: 'invalid', vatId, message: 'This does not look like a VAT number.' }
  }

  const response = await checkVat(vatId)

  if (!response.ok) {
    if (response.code === 'INVALID_FORMAT') {
      return { status: 'invalid', vatId, message: 'This does not look like a VAT number.' }
    }

    // Your side, not the customer's: a wrong key, a bad requester VAT, an empty quota.
    if (OPERATOR_ERRORS.has(response.code)) {
      console.error('vatnode check needs attention', response)
    }

    await saveBillingDetails(vatId, false)
    return { status: 'unverified', vatId, message: UNVERIFIED_MESSAGE }
  }

  if (!response.result.valid) {
    await saveBillingDetails(vatId, false)
    return {
      status: 'invalid',
      vatId,
      message: 'VIES reports this VAT number as not currently valid for intra-EU transactions.',
    }
  }

  await saveBillingDetails(vatId, true)
  return {
    status: 'valid',
    vatId,
    message: null,
    companyName: response.result.companyName,
  }
}

saveBillingDetails is a stub for whatever your app does with the result – attach it to an order, a customer record, whatever persistence you already have. It runs on every outcome except a malformed ID, including unverified: billing details still get saved, just flagged for a later re-check.

Expected outcomes – invalid, unverified – are returned from the action, not thrown. Nothing here throws except the missing-API-key case in checkVat, which is a deploy misconfiguration, not a user-facing state.

The React form docs say that after a form action succeeds, React resets every uncontrolled field in the form – and an action that returns an error as state has still completed. Return the submitted, normalized vatId in state and bind the input to it with defaultValue, or a rejected number vanishes from the field along with the error explaining why.

// app/checkout/VatIdField.tsx
'use client'

import { useActionState, useId } from 'react'
import { checkVatAction } from './actions'
import { initialCheckoutState } from './types'

export function VatIdField() {
  const [state, formAction, pending] = useActionState(checkVatAction, initialCheckoutState)
  const statusId = useId()
  const isInvalid = state.status === 'invalid'

  return (
    <form action={formAction}>
      <label htmlFor="vat-id">VAT number (optional)</label>
      {state.message && (
        <p id={statusId} role={isInvalid ? 'alert' : 'status'}>
          {state.message}
        </p>
      )}
      <input
        id="vat-id"
        name="vatId"
        type="text"
        inputMode="text"
        autoComplete="off"
        defaultValue={state.vatId}
        aria-invalid={isInvalid}
        aria-describedby={state.message ? statusId : undefined}
      />
      <button type="submit" disabled={pending}>
        {pending ? 'Checking…' : 'Validate VAT number'}
      </button>
    </form>
  )
}

The label sits above the field; the status message sits between the label and the input, not below it, where a phone keyboard hides it; aria-invalid/aria-describedby tie the message to the field for assistive tech. The submit button disables only while pending, with a visible ‘Checking…’ label – not based on whether the current input looks valid. <form noValidate> isn’t needed here, since the input carries no HTML validation attributes of its own.

If you redirect to a confirmation step on success, call redirect() outside any try/catch – the Next.js redirect reference notes that redirect() works by throwing, so catching it anywhere in the call chain swallows the navigation. The example skips the redirect.

Calling vatnode with a bounded timeout

checkVat sets signal: AbortSignal.timeout(35_000). vatnode’s own VIES budget is a 12-second attempt plus, for some failures, a 6-second retry, and for some countries a VIES outage also triggers a national-registry fallback before vatnode answers, so a slow check can take close to half a minute. A shorter timeout on your side aborts checks vatnode would have completed. Keep yours above that budget, not under it, and let vatnode return 504 UPSTREAM_TIMEOUT on its own schedule if VIES doesn’t answer.

A thrown fetch – a network failure, the abort firing, or a body that isn’t JSON – maps to unverified above. There’s no retry loop inside the action: vatnode caches a transient 503/504 for the same VAT ID for 60 seconds (when the account has no requester VAT configured), so an immediate retry would just replay the cached failure, not get a fresh answer.

Mapping every outcome by error code, not HTTP status

Switch on error.code from the envelope { error: { code, message, requestId } }, not on HTTP status alone – AUDIT_WRITE_FAILED and VIES_ERROR both arrive at 502.

ResponseMeaningAction state
200, valid: trueThe number checks out – source says which system answered; companyName may be nullvalid
200, valid: falseVIES reports the number as not currently validinvalid
400 INVALID_FORMATMalformed ID (also returned if a vat_test_ key gets a non-XX id)invalid
401 UNAUTHORIZED / INVALID_API_KEYYour key is missing or wrong – your config bug, not the customer’s inputunverified
403 VIES_ERRORVIES reports the VAT ID or vatnode’s own IP as blocked (VAT_BLOCKED / IP_BLOCKED)unverified
422 INVALID_REQUESTERYour account’s dashboard requester VAT is invalid in VIES – your setting, not theirsunverified
429 RATE_LIMITEDMonthly quota exceededunverified
502 VIES_ERRORUnexpected VIES protocol errorunverified
502 AUDIT_WRITE_FAILEDCheck ran, record couldn’t be stored – verdict withheld, no quota spent, safe to retryunverified
503 VIES_UNAVAILABLEVIES is unavailable (without a requester VAT, a national fallback was tried first where one exists for that country)unverified
504 UPSTREAM_TIMEOUTVIES didn’t answer within vatnode’s own budgetunverified
500 INTERNAL_ERRORvatnode-side failure unrelated to VIES or the inputunverified

Every row except the first three says ‘the check didn’t complete’, not ‘the number is wrong’, so the action maps all of them to unverified. The ones caused by your own setup – the key, the requester VAT, the quota – also get logged, because no customer can fix them. The full code set and how to handle each one outside the UI – retry, cache, log – is in handling VIES errors in code; the contract they come from is in the error reference.

Never block the order on an unverified check

If the check can’t complete, don’t block the order. Treat the customer as unverified: charge VAT as you would for a customer without a valid VAT ID, re-run the check before the invoice is finalized, and – only for a customer in another member state – exempt the intra-Community supply of goods or apply the reverse charge for services once that later check confirms the number. A same-country customer is charged domestic VAT regardless of VAT ID status.

If confirmation arrives after an invoice has gone out, correct it with a credit note and a new, correctly treated invoice – not by editing the original.

A valid: true answer with source: 'VIES' means VIES confirmed the number is registered for intra-EU transactions at the moment of the check. Without a requester VAT, when VIES is down for that country, vatnode can fall back to the national VAT register instead – source then names that register, and there’s no consultation number. When your account has a requester VAT configured, every check skips the cache, so source is always 'VIES' or a named registry; without one, a repeat check within 15 minutes can return 'CACHE' instead, which tells you the number was valid recently, not which authority confirmed it. Store source with the verdict so you know which system confirmed the number, when it isn’t 'CACHE'. In any case, it isn’t a certificate, and it doesn’t tell you which treatment – the exemption for goods or the reverse charge for services – the supply qualifies for.

valid: false means VIES reports the number as not currently valid for intra-EU transactions – the customer may have a domestic-only registration, a typo, or a deregistered number. Charge VAT and let them correct it.

Store the consultation number vatnode returns when your account has a requester VAT set in the dashboard’s Account details – it’s your evidence that you checked. The response also carries checkId and verifiedAt, which are worth storing alongside it.

The Server Action above is the server-side check that happens at submit. ‘Re-run before the invoice’ is a separate, later server-side job – your invoice run, typically – since the action itself doesn’t retry a stale unverified result on its own. The same pattern, applied to a full checkout and to a signup form, is covered in validate VAT numbers at checkout and validate VAT numbers at signup.

Testing with a test key

A vat_test_ key accepts only XX-prefixed IDs, makes no VIES call, and spends no quota:

VAT IDResult
XX0000001valid: true, with a company name
XX0000002valid: true, no company name or address
XX0000003valid: false
XX0000004503 VIES_UNAVAILABLE
XX0000005502 VIES_ERROR
Any other XX…200, valid: false
Non-XX ID with a test key400 INVALID_FORMAT

That’s enough to exercise every branch in the action above without touching a live key. More on the full fixture set in test keys and the test-mode docs.

Next steps

FAQ

Why not call vatnode on every keystroke from a Server Action? Next.js dispatches Server Actions from the same client one at a time – a second action waits for the first to finish. A debounced per-keystroke action queues behind whatever check is already in flight, and a vatnode call can take several seconds when VIES is slow. For live-typing validation, use a route handler instead; this post covers validating once, on submit.

Is a Server Action safe to call from an unauthenticated form? A Server Action is a public POST endpoint – anyone who can send the same request body can invoke it, framework CSRF origin checks aside. If the action calls vatnode, it’s a proxy to your API quota, so validate the input shape before the upstream call, put the form behind your app’s own auth/session if it has one, and return a shaped result rather than the raw upstream body.

What does useActionState reset on a failed submission? React resets every uncontrolled field in the form after the action succeeds, and an action that returns a validation error as state has still succeeded. Return the submitted VAT ID in the action’s state and render the input with defaultValue bound to it, or a rejected VAT ID disappears from the field along with the error message.

Should an unverified VAT check block the order? No. Treat the customer as unverified – charge VAT as you would for a customer without a valid VAT ID, let the order proceed, and re-run the check before the invoice is finalized. VIES has no uptime guarantee, and blocking checkout on it turns an upstream outage into a lost order.