Real-Time VAT Validation in a React Checkout Form

9 Oct 2026

Real-Time VAT Validation in a React Checkout Form

This is the implementation for a React checkout field that validates a VAT ID as the customer types: a debounced hook, a backend proxy route that holds the API key, and a response mapping that distinguishes ‘invalid’ from ‘we couldn’t check right now.’ It assumes you’ve already decided your checkout needs this – if you haven’t, see when and why to validate VAT numbers at checkout first.

Why this isn’t a client-side API call

Every vatnode request needs an Authorization: Bearer vat_live_... header. If a React component calls https://api.vatnode.dev/v1/vat/:vatId directly, that key ships inside the JavaScript bundle the browser downloads – visible in the Network tab, visible in the bundle source, extractable by anyone who opens dev tools. There’s no client-side way to call an authenticated API without exposing the credential that authenticates it.

It also wouldn’t get past the browser’s CORS preflight: vatnode’s API restricts cross-origin requests to its own web app’s origin, not to whatever origin your checkout runs on, so a direct browser call would fail before the key problem even mattered.

The fix is a backend route in your own app that holds the key server-side. The React component talks to that route instead.

The architecture

React component → your backend route → vatnode API
                                        GET https://api.vatnode.dev/v1/vat/:vatId
                                        Authorization: Bearer vat_live_...

Three pieces:

  • The React hook – debounces keystrokes, calls your own /api/vat-check route, tracks a small state machine.
  • Your backend route – reads the vatnode key from a server-only environment variable, forwards the request, normalizes the response.
  • vatnode – does the actual VIES lookup and returns a verdict.

The browser never sees vat_live_.... It only ever talks to your own origin.

Local format pre-check

Before any network call, reject obviously malformed input. The eu-vat-rates-data npm package exports validateFormat(vatId: string): boolean, which runs the VAT ID against the per-country regex pattern for its prefix:

import { validateFormat } from 'eu-vat-rates-data'

validateFormat('IE6388047V') // true
validateFormat('not-a-vat-id') // false

Two prefixes to get right: Greek VAT numbers use the VIES prefix EL, not the ISO code GR – validateFormat and the vatnode API both expect EL. Northern Ireland uses XI, a separate prefix from the rest of the UK (which isn’t in scope for EU VAT validation at all). XI numbers cover goods only – for services, a Northern Ireland business is a UK customer and outside VIES.

A local pass only means ‘this is shaped like a VAT ID.’ It says nothing about whether the number is registered – that’s what the network call is for. Treat a local failure as a hard client-side error (don’t call the API with it), and treat a local pass as ‘go ahead and check.’

The backend route

A minimal Next.js App Router route handler. It reads the key from a server-only env var, validates the path param, and normalizes the response shape before sending it to the browser:

// app/api/vat-check/[vatId]/route.ts
import { NextResponse } from 'next/server'

const VATNODE_API_KEY = process.env.VATNODE_API_KEY // server-only, no NEXT_PUBLIC_ prefix

export async function GET(_req: Request, { params }: { params: Promise<{ vatId: string }> }) {
  const { vatId } = await params

  const upstream = await fetch(`https://api.vatnode.dev/v1/vat/${encodeURIComponent(vatId)}`, {
    headers: { Authorization: `Bearer ${VATNODE_API_KEY}` },
  })

  const body = await upstream.json()
  return NextResponse.json(body, { status: upstream.status })
}

An Express route is the same shape – read the key from process.env, forward the request to the same URL and header, pass the upstream status code straight through to the response.

One rule that matters more than the code: the VAT verdict used to decide final tax treatment is re-derived server-side at order submission, not read back from whatever the client last showed on screen. The client-side check is for UX feedback – telling the customer their number looks wrong before they submit. It is not the record you invoice against. See handling VIES errors in code for the full error-code contract your backend route is forwarding.

The React hook

Debounce around 400–500ms, cancel the in-flight request on every new keystroke with AbortController, and track a small explicit state machine rather than a loose pile of booleans:

// useVatValidation.ts
import { useEffect, useRef, useState } from 'react'

type VatCheckState = 'idle' | 'checking' | 'valid' | 'invalid' | 'unavailable'

interface VatCheckResult {
  state: VatCheckState
  message: string | null
}

const UNAVAILABLE_STATUS = new Set([403, 429, 502, 503, 504])

export function useVatValidation(vatId: string, debounceMs = 450): VatCheckResult {
  const [result, setResult] = useState<VatCheckResult>({ state: 'idle', message: null })
  const abortRef = useRef<AbortController | null>(null)

  useEffect(() => {
    abortRef.current?.abort()

    const trimmed = vatId.trim()
    if (trimmed.length === 0) {
      setResult({ state: 'idle', message: null })
      return
    }

    const timer = setTimeout(async () => {
      const controller = new AbortController()
      abortRef.current = controller
      setResult({ state: 'checking', message: null })

      try {
        const res = await fetch(`/api/vat-check/${encodeURIComponent(trimmed)}`, {
          signal: controller.signal,
        })
        const body = await res.json()

        if (res.status === 200 && body.valid === true) {
          setResult({ state: 'valid', message: body.companyName ?? null })
        } else if (res.status === 200 && body.valid === false) {
          setResult({ state: 'invalid', message: 'This VAT number is not currently registered.' })
        } else if (res.status === 400) {
          setResult({
            state: 'invalid',
            message: body.error?.message ?? 'Invalid VAT number format.',
          })
        } else if (UNAVAILABLE_STATUS.has(res.status)) {
          setResult({
            state: 'unavailable',
            message: 'Could not verify right now — you can continue.',
          })
        } else {
          setResult({
            state: 'unavailable',
            message: 'Could not verify right now — you can continue.',
          })
        }
      } catch (err) {
        if ((err as Error).name === 'AbortError') return
        setResult({
          state: 'unavailable',
          message: 'Could not verify right now — you can continue.',
        })
      }
    }, debounceMs)

    return () => clearTimeout(timer)
  }, [vatId, debounceMs])

  return result
}

