Iurii RoguliaIurii Rogulia
AboutServicesPricingProjectsStackReviewsPhrasesBlog
Contact
Iuriiย ships.

Iurii Rogulia, IT partner for business & fractional CTO. Professionally building software since 2001.

Think of a number
PricingQuality checklistPrivacy PolicyCookie Policy

Business

TMI Iurii Rogulia
VAT ID: FI29845875
DUNS: 368664211
Lappeenranta, Finland ๐Ÿ‡ซ๐Ÿ‡ฎ

[email protected]
  1. Home
  2. /
  3. Blog
  4. /
  5. EU VAT Number Validation in Next.js (VIES API Tutorial)

Iuriiย Validates: EU VAT Number Validation in Next.js (VIES API Tutorial)

VIES goes down without warning. Here's the caching, fallback logic, and audit trail that keep EU checkouts running.

February 5, 2026ยท 12 min read

EU VAT validation in Next.js: VIES caching with Redis, three-state results, reverse charge logic, and audit logging โ€” what actually works in production.

Stack

Next.jsTypeScript

Databases

Redis

Topics

Tax/VATFintechE-commerceSaaS
EU VAT Number Validation in Next.js (VIES API Tutorial)

On this page

  • How VIES Works (And Why It Is Unreliable)
  • A Production-Grade VIES Wrapper
  • Redis Caching: The Part Everyone Skips
  • Validation Endpoint With Audit Logging
  • B2B vs B2C: The Reverse Charge Mechanism
  • EU VAT Rates: Do Not Hardcode Them
  • Common Mistakes That Will Hurt You
  • Results From Production

You launch your product in Europe and suddenly discover that VAT is not just a field in a form. There is a SOAP API from the early 2000s that your checkout depends on. It goes down without warning. It returns "INVALID" for valid numbers during EU member state outages. Greece uses the prefix EL, not GR. And if you are building B2B software, the entire tax logic flips depending on whether the buyer has a valid VAT number.

I've dealt with this across several production systems โ€” vatnode.dev, a SaaS specifically for EU VAT validation, and pi-pi.ee, a B2B e-commerce platform covering 32 EU markets. This guide covers what I actually learned building them, not what the official documentation says.

How VIES Works (And Why It Is Unreliable)

VIES โ€” the EU VAT Information Exchange System โ€” is the official European Commission service for validating VAT numbers. It connects to tax authority databases in each EU member state and tells you whether a given VAT number is currently registered for intra-EU trade.

