Integration guide 8 min read

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.

Written and reviewed by the GSTIN API team Last reviewed

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.

PieceWhere it runsFileWhy there
Format + checksumBrowser and serverlib/gstin.tsPure function, no secrets; instant feedback and server-side re-check
Form + debounceBrowserapp/(site)/GstinField.tsx with 'use client'Needs state and events
API call with the keyServer onlyapp/api/verify-gstin/route.tsThe key never leaves the server
Rate limitServer onlyInside the route, backed by a shared storeProtects your credit balance
API keyServer onlyGSTINAPI_KEY env varNo NEXT_PUBLIC_ prefix, so it is never inlined into the bundle
Where each part of a GSTIN check belongs in a Next.js App Router project.

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 resultMeaningYour route returnsCharged
200, status_code: 1Live registration found200 with name, trade name, status1 credit
200, status_code: 0No registration, or invalid; reason in taxpayer_data.error200 with found: false and the messageFree
401Key missing, wrong or revoked503 generic message; log and alert yourselfFree
402Account out of credits503 generic message; alert whoever tops upFree
502Government source unreachable503 "try again shortly"; safe to retryFree
Timeout (your abort)No answer within your limit504 "try again"Depends on whether upstream completed
How the route translates GST API outcomes into responses for the browser.

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

No. Anything a client component can read, a visitor can read too, including an API key in a header. Put the call in a Route Handler or a Server Action, keep the key in a server-only environment variable, and have the browser talk only to your own endpoint.
Both keep the key on the server. A Route Handler at app/api/verify-gstin/route.ts is a plain HTTP endpoint, which makes it easy to call from a debounced fetch, to rate-limit, and to reuse from a mobile app. A Server Action is less code when the lookup happens once, on form submit, inside a form you already own.
Because the single-GSTIN endpoint returns HTTP 200 even when no registration is found. You have to read taxpayer_data.status_code: 1 means a live registration was found, 0 means it was not, with the reason in taxpayer_data.error. Checking res.ok alone treats every well-formed request as a success.
No. A credit is charged only when taxpayer_data.status_code is 1. Invalid GSTINs, not-found results, 401, 402 and 502 responses are free. You should still validate format and checksum in the browser first, because it saves a network round trip and gives the user an instant answer.
It depends on your version and settings: Next.js 14 cached fetch in server code by default, and Next.js 15 changed the default to not caching. Set cache: 'no-store' or an explicit revalidate on the call so the behaviour does not change underneath you on an upgrade.
In the project's Environment Variables settings, under a name without the NEXT_PUBLIC_ prefix, for each environment that needs it (Production, Preview, Development). Redeploy after adding or changing it, since environment variables are read at build and deploy time.

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 tool

Build it into your product

One REST call, JSON back. Get an API key and 20 free verification credits in under a minute.

Start free