AbortController matters here specifically because of how fast people type: without it, a check started on "DE12345" can resolve after a check started on "DE123456789", and the stale response overwrites the current one.

Mapping API responses to UI states

vatnode responseUI state
200 with valid: truevalid
200 with valid: falseinvalid
400 INVALID_FORMATinvalid
422 INVALID_REQUESTERunavailable (usually your account’s own requester setting, not the customer’s input – don’t surface this to the customer as their mistake)
403 VIES_ERROR, 429 RATE_LIMITED, 502 VIES_ERROR, 503 VIES_UNAVAILABLE, 504 UPSTREAM_TIMEOUTunavailable
Network error, timeout, aborted fetchunavailable

The codes themselves – INVALID_FORMAT, INVALID_REQUESTER, RATE_LIMITED, VIES_UNAVAILABLE, VIES_ERROR, UPSTREAM_TIMEOUT, AUDIT_WRITE_FAILED, INTERNAL_ERROR – are the full set of outcomes for a VAT check. A 401 (UNAUTHORIZED or INVALID_API_KEY) means your route’s key is missing or wrong – a configuration bug on your side, not a customer problem. The full breakdown of what each one means and how to handle it outside the UI layer (retry, cache, log) is in handling VIES errors in code.

Never block checkout on ‘unavailable’

unavailable is not invalid. VIES has no uptime guarantee, and a member-state node being slow or down is a routine event, not a signal about the customer’s VAT number. If your checkout button disables itself whenever this field can’t be verified, you’ve turned a VIES hiccup into a lost order.

Let the order through when the state is unavailable, but treat it as unverified: charge VAT as you would for a customer without a valid VAT ID, re-run the check server-side before the invoice is finalized – from a fresh server call, not from the client state – and only zero-rate the intra-EU supply (goods) or apply the reverse charge (services) once that call confirms the number. 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. This is the same pattern covered in more depth in validating VAT numbers at checkout: checkout is not the last chance to get the verdict right – the invoice is where it has to be right.

Full example

The component wiring the hook to a visible input, with accessible labeling and error placement – label above the field, status message between the label and the input (not below, where a phone keyboard hides it), aria-invalid and aria-describedby wired to that message, and the input never disabled while a check is in flight:

// VatIdField.tsx
import { useId, useState } from 'react'
import { useVatValidation } from './useVatValidation'

export function VatIdField() {
  const [vatId, setVatId] = useState('')
  const { state, message } = useVatValidation(vatId)
  const statusId = useId()

  const isInvalid = state === 'invalid'

  return (
    <div>
      <label htmlFor="vat-id">VAT number (optional)</label>
      {message && (
        <p id={statusId} role={isInvalid ? 'alert' : 'status'}>
          {state === 'checking' && 'Checking…'}
          {state === 'valid' && (message || 'Valid VAT number.')}
          {state === 'invalid' && message}
          {state === 'unavailable' && message}
        </p>
      )}
      <input
        id="vat-id"
        name="vatId"
        type="text"
        inputMode="text"
        autoComplete="off"
        value={vatId}
        onChange={(e) => setVatId(e.target.value)}
        aria-invalid={isInvalid}
        aria-describedby={message ? statusId : undefined}
      />
    </div>
  )
}

Nothing in this component disables the checkout submit button – that decision belongs at the order-submission layer, not the field, and it should never key off state === 'checking' or state === 'unavailable'.

To try this end to end without touching production quota, use a test API key in the backend route: a vat_test_ key accepts only VAT IDs starting with XX (for example XX0000001) and returns a deterministic fixture – no VIES call, no quota spent. Register for a free API key to get a test key at sign-up and create a live key in the dashboard (free plan, 100 requests/month), or see the full request/response contract in the API reference.

Next steps

FAQ

Should the React component call the vatnode API directly? No. Calling api.vatnode.dev from the browser means shipping your API key in client-side JavaScript, where anyone can read it from the network tab or the bundle. Route the call through your own backend – a Next.js route handler, an Express endpoint, whatever you already have – and keep the key server-side only.

What HTTP response means the check is unavailable, not invalid? A 403 VIES_ERROR, 429 RATE_LIMITED, 502 VIES_ERROR, 503 VIES_UNAVAILABLE, or 504 UPSTREAM_TIMEOUT, plus a client-side network failure or an aborted/timed-out fetch. None of those say the VAT ID is wrong – they say the check didn’t complete. Only a 200 response with valid: false, or a 400 INVALID_FORMAT, means invalid.

How often should the frontend re-check as the user types? Debounce around 400–500ms after the last keystroke, and cancel the in-flight request with AbortController when a new one starts. Checking on every keystroke burns quota and VIES capacity on half-typed numbers; checking only on blur feels sluggish on a field people often paste into.

Can I use a test API key for this tutorial? Yes. A vat_test_ key accepts only VAT IDs starting with XX and returns a fixture with no VIES call and no quota spent, which is enough to build and demo the whole flow in this post. Switch to a vat_live_ key before you ship.