The API is SOAP over HTTP. In 2026. There is a REST wrapper available (https://ec.europa.eu/taxation_customs/vies/rest-api), but it is still backed by the same infrastructure.

Here is the core problem: VIES is a federation of 27 national systems. When the Finnish tax authority (Vero) has a maintenance window, VIES returns an error for all Finnish VAT numbers. When the German Bundeszentralamt fรผr Steuern has a timeout, your checkout silently fails for German B2B customers. The European Commission publishes availability statistics โ€” some member states have uptime well below 99%. This is the kind of integration complexity that makes EU e-commerce development significantly harder than a standard checkout build.

The naive implementation looks like this:

// naive-vies.ts โ€” do not use in production
const response = await fetch(
  `https://ec.europa.eu/taxation_customs/vies/rest-api/ms/${countryCode}/vat/${vatNumber}`
);
const data = await response.json();
return data.valid; // will be false during downtime, not an error

The critical bug: when VIES is down for a member state, it does not return an HTTP error. It returns { "valid": false }. Your code sees a valid B2B customer as invalid, blocks their checkout, and you never know it happened.

How-to

How to validate EU VAT numbers in Next.js

Time: 4hTools: Next.js 16, TypeScript, ioredis, Drizzle ORM, VIES REST API, eu-vat-rates-data
  1. 1

    Build the VIES wrapper

    Wrap the European Commission's REST endpoint in a typed function with an 8-second timeout and a three-state result โ€” valid, invalid, or unavailable. Distinguish 'could not check' from 'confirmed invalid' using the userError field.

  2. 2

    Add Redis caching with fallback

    Cache results with different TTLs per state: 7 days for valid, 4 hours for invalid, 5 minutes for unavailable. Include an in-memory fallback map for when Redis itself is down.

  3. 3

    Build the validation endpoint with audit log

    Create a POST route that checks the cache first, calls VIES on a miss, stores the result, and writes every check to a database audit log โ€” required for tax compliance across EU member states.

  4. 4

    Implement reverse charge logic

    Write a determineVatTreatment function that returns one of four decisions: reverse_charge, domestic_vat, oss_vat, or pending_verification. Never guess when VIES is unavailable โ€” queue ambiguous transactions for manual review.

  5. 5

    Use a maintained VAT rates package

    Install eu-vat-rates-data instead of hardcoding rates. It handles country-specific rules (Azores, Madeira, Greek EL prefix) and updates automatically via GitHub Actions when EU rates change.

A Production-Grade VIES Wrapper

Related service

E-commerce

Need EU VAT validation wired into your checkout โ€” with VIES caching, reverse charge logic, and a full audit trail? This is what I build.

More about this service โ†’

Here is the wrapper I built for vatnode, with proper error handling and distinction between "definitely invalid" and "could not verify":

// lib/vies.ts
import { z } from "zod";
 
const VIES_REST_BASE = "https://ec.europa.eu/taxation_customs/vies/rest-api";
 
// Country codes that VIES uses โ€” note EL for Greece, not GR
const VIES_COUNTRY_CODES = new Set([
  "AT",
  "BE",
  "BG",
  "CY",
  "CZ",
  "DE",
  "DK",
  "EE",
  "EL",
  "ES",
  "FI",
  "FR",
  "HR",
  "HU",
  "IE",
  "IT",
  "LT",
  "LU",
  "LV",
  "MT",
  "NL",
  "PL",
  "PT",
  "RO",
  "SE",
  "SI",
  "SK",
]);
 
const ViesResponseSchema = z.object({
  isValid: z.boolean(),
  requestDate: z.string(),
  userError: z.string().optional(),
  name: z.string().optional(),
  address: z.string().optional(),
  requestIdentifier: z.string().optional(),
});
 
export type ViesResult =
  | { status: "valid"; name?: string; address?: string }
  | { status: "invalid" }
  | { status: "unavailable"; reason: string };
 
export async function checkVies(countryCode: string, vatNumber: string): Promise<ViesResult> {
  // Normalize: Greece is EL in VIES, not GR
  const normalized = countryCode.toUpperCase() === "GR" ? "EL" : countryCode.toUpperCase();
 
  if (!VIES_COUNTRY_CODES.has(normalized)) {
    return { status: "invalid" };
  }
 
  // Strip the country prefix if the caller included it in vatNumber
  const cleanNumber = vatNumber.replace(/^[A-Z]{2}/i, "").trim();
 
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), 8000); // 8s timeout
 
  try {
    const response = await fetch(`${VIES_REST_BASE}/ms/${normalized}/vat/${cleanNumber}`, {
      signal: controller.signal,
    });
 
    clearTimeout(timeoutId);
 
    if (!response.ok) {
      return { status: "unavailable", reason: `VIES HTTP ${response.status}` };
    }
 
    const raw = await response.json();
    const parsed = ViesResponseSchema.safeParse(raw);
 
    if (!parsed.success) {
      return { status: "unavailable", reason: "Unexpected VIES response shape" };
    }
 
    const data = parsed.data;
 
    // userError appears when the member state database is down
    // MS_UNAVAILABLE, SERVICE_UNAVAILABLE, etc.
    if (data.userError && data.userError !== "VALID") {
      return { status: "unavailable", reason: data.userError };
    }
 
    if (!data.isValid) {
      return { status: "invalid" };
    }
 
    return {
      status: "valid",
      name: data.name ?? undefined,
      address: data.address ?? undefined,
    };
  } catch (error) {
    clearTimeout(timeoutId);
    if (error instanceof Error && error.name === "AbortError") {
      return { status: "unavailable", reason: "VIES timeout" };
    }
    return { status: "unavailable", reason: "Network error" };
  }
}

