B2B checkout forms have a UI requirement that B2C forms do not: after a customer enters their VAT number, you need to show them the registered company name and address so they can confirm the number is correct before completing the purchase. This guide builds that exact pattern in React — a reusable `useVatValidation` hook, a debounced input component, and a company confirmation UI — backed by a server-side proxy so your TaxID API key never reaches the browser.
Warning
Never call the TaxID API directly from browser JavaScript. Your API key would be visible in network requests. Always proxy through a backend endpoint — the server-side proxy section below shows how.
Server-Side Proxy: Next.js API Route
Create a thin API route on your server that accepts the VAT number and calls TaxID. This keeps your API key server-side and gives you a place to add rate limiting or logging.
// Next.js 14 App Router
export async function POST(request: Request) {
const { vatNumber } = await request.json();
if (!vatNumber || typeof vatNumber !== 'string') {
return Response.json({ error: 'vat_number_required' }, { status: 400 });
}
const country = vatNumber.slice(0, 2).toUpperCase();
const vat = vatNumber.replace(/\s/g, '').toUpperCase();
const res = await fetch(
`https://taxid.dev/api/v1/validate/${country}/${vat}`,
{ headers: { Authorization: `Bearer ${process.env.TAXID_API_KEY}` } }
);
const data = await res.json();
// Return only what the frontend needs — not the full raw response
return Response.json({
valid: data.valid,
status: data.status,
companyName: data.company_name,
address: data.address,
});
}useVatValidation Hook
The hook manages the three states a VAT field can be in: idle (user hasn't typed yet or cleared), loading (API call in flight), and resolved (a result is ready). It debounces the input by 600ms to avoid an API call on every keystroke.
import { useState, useEffect, useRef } from 'react';
type VatState =
| { phase: 'idle' }
| { phase: 'loading' }
| { phase: 'valid'; companyName: string | null; address: string | null }
| { phase: 'invalid'; reason: string }
| { phase: 'unavailable' };
export function useVatValidation(vatNumber: string, debounceMs = 600) {
const [state, setState] = useState<VatState>({ phase: 'idle' });
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
// Clear previous timer
if (debounceRef.current) clearTimeout(debounceRef.current);
// Reset to idle when input is cleared
if (!vatNumber || vatNumber.length < 8) {
setState({ phase: 'idle' });
return;
}
setState({ phase: 'loading' });
debounceRef.current = setTimeout(async () => {
try {
const res = await fetch('/api/validate-vat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vatNumber }),
});
const data = await res.json();
switch (data.status) {
case 'active':
setState({ phase: 'valid', companyName: data.companyName, address: data.address });
break;
case 'inactive':
setState({ phase: 'invalid', reason: 'This VAT number is not currently registered.' });
break;
case 'format_invalid':
setState({ phase: 'invalid', reason: 'Invalid format. Expected: country code + digits (e.g. DE123456789).' });
break;
case 'service_unavailable':
setState({ phase: 'unavailable' });
break;
default:
setState({ phase: 'unavailable' });
}
} catch {
setState({ phase: 'unavailable' });
}
}, debounceMs);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [vatNumber, debounceMs]);
return state;
}VatInput Component with Inline Feedback
The input component consumes the hook and renders appropriate inline feedback for each phase. The company name and address are shown on successful validation so the customer can verify the entry before submitting.
import { useState } from 'react';
import { useVatValidation } from '../hooks/useVatValidation';
interface VatInputProps {
onValidVat?: (vatNumber: string, companyName: string | null) => void;
}
export function VatInput({ onValidVat }: VatInputProps) {
const [value, setValue] = useState('');
const vatState = useVatValidation(value);
// Notify parent when VAT becomes valid
if (vatState.phase === 'valid' && onValidVat) {
onValidVat(value, vatState.companyName);
}
return (
<div className="vat-field">
<label htmlFor="vat-number">EU VAT Number (optional for B2B)</label>
<div className="input-wrapper">
<input
id="vat-number"
type="text"
value={value}
onChange={e => setValue(e.target.value.toUpperCase())}
placeholder="e.g. DE123456789"
aria-describedby="vat-feedback"
aria-invalid={vatState.phase === 'invalid'}
/>
{vatState.phase === 'loading' && (
<span className="spinner" aria-label="Validating..." />
)}
</div>
<div id="vat-feedback" role="status" aria-live="polite">
{vatState.phase === 'valid' && (
<div className="vat-confirmed">
<span className="check-icon">✓</span>
<div>
<strong>{vatState.companyName ?? 'Company confirmed'}</strong>
{vatState.address && <p>{vatState.address}</p>}
</div>
</div>
)}
{vatState.phase === 'invalid' && (
<p className="error">{vatState.reason}</p>
)}
{vatState.phase === 'unavailable' && (
<p className="warning">
EU VAT registry is temporarily unavailable. Your order will proceed with VAT included.
</p>
)}
</div>
</div>
);
}Integrating Into a Checkout Form
import { useState } from 'react';
import { VatInput } from './VatInput';
export function CheckoutForm() {
const [vatNumber, setVatNumber] = useState<string | null>(null);
const [companyName, setCompanyName] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// vatNumber is set only when validation returned 'active'
// When null, treat as B2C — apply standard VAT
await createOrder({ vatNumber, companyName });
};
return (
<form onSubmit={handleSubmit}>
{/* ... billing address fields ... */}
<VatInput
onValidVat={(vat, name) => {
setVatNumber(vat);
setCompanyName(name);
}}
/>
<p className="vat-hint">
Enter your VAT number to apply reverse charge for EU B2B orders.
</p>
<button type="submit">Complete order</button>
</form>
);
}Tip
Reset `vatNumber` state to `null` if the user clears the VAT input after it was valid. Otherwise a partial edit leaves stale validated data attached to the order.
Related guides
Start validating EU VAT numbers
Free plan — 100 validations/month. No credit card required.