GST Verification in Next.js
Verify GSTINs from a Next.js app through a route handler that keeps your API key on the server, with client-side format checks first.
Next.js blurs the line between browser code and server code, which is exactly why GST verification goes wrong in it. A component that looks like it runs on the server can end up in the client bundle, and an environment variable with the wrong prefix gets inlined into JavaScript that every visitor downloads. This guide builds a GSTIN check the App Router way: an instant format check in a client component, a Route Handler that holds the API key and calls the API, and the error mapping, caching and rate limiting that stop the route from leaking the key or burning credits.
The shape of a safe Next.js integration
The general Node.js mechanics, request building and retries, are covered in how to integrate the GST verification API in Node.js, and the validation logic itself in GSTIN validation in Node.js and TypeScript. This post is about where each piece lives in an App Router project.
| Piece | Where it runs | File | Why there |
|---|---|---|---|
| Format + checksum | Browser and server | lib/gstin.ts | Pure function, no secrets; instant feedback and server-side re-check |
| Form + debounce | Browser | app/(site)/GstinField.tsx with 'use client' | Needs state and events |
| API call with the key | Server only | app/api/verify-gstin/route.ts | The key never leaves the server |
| Rate limit | Server only | Inside the route, backed by a shared store | Protects your credit balance |
| API key | Server only | GSTINAPI_KEY env var | No NEXT_PUBLIC_ prefix, so it is never inlined into the bundle |
A Server Action works too, and it is the shorter path when the lookup only happens on submit. A Route Handler is the better default when you want an as-you-type check, because it is an ordinary HTTP endpoint you can debounce against, rate-limit, and reuse from other clients. This guide uses a Route Handler and shows the Server Action variant at the end of the route section.
Keep the key server-only
Next.js inlines any environment variable whose name starts with NEXT_PUBLIC_ into the client
bundle at build time. Everything else is available only to server code. So the rule is one line: name it
GSTINAPI_KEY, never NEXT_PUBLIC_GSTINAPI_KEY.
# .env.local (never commit this file)
GSTINAPI_KEY=your_key_here
If you wrap the API call in a helper module rather than calling it inside the route, add
import 'server-only' at the top of that module (it is the server-only npm
package). If a client component ever imports it by accident, the build fails instead of quietly shipping
a function that tries to read a key the browser does not have.
On Vercel, add GSTINAPI_KEY under the project's Environment Variables for each environment
that needs it (Production, Preview, Development) and redeploy, because a running deployment does not pick
up new values. Consider a separate key for Preview so a leaked preview URL cannot spend production
credits. Other hosts work the same way: set it as a runtime secret, not a build-time public variable.
You create keys from your dashboard after you sign up; the header name and base
URL are in the API docs.
The client form: check first, then ask
Many typing mistakes, a swapped digit or an O for a 0, are caught by the checksum alone. Catch those in the browser. The pattern and the modulus-36 check digit are explained step by step in the GSTIN checksum algorithm. Put them in a shared module that both the form and the route import:
// lib/gstin.ts: no secrets, safe to import anywhere
const CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const PATTERN = /^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$/;
export function normaliseGstin(input: string): string {
return input.toUpperCase().replace(/[^0-9A-Z]/g, '');
}
export function isValidGstin(gstin: string): boolean {
if (!PATTERN.test(gstin)) return false;
let sum = 0;
for (let i = 0; i < 14; i++) {
const product = CHARS.indexOf(gstin[i]) * (i % 2 === 0 ? 1 : 2);
sum += Math.floor(product / 36) + (product % 36);
}
return CHARS[(36 - (sum % 36)) % 36] === gstin[14];
}
If you would rather not maintain this, the @dlminds/gstin-api
package ships the same offline check with TypeScript types. Its validation half is safe in a browser
bundle; its API client half belongs on the server.
The field validates on every keystroke, and only when the string passes does it wait for the user to stop
typing before asking the server. An AbortController cancels the previous request if the value
changes mid-flight, so a slow response for an old value never overwrites a newer one.
// app/(site)/GstinField.tsx
'use client';
import { useEffect, useState } from 'react';
import { isValidGstin, normaliseGstin } from '@/lib/gstin';
type Result =
| { state: 'idle' | 'invalid' | 'checking' }
| { state: 'found'; name: string; tradename: string; status: string }
| { state: 'not_found' | 'error'; message: string };
export function GstinField() {
const [value, setValue] = useState('');
const [result, setResult] = useState({ state: 'idle' });
useEffect(() => {
const gstin = normaliseGstin(value);
if (gstin.length < 15) return setResult({ state: 'idle' });
if (!isValidGstin(gstin)) return setResult({ state: 'invalid' });
const controller = new AbortController();
const timer = setTimeout(async () => {
setResult({ state: 'checking' });
try {
const res = await fetch('/api/verify-gstin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gstin }),
signal: controller.signal,
});
const body = await res.json();
if (res.ok && body.found) setResult({ state: 'found', ...body });
else if (res.ok) setResult({ state: 'not_found', message: body.message });
else setResult({ state: 'error', message: body.message });
} catch (e) {
if ((e as Error).name !== 'AbortError') {
setResult({ state: 'error', message: 'Could not check right now.' });
}
}
}, 500);
return () => { clearTimeout(timer); controller.abort(); };
}, [value]);
return (
setValue(e.target.value)} autoComplete="off" />
{result.state === 'invalid' && That GSTIN has a typo: the check digit does not match.
}
{result.state === 'checking' && Checking…
}
{result.state === 'found' && {result.name} ({result.status})
}
{(result.state === 'not_found' || result.state === 'error') && {result.message}
}
);
}
For the wording of these messages, and why you should show the legal name back to the user for confirmation, see GST number validation in forms.
The route handler that calls the API
The route re-validates the input (never trust the client), calls
GET https://gstinapi.com/api/get-taxpayer-info/{gstin} with the key in the
X-API-Key header, and returns only the fields the form needs. Two details matter.
AbortSignal.timeout() puts a hard ceiling on how long a user waits on the government
source. And the success check reads taxpayer_data.status_code, because the endpoint returns
HTTP 200 whether or not a registration exists.
// app/api/verify-gstin/route.ts
import { NextResponse } from 'next/server';
import { isValidGstin, normaliseGstin } from '@/lib/gstin';
import { allowRequest } from '@/lib/rate-limit';
export const runtime = 'nodejs';
type TaxpayerEnvelope = {
taxpayer_data: {
status_code: 0 | 1;
gstin?: string;
name?: string; // legal name
tradename?: string;
status?: string; // e.g. "Active", "Cancelled"
error?: { error_cd: string; message: string };
};
filing_data?: unknown[];
};
export async function POST(req: Request) {
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
if (!(await allowRequest(ip))) {
return NextResponse.json({ message: 'Too many checks. Try again in a minute.' }, { status: 429 });
}
const body = await req.json().catch(() => null);
const gstin = normaliseGstin(String(body?.gstin ?? ''));
if (!isValidGstin(gstin)) {
return NextResponse.json({ message: 'That is not a valid GSTIN.' }, { status: 400 });
}
let res: Response;
try {
res = await fetch(`https://gstinapi.com/api/get-taxpayer-info/${gstin}`, {
headers: { 'X-API-Key': process.env.GSTINAPI_KEY! },
cache: 'no-store',
signal: AbortSignal.timeout(10_000),
});
} catch {
return NextResponse.json({ message: 'Verification timed out. Please try again.' }, { status: 504 });
}
if (res.status === 401 || res.status === 402) {
console.error(`GST API returned ${res.status}: check the key or top up credits`);
return NextResponse.json({ message: 'Verification is unavailable right now.' }, { status: 503 });
}
if (res.status === 502) {
return NextResponse.json({ message: 'The GST portal is not responding. Try again shortly.' }, { status: 503 });
}
if (!res.ok) {
return NextResponse.json({ message: 'Verification failed.' }, { status: 502 });
}
const { taxpayer_data: t } = (await res.json()) as TaxpayerEnvelope;
if (t.status_code !== 1) {
return NextResponse.json({
found: false,
message: t.error?.message ?? 'No registration found for this GSTIN.',
});
}
return NextResponse.json({
found: true,
name: t.name,
tradename: t.tradename,
status: t.status,
});
}
Notice what the route does not send back: the address, the filing history in filing_data,
anything the browser does not display. The same response carries GSTR filing history, which is useful
server-side (flag a supplier who has stopped filing) but is not the visitor's business. A credit is
charged only when status_code is 1; a not-found result is free.
The Server Action version is the same body without the HTTP plumbing: a function in a file marked
'use server' that takes the GSTIN, calls a shared verifyGstin() helper
(protected with import 'server-only'), and returns the same small object. Server Actions are
reachable by POST from outside your UI too, so they need the same validation and rate limit.
Mapping upstream errors to your own responses
Your route's status codes are for your UI, not a mirror of the upstream API. A 401 from the GST API means your key is wrong; telling the visitor "unauthorised" confuses them and tells an attacker something. The full list of codes and their causes is in the GST API error codes guide. For this route:
| Upstream result | Meaning | Your route returns | Charged |
|---|---|---|---|
200, status_code: 1 | Live registration found | 200 with name, trade name, status | 1 credit |
200, status_code: 0 | No registration, or invalid; reason in taxpayer_data.error | 200 with found: false and the message | Free |
| 401 | Key missing, wrong or revoked | 503 generic message; log and alert yourself | Free |
| 402 | Account out of credits | 503 generic message; alert whoever tops up | Free |
| 502 | Government source unreachable | 503 "try again shortly"; safe to retry | Free |
| Timeout (your abort) | No answer within your limit | 504 "try again" | Depends on whether upstream completed |
Decide in advance what a 503 means for the business flow. On a signup form you might let the user continue and re-verify in a background job. On a payout to a new supplier you should block until verification succeeds.
Caching: fresh status versus spent credits
Next.js has changed its fetch caching defaults between major versions. Server-side
fetch was cached by default in Next.js 14; Next.js 15 made it uncached by default. Do not
rely on either default. Say what you want on the call.
-
cache: 'no-store'for onboarding, payouts and anything where a registration cancelled yesterday must show as cancelled today. Every call costs a credit when a registration is found. -
next: { revalidate: 86400 }where a day-old answer is acceptable, such as displaying a supplier's name on an internal screen that reloads often. Repeat lookups of the same GSTIN within the window cost nothing, but a status change can take up to that window to appear.
For anything you need to audit, a table of your own beats the framework cache: store the GSTIN, the
fields you care about and a verified_at timestamp, and re-query when the row is older than
your policy allows. You can see and control that store. The Next.js Data Cache is tied to your
deployment platform and is harder to inspect when someone asks what the status was on the day you paid.
Rate-limit your own route
/api/verify-gstin is a public endpoint that spends your money. A scraper that finds it can
walk through valid GSTINs and have you pay ₹0.50 (₹0.40 at volume) for each registration it finds,
since the checksum stops random strings but not real ones. Limit per IP, and per user once someone is
signed in.
// lib/rate-limit.ts: fixed window, 10 checks per IP per minute.
// In-memory is fine on one long-running server. On serverless (Vercel),
// each instance has its own memory, so back this with Redis or a similar shared store.
const hits = new Map();
export async function allowRequest(key: string, limit = 10, windowMs = 60_000) {
const now = Date.now();
const entry = hits.get(key);
if (!entry || entry.resetAt < now) {
hits.set(key, { count: 1, resetAt: now + windowMs });
return true;
}
entry.count += 1;
return entry.count <= limit;
}
Ten a minute is generous for a human filling in a form and useless to a scraper. Add a daily cap per account as well, and a low-balance alert, so an abuse pattern shows up before it drains your credits. For how limits interact with retries and backoff on the upstream side, see GST API rate limits and webhooks. If you need to check a whole supplier list rather than one field at a time, use the bulk verification job instead of looping over your route.
Frequently asked questions
A note on accuracy. GST rules change often. This article reflects our understanding as of 23 September 2026 and is general information, not tax or legal advice. For the authoritative position, check gst.gov.in and cbic-gst.gov.in, or speak to a qualified tax professional about your specific situation.
Check a GSTIN right now, free
Five lookups a day, no account required. Status, filing history, e-invoicing and jurisdiction.
Free GST search toolBuild it into your product
One REST call, JSON back. Get an API key and 20 free verification credits in under a minute.
Start freeMore on developer integration guides
Browse every guide in the GST API blog.