The key distinction is the three-state result: valid, invalid, and unavailable. Your business logic needs all three. "Unavailable" means you should not block the customer โ€” you should either cache the last known result or apply a graceful fallback policy.

Redis Caching: The Part Everyone Skips

Calling VIES on every checkout request is a mistake for two reasons. First, it is slow (200โ€“800ms). Second, VIES has an undocumented rate limit โ€” bulk validation will get your IP blocked. In vatnode, I achieved a 95% cache hit rate using Redis with different TTLs for each result type.

// lib/vat-cache.ts
import { Redis } from "ioredis";
 
const redis = new Redis(process.env.REDIS_URL!, {
  maxRetriesPerRequest: 2,
  lazyConnect: true,
});
 
// In-memory fallback for when Redis is unavailable
const memoryCache = new Map<string, { result: ViesResult; expiresAt: number }>();
 
const TTL = {
  valid: 60 * 60 * 24 * 7, // 7 days โ€” valid registrations are stable
  invalid: 60 * 60 * 4, // 4 hours โ€” they might fix their number and retry
  unavailable: 60 * 5, // 5 minutes โ€” retry VIES soon
} as const;
 
function cacheKey(countryCode: string, vatNumber: string): string {
  return `vat:${countryCode.toUpperCase()}:${vatNumber.replace(/\s/g, "")}`;
}
 
export async function getCachedVatResult(
  countryCode: string,
  vatNumber: string
): Promise<ViesResult | null> {
  const key = cacheKey(countryCode, vatNumber);
 
  try {
    const cached = await redis.get(key);
    if (cached) return JSON.parse(cached) as ViesResult;
  } catch {
    // Redis unavailable โ€” fall through to memory cache
    const mem = memoryCache.get(key);
    if (mem && mem.expiresAt > Date.now()) return mem.result;
  }
 
  return null;
}
 
export async function setCachedVatResult(
  countryCode: string,
  vatNumber: string,
  result: ViesResult
): Promise<void> {
  const key = cacheKey(countryCode, vatNumber);
  const ttl = TTL[result.status];
 
  try {
    await redis.set(key, JSON.stringify(result), "EX", ttl);
  } catch {
    // Redis unavailable โ€” write to memory cache as fallback
    memoryCache.set(key, { result, expiresAt: Date.now() + ttl * 1000 });
  }
}

Do not cache unavailable results for too long. Five minutes is enough. A 4-hour TTL on unavailability means your Finnish customers are blocked for 4 hours after a 10-minute Vero maintenance window.

Validation Endpoint With Audit Logging

// app/api/vat/validate/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { checkVies } from "@/lib/vies";
import { getCachedVatResult, setCachedVatResult } from "@/lib/vat-cache";
import { db } from "@/lib/db";
import { vatValidationLog } from "@/lib/db/schema";
 
const RequestSchema = z.object({
  countryCode: z.string().length(2),
  vatNumber: z.string().min(4).max(20),
});
 
export async function POST(request: NextRequest) {
  const body = await request.json();
  const parsed = RequestSchema.safeParse(body);
 
  if (!parsed.success) {
    return NextResponse.json(
      { error: "Invalid request", details: parsed.error.flatten() },
      { status: 400 }
    );
  }
 
  const { countryCode, vatNumber } = parsed.data;
 
  const cached = await getCachedVatResult(countryCode, vatNumber);
  if (cached) {
    return NextResponse.json({ ...cached, cached: true });
  }
 
  const result = await checkVies(countryCode, vatNumber);
  await setCachedVatResult(countryCode, vatNumber, result);
 
  // Audit log โ€” essential for tax compliance
  // You must be able to prove what VIES returned at the time of a transaction
  await db.insert(vatValidationLog).values({
    countryCode,
    vatNumber,
    result: result.status,
    checkedAt: new Date(),
  });
 
  return NextResponse.json({ ...result, cached: false });
}

The audit log is not optional. Tax authorities can audit your transactions years later. You need to show what VIES returned when you made a sale. Without logging, you cannot reconstruct that audit trail.

B2B vs B2C: The Reverse Charge Mechanism

If you are also automating the post-payment flow, see how this fits into a broader pipeline in E-commerce Order Automation.

When you sell to another business in a different EU country, the VAT responsibility shifts from seller to buyer. This is called the reverse charge mechanism โ€” you charge 0% VAT, include "Reverse charge" on the invoice, and the buyer accounts for VAT in their own country.

The rules:

  • B2C (no VAT number): Charge VAT at the buyer's country rate. OSS registration applies for digital services.
  • B2B (valid VAT number, different EU country): Reverse charge. Zero VAT.
  • B2B (same country): Normal domestic VAT applies.
  • B2B (VIES unavailable): Do not guess โ€” queue for review.
// lib/vat-logic.ts
import type { ViesResult } from "./vies";
 
export type VatDecision =
  | { type: "reverse_charge"; buyerVatNumber: string }
  | { type: "domestic_vat"; rate: number }
  | { type: "oss_vat"; rate: number; buyerCountry: string }
  | { type: "pending_verification" }; // VIES unavailable โ€” queue for review
 
interface TaxContext {
  sellerCountryCode: string;
  buyerCountryCode: string;
  buyerVatNumber?: string;
  viesResult?: ViesResult;
}
 
export function determineVatTreatment(ctx: TaxContext): VatDecision {
  const { sellerCountryCode, buyerCountryCode, buyerVatNumber, viesResult } = ctx;
 
  const isSameCountry = sellerCountryCode.toUpperCase() === buyerCountryCode.toUpperCase();
 
  // B2B cross-border with confirmed valid VAT number = reverse charge
  if (!isSameCountry && buyerVatNumber && viesResult?.status === "valid") {
    return { type: "reverse_charge", buyerVatNumber };
  }
 
  // VIES was unavailable โ€” cannot confirm reverse charge eligibility
  if (!isSameCountry && buyerVatNumber && viesResult?.status === "unavailable") {
    return { type: "pending_verification" };
  }
 
  // Domestic sale โ€” always applies local VAT
  if (isSameCountry) {
    return { type: "domestic_vat", rate: getVatRate(sellerCountryCode) };
  }
 
  // Cross-border B2C โ€” destination country rate (OSS)
  return {
    type: "oss_vat",
    rate: getVatRate(buyerCountryCode),
    buyerCountry: buyerCountryCode,
  };
}

In pi-pi.ee, when VIES was unavailable, I queued the transaction for manual review rather than incorrectly applying or skipping VAT. You do not want to commit a tax error because a remote SOAP service had a 5-minute outage.

EU VAT Rates: Do Not Hardcode Them

VAT rates change. Hungary changed its standard rate. Ireland introduced new reduced rates. Portugal has different rates for the Azores and Madeira. If you hardcode rates, you will eventually charge the wrong amount.

I maintain an open-source package for this: eu-vat-rates-data, available on npm, PyPI, Go modules, RubyGems, and Packagist. Updated automatically via GitHub Actions whenever rates change.

npm install eu-vat-rates-data
// lib/vat-rates.ts
import { getRate } from "eu-vat-rates-data";
 
type RateType = "standard" | "reduced" | "super_reduced" | "parking";
 
export function getVatRate(countryCode: string, type: RateType = "standard"): number {
  const code = countryCode.toUpperCase() === "GR" ? "EL" : countryCode.toUpperCase();
  const rate = getRate(code, type);
 
  if (rate === undefined) {
    throw new Error(`No VAT rates found for: ${countryCode}`);
  }
 
  return rate;
}
 
const germanVat = getVatRate("DE"); // 19
const finnishVat = getVatRate("FI"); // 25.5
const greekVat = getVatRate("GR"); // 24 โ€” EL normalization handled internally

Common Mistakes That Will Hurt You

1. Trusting VIES "invalid" unconditionally. Always check the userError field. Values like MS_UNAVAILABLE mean "I could not check" โ€” not "this number is wrong." The wrapper above handles this correctly.

2. No format pre-validation. Every EU country has its own VAT number format. Validate format locally before calling VIES โ€” it saves a round trip and gives faster user feedback. But format validation does not substitute for VIES; a correctly formatted number can still be deregistered.

3. No audit log. Tax authorities can audit transactions from years ago. Log every validation result with the VAT number, country code, VIES result, and timestamp. In EU jurisdictions, keep these for at least 10 years.

4. Ignoring the OSS threshold. There is a โ‚ฌ10,000 annual threshold before OSS registration becomes mandatory for cross-border B2C digital services. Build threshold tracking into your billing system from day one.

5. Skipping the Greece normalization. ISO country code: GR. EU VAT prefix: EL. These are different in every system. Normalize GR โ†’ EL at the earliest point in your stack โ€” before any validation logic runs.

Results From Production

In vatnode after implementing caching and three-state validation:

  • 95% cache hit rate โ€” most validation calls served from Redis, never touching VIES
  • VIES rate limit never approached
  • Zero checkout blocks due to VIES downtime โ€” unavailable triggers a graceful fallback
  • Full audit trail queryable by VAT number or transaction ID

In pi-pi.ee covering 32 EU markets, reverse charge detection runs on every B2B order. The pending verification queue handles a handful of orders per week during VIES outages โ€” the rest are processed automatically. VAT is only one part of selling across that many countries; the localized checkout itself โ€” locale-aware routing, currency formatting, and hreflang โ€” is covered in setting up Next.js i18n for 30 languages.


EU VAT compliance looks simple from the outside and becomes an ongoing engineering concern once you are in production. VIES reliability, caching strategy, audit trails, reverse charge logic, country-specific rate tables, format validation per member state โ€” it adds up.

I have solved all of this across multiple production systems, from a dedicated VAT validation SaaS (vatnode.dev) to multi-country B2B platforms covering 32 markets (pi-pi.ee). If you are building a product for the European market and want to get the tax logic right from the start โ€” get in touch. I am available for e-commerce development and API integration projects and longer-term engagements.


Further reading:

  • VIES official documentation and availability monitor
  • EU OSS scheme for digital services
  • eu-vat-rates-data on GitHub
  • vatnode.dev โ€” production EU VAT validation API
  • Vatnode project case study โ€” how the SaaS was built end-to-end
Iurii Rogulia

Working on something like this?

E-commerce

Building a product for the EU market? I've solved VIES reliability, reverse charge logic, and VAT audit trails across multiple production systems.

More about this service

Relevant client work

View all projects
vatnode โ€” EU VAT Validation API
vatnode โ€” EU VAT Validation API
January 19, 2026
vatnode โ€” EU VAT Validation API

Developer-first SaaS API for EU VAT validation via VIES with Redis caching, change monitoring, and webhook notifications.

Pikkuna โ€” i18n RAG AI System
Pikkuna โ€” i18n RAG AI System
December 15, 2025
Pikkuna โ€” i18n RAG AI System

RAG system on OpenAI and Upstash Vector with 30 language support. Includes streaming chatbot, hybrid search (semantic + keyword), AI ticket classifier, and

Pikkuna โ€” E-commerce for Vinyl Curtains & PVC Products
Pikkuna โ€” E-commerce for Vinyl Curtains & PVC Products
October 12, 2024
Pikkuna โ€” E-commerce for Vinyl Curtains & PVC Products

International e-commerce platform with 30 locales, product configurators, AI chatbot, and fully automated order flow: Stripe โ†’ Zoho CRM โ†’ Airtable โ†’ Mailgun โ†’

What clients say

โ€œ

I had a validated idea and a deadline tied to an accelerator demo day, but no product. Iurii helped me cut the scope down to what actually needed to exist for launch and pushed back hard on the features I thought I needed but didn't. We shipped a working MVP in six weeks with real users on it by demo day. The honest scoping at the start is what saved the timeline โ€” it would have been easy to let me build twice as much and miss the date.

Aino Virtanen ๐Ÿ‡ซ๐Ÿ‡ฎ

Founder

Stack

Next.jsTypeScript

Databases

PostgreSQL

Topics

MVPProductScopeSaaS
โ€œ

I'd built most of our MVP with Cursor and it looked finished โ€” it compiled, the tests were green, the demo worked. It just wouldn't survive real users. Iurii took it over and found what I couldn't see: authentication handled four different ways, tests that only asserted what the code already did, and a dependency list half of which was unused. He didn't rewrite it from scratch โ€” he told me honestly what was salvageable, ripped out the dead code, and got it to something a real team could build on. Two weeks and it went from 'looks done' to actually shippable.

Sebastian Falk ๐Ÿ‡ธ๐Ÿ‡ช

Founder

Stack

Next.jsTypeScript

Topics

Technical DebtAICode ReviewArchitecture
โ€œ

We wanted to add an AI feature that turns messy user notes into structured records, but our first attempt returned unpredictable JSON that broke the app half the time. Iurii rebuilt it using structured outputs against the OpenAI API with proper validation, so the data is always shaped the way our database expects. He also added a fallback path for when the model is unsure instead of letting it guess. It's been running in production for a month with no manual cleanup.

Bram de Vries ๐Ÿ‡ณ๐Ÿ‡ฑ

Product Lead

Stack

Next.jsTypeScript

Services

OpenAI

Topics

AILLMStructured Outputs

Related articles

Preventing Overselling: Inventory Locks Under Concurrent Checkouts
July 31, 2026ยท 13 min
Preventing Overselling: Inventory Locks Under Concurrent Checkouts

Prevent overselling under concurrent checkouts: reservations vs hard decrements, SELECT FOR UPDATE, deadlock-safe multi-line carts, and the payment window.

Stack

Next.jsTypeScriptNode.js

Databases

PostgreSQLRedis

Topics

E-commercePaymentsArchitectureSaaS
How to Add AI to an Existing Product Without Rewriting It
May 4, 2026ยท 11 min
How to Add AI to an Existing Product Without Rewriting It

Add AI to an existing product without a rebuild. Three integration patterns, how to pick the right one, and what production-ready AI actually demands.

Stack

Next.jsTypeScript

Libraries

Vercel AI SDK

Databases

PostgreSQLRedisUpstash Vector

Services

OpenAI

Topics

RAGArchitectureSaaSE-commerce
Subscription Billing: The Edge Cases Stripe Docs Skip
July 22, 2026ยท 17 min
Subscription Billing: The Edge Cases Stripe Docs Skip

Subscription billing edge cases Stripe glosses over: proration, dunning, the state machine, cancellation timing, refunds, VAT, and webhook ordering.

Stack

Next.jsTypeScriptNode.js

Databases

PostgreSQL

Services

Stripe

Topics

SaaSPaymentsE-commerceBilling
JWT vs Sessions vs OAuth: Which to Use for SaaS Auth
May 14, 2026ยท 15 min
JWT vs Sessions vs OAuth: Which to Use for SaaS Auth

JWT vs sessions vs OAuth for SaaS: token invalidation, mobile clients, refresh token rotation, and a decision matrix to pick the right auth pattern.

Stack

Next.jsTypeScriptNode.js

Libraries

Better Auth

Databases

PostgreSQLRedis

Topics

SaaSAuthArchitectureSecurity
Next.js Blog View Counter with Upstash Redis (Tutorial)
March 16, 2026ยท 8 min
Next.js Blog View Counter with Upstash Redis (Tutorial)

Next.js view counter with Upstash Redis over HTTP: atomic INCR, Edge Runtime for zero cold starts, React Strict Mode fix, and slug namespace gotchas.

Stack

Next.jsTypeScript

Databases

Redis

Services

VercelUpstash

Topics

SaaSPerformanceAPI Routes