Patient Insurance Eligibility Checker
Build a Next.js app that verifies patient insurance coverage via Stedi's 270/271 API.
Introduction
30 Second Summary
Every time you visit a doctor or dentist, someone at the front desk checks whether your insurance will cover the visit. That check happens through a standardized digital transaction called a 270/271 eligibility inquiry.
In this project, you will build a web application that performs these insurance eligibility checks in real time. You'll call Stedi's healthcare API and display a formatted breakdown of coverage status, copays, and deductibles.
What You'll Build
A Next.js web application that verifies patient insurance eligibility by calling Stedi's JSON Eligibility API and displaying formatted coverage results.
By the end of this project, you'll have:
- A server-side API route that securely proxies eligibility requests to Stedi, keeping secrets out of the browser.
- A patient intake form with pre-filled mock data for instant testing against real API responses.
- A formatted response view showing coverage status, plan info, copays, deductibles, and out-of-pocket maximums.
- Secret Mission: Build an Open Dental integration layer that maps dental practice patient data to the eligibility request format.
Are there any prerequisites?
This project assumes familiarity with React, TypeScript, and the Next.js App Router. You do not need any prior healthcare or insurance industry experience.
Set Up Your Project and Stedi Account
Before you can verify insurance eligibility, you need two things: a connection to a real healthcare clearinghouse and a local development environment to build your app.
In this step, you will create a free Stedi sandbox account, generate a test API key, and scaffold a Next.js project ready for development.
In this step, get ready to:
- Create a Stedi sandbox account and generate a test API key.
- Scaffold a Next.js 16 project with TypeScript and Tailwind CSS.
- Configure your environment variable and verify the dev server runs.
Create a free Stedi sandbox account
Stedi is a healthcare clearinghouse that provides a JSON-friendly API for running real-time eligibility checks. You will use their free sandbox to send mock 270/271 transactions without any charges.
- Open your browser and go to stedi.com/create-account.
- Select Create a healthcare clearinghouse account.
- Complete the signup form with your email, confirm your email address, and provide a company name.
- Choose the free sandbox account option.
Once your account is ready, you need to generate a test API key that your app will use to authenticate requests.
- Click your account name in the top-right corner of the Stedi dashboard.
- Click API Keys.
- Click Generate new API Key.
- Name it eligibility-test.
- Select Test mode.
- Click Generate.
- Copy the key immediately and save it somewhere safe. Stedi only shows it once.
What is a 270/271 transaction?
A 270 request asks a payer "does this patient have active coverage?" and a 271 response provides the answer with benefits details. Every medical and dental office in the US uses this standard to verify insurance before appointments.
Stedi translates these transactions into a JSON API so you do not need to work with raw X12 EDI formats.
Don't see the API Keys option?
Make sure you selected "Create a healthcare clearinghouse account" during signup. If you chose "Access EDI docs only", you will not have API access. Create a new account with the clearinghouse option.
help me find the API Keys section in my Stedi dashboard
Scaffold the Next.js project
Next.js gives you server-side API route handlers out of the box. This means your Stedi API key stays on the server and never reaches the browser.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Next.js project by running this command:
npx create-next-app@latest eligibility-app --yes --use-npm
What does this command do?
The --yes flag accepts the recommended defaults: TypeScript, Tailwind CSS, ESLint, and App Router. The --use-npm flag ensures npm is used as the package manager.
- Once scaffolding finishes, move into the project folder by running:
cd eligibility-app
- Confirm the project files exist by running:
ls
You should see files like package.json, tsconfig.json, next.config.ts, and an app/ directory in the output.
Seeing a 'command not found' error?
Make sure Node.js is installed on your machine. Run node --version to check. If it is not installed, download it from nodejs.org before continuing.
help me fix a command not found error when running npx create-next-app
Create your environment variable file and verify the dev server
Your API key needs to live in an environment variable so the server-side route can read it without exposing it to the browser.
- Create the .env.local file in your eligibility-app project root by running this command:
touch .env.local
- Open the project in VS Code by running:
code .
- In the VS Code Explorer sidebar, click on .env.local to open it.
- Paste the following into the file, replacing your-test-key-here with the actual Stedi API key you copied earlier:
STEDI_API_KEY=your-test-key-here
Why .env.local?
Next.js automatically loads variables from .env.local into process.env on the server side. Because this file is listed in .gitignore by default, your API key never gets committed to source control.
- Save .env.local.
- Start the dev server by running this command in your terminal:
npm run dev
- Open your browser and go to http://localhost:3000.
You should see the default Next.js welcome page. This confirms your project is running correctly with your environment variable configured.
Page not loading at localhost:3000?
Check your terminal for error messages. If you see a port conflict, another process may be using port 3000. Stop the other process or restart with a different port.
If you see a message about missing dependencies, run npm install inside the eligibility-app folder, then try npm run dev again.
help me get my Next.js dev server running at localhost:3000
✔️ Awesome, I've got everything!
Great. Double check you have saved .env.local with your real Stedi test API key in place of the placeholder.
ⓧ I'd like to double check the full code
Here is what your .env.local file should look like (with your actual key replacing the placeholder):
STEDI_API_KEY=your-test-key-here
Your development environment is ready. Next up, you will build the API route that proxies eligibility requests to Stedi.
Build the Eligibility API Route
Your app needs to talk to Stedi's eligibility API, but that API requires a secret key. If the browser called Stedi directly, your key would be exposed in network requests for anyone to steal.
The solution is a server-side API route that acts as a proxy. The browser sends a request to your own Next.js server, which attaches the secret key and forwards it to Stedi. The key never leaves the server.
In this step, get ready to:
- Create a server-side API route that proxies eligibility requests to Stedi.
- Build mock request payloads that match Stedi's test mode requirements.
- Test the route and confirm it returns real eligibility data.
Create the eligibility API route handler
In the Next.js App Router, you create API endpoints by placing a route.ts file inside a folder under app/. The folder path becomes the URL path. A file at app/api/eligibility/route.ts creates an endpoint at /api/eligibility.
- In the VS Code Explorer sidebar, right-click on the app folder and select New File.
- Type api/eligibility/route.ts as the filename. VS Code creates the nested folders automatically.
- Paste the following code into app/api/eligibility/route.ts:
import { NextRequest, NextResponse } from "next/server";
// Stedi's real-time eligibility check endpoint
const STEDI_ELIGIBILITY_URL =
"https://healthcare.us.stedi.com/2024-04-01/change/medicalnetwork/eligibility/v3";
export async function POST(request: NextRequest) {
// Read the API key from server-side environment variables
const apiKey = process.env.STEDI_API_KEY;
// Return early if the key isn't configured
if (!apiKey) {
return NextResponse.json(
{ error: "STEDI_API_KEY is not configured" },
{ status: 500 }
);
}
try {
// Parse the incoming request body from the frontend
const body = await request.json();
// Forward the request to Stedi with the secret API key attached
const response = await fetch(STEDI_ELIGIBILITY_URL, {
method: "POST",
headers: {
Authorization: `Key ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const data = await response.json();
// Pass through any Stedi-specific errors
if (!response.ok) {
return NextResponse.json(
{ error: "Stedi API error", details: data },
{ status: response.status }
);
}
return NextResponse.json(data);
} catch (error) {
// Handle network failures (Stedi unreachable, DNS issues, etc.)
return NextResponse.json(
{ error: "Failed to connect to Stedi API" },
{ status: 502 }
);
}
}
What does this code do?
This route handler accepts POST requests from your frontend and proxies them to Stedi's eligibility API.
- process.env.STEDI_API_KEY reads the key from your .env.local file. This value is only accessible on the server and never sent to the browser.
- The Authorization: Key ${apiKey} header is the format Stedi expects for authentication.
- Three error paths are handled: missing API key (500), Stedi returning an error (pass-through status), and network failure (502).
Why a server-side proxy instead of calling Stedi directly?
If the browser called Stedi directly, your API key would appear in the network tab for anyone to copy. A server-side route keeps the key hidden.
This is the same pattern that production healthcare apps use. When you later connect Open Dental as a patient data source, the same proxy handles both the patient lookup and the eligibility call without exposing any credentials to the client.
- Save app/api/eligibility/route.ts.
- You won't see a visible change in the browser yet. The next substep creates test data so you can call this route.
Seeing a red underline on the import?
If VS Code shows an error on import { NextRequest, NextResponse } from "next/server", your project dependencies may not be installed. Switch to your terminal and run npm install, then reopen the file.
Help me fix the import error in my route.ts file.
Create the mock request data file
Stedi's test API keys only work with specific predefined mock request values. Any other patient name, DOB, or member ID returns an error. You need to use these exact values when testing.
- In the VS Code Explorer sidebar, right-click on the project root (eligibility-app) and select New File.
- Type lib/mock-requests.ts as the filename. VS Code creates the lib folder automatically.
- Paste the following code into lib/mock-requests.ts:
export interface EligibilityRequest {
tradingPartnerServiceId: string;
provider: {
organizationName: string;
npi: string;
};
subscriber: {
firstName: string;
lastName: string;
dateOfBirth: string;
memberId: string;
};
encounter: {
serviceTypeCodes: string[];
};
}
// Three predefined test scenarios that Stedi's sandbox accepts
export const MOCK_REQUESTS: { label: string; data: EligibilityRequest }[] = [
{
label: "Aetna - Active Coverage",
data: {
tradingPartnerServiceId: "AETNA",
provider: {
organizationName: "ACME Health Services",
npi: "1999999984",
},
subscriber: {
firstName: "Jane",
lastName: "Doe",
dateOfBirth: "19000101",
memberId: "W123456789",
},
encounter: {
serviceTypeCodes: ["30"],
},
},
},
{
label: "UHC - Active Coverage",
data: {
tradingPartnerServiceId: "UHC",
provider: {
organizationName: "ACME Health Services",
npi: "1999999984",
},
subscriber: {
firstName: "Jane",
lastName: "Doe",
dateOfBirth: "19000101",
memberId: "123456789",
},
encounter: {
serviceTypeCodes: ["30"],
},
},
},
{
label: "AAA Error 72 - Invalid Subscriber ID",
data: {
tradingPartnerServiceId: "AAA72",
provider: {
organizationName: "ACME Health Services",
npi: "1999999984",
},
subscriber: {
firstName: "Jane",
lastName: "Doe",
dateOfBirth: "19000101",
memberId: "000000000",
},
encounter: {
serviceTypeCodes: ["30"],
},
},
},
];
What does this code do?
This file defines the shape of eligibility requests and provides three test scenarios:
- Aetna - Active Coverage returns a successful response with full benefits information (deductibles, copays, coinsurance).
- UHC - Active Coverage returns a successful response from a different payer.
- AAA Error 72 triggers a rejection response. This lets you test your error handling later.
Service type code 30 means "Health Benefit Plan Coverage", which is the general eligibility check used by most offices.
- Save lib/mock-requests.ts.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the complete app/api/eligibility/route.ts:
import { NextRequest, NextResponse } from "next/server";
const STEDI_ELIGIBILITY_URL =
"https://healthcare.us.stedi.com/2024-04-01/change/medicalnetwork/eligibility/v3";
export async function POST(request: NextRequest) {
const apiKey = process.env.STEDI_API_KEY;
if (!apiKey) {
return NextResponse.json(
{ error: "STEDI_API_KEY is not configured" },
{ status: 500 }
);
}
try {
const body = await request.json();
const response = await fetch(STEDI_ELIGIBILITY_URL, {
method: "POST",
headers: {
Authorization: `Key ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const data = await response.json();
if (!response.ok) {
return NextResponse.json(
{ error: "Stedi API error", details: data },
{ status: response.status }
);
}
return NextResponse.json(data);
} catch (error) {
return NextResponse.json(
{ error: "Failed to connect to Stedi API" },
{ status: 502 }
);
}
}
Here is the complete lib/mock-requests.ts:
export interface EligibilityRequest {
tradingPartnerServiceId: string;
provider: {
organizationName: string;
npi: string;
};
subscriber: {
firstName: string;
lastName: string;
dateOfBirth: string;
memberId: string;
};
encounter: {
serviceTypeCodes: string[];
};
}
export const MOCK_REQUESTS: { label: string; data: EligibilityRequest }[] = [
{
label: "Aetna - Active Coverage",
data: {
tradingPartnerServiceId: "AETNA",
provider: {
organizationName: "ACME Health Services",
npi: "1999999984",
},
subscriber: {
firstName: "Jane",
lastName: "Doe",
dateOfBirth: "19000101",
memberId: "W123456789",
},
encounter: {
serviceTypeCodes: ["30"],
},
},
},
{
label: "UHC - Active Coverage",
data: {
tradingPartnerServiceId: "UHC",
provider: {
organizationName: "ACME Health Services",
npi: "1999999984",
},
subscriber: {
firstName: "Jane",
lastName: "Doe",
dateOfBirth: "19000101",
memberId: "123456789",
},
encounter: {
serviceTypeCodes: ["30"],
},
},
},
{
label: "AAA Error 72 - Invalid Subscriber ID",
data: {
tradingPartnerServiceId: "AAA72",
provider: {
organizationName: "ACME Health Services",
npi: "1999999984",
},
subscriber: {
firstName: "Jane",
lastName: "Doe",
dateOfBirth: "19000101",
memberId: "000000000",
},
encounter: {
serviceTypeCodes: ["30"],
},
},
},
];
Test the route with a mock request
Both files are saved. Now you can send a real request to your API route and confirm Stedi responds with eligibility data.
- Open your browser to http://localhost:3000 (the dev server should already be running from Step 1).
- Open the browser's developer console by pressing Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).
- Paste this fetch call into the console and press Enter:
fetch('/api/eligibility', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tradingPartnerServiceId: "AETNA",
provider: { organizationName: "ACME Health Services", npi: "1999999984" },
subscriber: { firstName: "Jane", lastName: "Doe", dateOfBirth: "19000101", memberId: "W123456789" },
encounter: { serviceTypeCodes: ["30"] }
})
}).then(r => r.json()).then(data => console.log(data))
What should I see?
After a moment, the console prints a JSON object containing benefitsInformation (an array of coverage details), planDateInformation (eligibility start/end dates), and subscriber (the patient's name and member ID). This confirms your route successfully proxied the request to Stedi and returned real eligibility data.
Getting a 500 error about STEDI_API_KEY?
Your .env.local file might not have the correct key, or the dev server needs a restart. Stop the server with Ctrl+C in the terminal, then run npm run dev again. Next.js only reads .env.local at startup.
If you see a different error in the response, make sure you pasted the exact mock values shown above. Stedi's test mode rejects any values that don't match their predefined scenarios.
Help me debug my eligibility API route error.
Your API route is working and returning real eligibility data from Stedi's sandbox. Next up, you'll build a patient form that lets users fill in these fields and see the response formatted in a readable layout.
Build the Patient Form and Request Flow
Your API route can now proxy eligibility requests to Stedi and return the results. The missing piece is a user interface that lets a front-desk worker actually send those requests.
In this step, you will build the patient intake form, wire it to your API route, and add input validation so only well-formed requests reach Stedi.
In this step, get ready to:
- Create a two-column page with a patient form and response area.
- Wire form submission to POST requests to your API route.
- Add client-side validation for NPI, DOB, and required fields.
Create the page layout and form fields
The main page needs TypeScript interfaces to describe the shape of Stedi's response data. These types ensure your editor catches mismatched field names before runtime.
- In VS Code, create a new file at app/page.tsx inside your eligibility-app folder. This replaces the default Next.js welcome page.
- Paste the following imports and TypeScript interfaces at the top of the file:
"use client";
import { useState } from "react";
import { MOCK_REQUESTS, EligibilityRequest } from "@/lib/mock-requests";
interface BenefitInfo {
code: string;
name: string;
benefitAmount?: string;
benefitPercent?: string;
coverageLevel?: string;
coverageLevelCode?: string;
inPlanNetworkIndicator?: string;
inPlanNetworkIndicatorCode?: string;
serviceTypeCodes?: string[];
serviceTypes?: string[];
timeQualifier?: string;
timeQualifierCode?: string;
planCoverage?: string;
additionalInformation?: { description: string }[];
authOrCertIndicator?: string;
}
interface AAAError {
code: string;
description: string;
followupAction?: string;
possibleResolutions?: string;
}
interface EligibilityResponse {
benefitsInformation?: BenefitInfo[];
subscriber?: {
firstName?: string;
lastName?: string;
memberId?: string;
dateOfBirth?: string;
aaaErrors?: AAAError[];
};
payer?: {
name?: string;
aaaErrors?: AAAError[];
};
planDateInformation?: {
eligibilityBegin?: string;
planBegin?: string;
planEnd?: string;
};
errors?: AAAError[];
meta?: { applicationMode?: string };
}
interface HistoryEntry {
timestamp: string;
patientName: string;
status: "success" | "error";
response: EligibilityResponse | null;
errorMessage?: string;
}
What do these interfaces describe?
BenefitInfo maps to each entry in Stedi's benefitsInformation array. Each entry represents one benefit detail (deductible, copay, coinsurance, or coverage status).
AAAError represents rejection codes that payers return when they cannot process a request (invalid subscriber ID, payer down, etc.).
HistoryEntry tracks each verification attempt in the current session so the user can review past checks without re-submitting.
- Below the interfaces, add the component function with state declarations and the mock request handler:
export default function Home() {
const [form, setForm] = useState<EligibilityRequest>({
tradingPartnerServiceId: "",
provider: { organizationName: "", npi: "" },
subscriber: { firstName: "", lastName: "", dateOfBirth: "", memberId: "" },
encounter: { serviceTypeCodes: ["30"] },
});
const [response, setResponse] = useState<EligibilityResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showRaw, setShowRaw] = useState(false);
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
function handleMockSelect(index: number) {
if (index === -1) return;
setForm(MOCK_REQUESTS[index].data);
setValidationErrors({});
}
What does this state manage?
- form holds all the patient and provider fields that will be POSTed to your API route.
- response stores the full eligibility response from Stedi once a check succeeds.
- handleMockSelect populates all form fields at once when the user picks a test scenario from the dropdown.
You won't see a change yet. The component still needs its submission handler and JSX, which you will add in the next chunks.
Wire the form submission to your API route
When the user clicks Verify Eligibility, the form needs to POST its data to your /api/eligibility route, handle loading state, and record each attempt in session history.
- Still inside the Home component, add the following handleSubmit function below handleMockSelect:
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
setLoading(true);
setError(null);
setResponse(null);
try {
const res = await fetch("/api/eligibility", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const data = await res.json();
if (!res.ok) {
const errMsg = data.error || `Request failed with status ${res.status}`;
setError(errMsg);
setHistory((prev) => [
{
timestamp: new Date().toLocaleTimeString(),
patientName: `${form.subscriber.firstName} ${form.subscriber.lastName}`,
status: "error",
response: null,
errorMessage: errMsg,
},
...prev,
]);
return;
}
setResponse(data);
const hasErrors =
(data.errors && data.errors.length > 0) ||
data.subscriber?.aaaErrors?.length > 0 ||
data.payer?.aaaErrors?.length > 0;
setHistory((prev) => [
{
timestamp: new Date().toLocaleTimeString(),
patientName: `${form.subscriber.firstName} ${form.subscriber.lastName}`,
status: hasErrors ? "error" : "success",
response: data,
},
...prev,
]);
} catch {
setError("Failed to connect to the server. Check your network connection.");
setHistory((prev) => [
{
timestamp: new Date().toLocaleTimeString(),
patientName: `${form.subscriber.firstName} ${form.subscriber.lastName}`,
status: "error",
response: null,
errorMessage: "Network error",
},
...prev,
]);
} finally {
setLoading(false);
}
}
What does this function do?
- It calls validate() first and bails out if any field is invalid. You will add this function next.
- It POSTs the form state (already shaped to match Stedi's request schema) to your server-side route at /api/eligibility.
- On success or failure, it prepends a HistoryEntry to the session history so the user can review past checks.
- The catch block handles network-level failures (server unreachable, timeout) separately from API-level errors.
Why does the form state already match Stedi's schema?
The EligibilityRequest interface you imported from lib/mock-requests.ts already nests fields under tradingPartnerServiceId, provider, subscriber, and encounter. Because the form state uses this same interface, you can stringify it directly without reshaping.
You won't see a change yet. The validate function referenced by handleSubmit does not exist yet. You will add it next.
Add input validation and complete the JSX
Stedi requires specific formats for NPI (exactly 10 digits) and date of birth (YYYYMMDD). Catching invalid data before it leaves the browser saves a round-trip and gives instant feedback.
- Add the validate function between handleMockSelect and handleSubmit:
function validate(): boolean {
const errors: Record<string, string> = {};
if (!form.tradingPartnerServiceId) errors.payerId = "Payer ID is required";
if (!form.provider.organizationName) errors.orgName = "Organization name is required";
if (!form.provider.npi) errors.npi = "NPI is required";
else if (!/^\d{10}$/.test(form.provider.npi)) errors.npi = "NPI must be exactly 10 digits";
if (!form.subscriber.firstName) errors.firstName = "First name is required";
if (!form.subscriber.lastName) errors.lastName = "Last name is required";
if (!form.subscriber.dateOfBirth) errors.dob = "Date of birth is required";
else if (!/^\d{8}$/.test(form.subscriber.dateOfBirth)) errors.dob = "DOB must be 8 digits (YYYYMMDD)";
if (!form.subscriber.memberId) errors.memberId = "Member ID is required";
setValidationErrors(errors);
return Object.keys(errors).length === 0;
}
What does this function check?
- Every field is required. Empty fields get a descriptive error message.
- NPI must match /^\d{10}$/ (exactly 10 digits). This matches Stedi's documented NPI regex.
- Date of birth must match /^\d{8}$/ (8 digits in YYYYMMDD format). Stedi rejects any other format.
- The function returns true only when no errors exist, which handleSubmit checks before firing the request.
- Now add the return statement with the full JSX layout below handleSubmit. This includes the two-column grid, mock dropdown, form fields with inline validation errors, and the response column:
return (
<main className="min-h-screen bg-gray-50 p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Eligibility Checker</h1>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Form Column */}
<div className="lg:col-span-1">
<div className="bg-white rounded-lg shadow p-6">
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Select a mock request</label>
<select className="w-full border border-gray-300 rounded-md p-2 text-sm" onChange={(e) => handleMockSelect(Number(e.target.value))} defaultValue={-1}>
<option value={-1}>-- Choose a test scenario --</option>
{MOCK_REQUESTS.map((req, i) => (<option key={i} value={i}>{req.label}</option>))}
</select>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<input type="text" placeholder="Organization Name" className="w-full border border-gray-300 rounded-md p-2 text-sm" value={form.provider.organizationName} onChange={(e) => setForm({ ...form, provider: { ...form.provider, organizationName: e.target.value } })} />
{validationErrors.orgName && <p className="text-red-500 text-xs">{validationErrors.orgName}</p>}
<input type="text" placeholder="NPI (10 digits)" className="w-full border border-gray-300 rounded-md p-2 text-sm" value={form.provider.npi} onChange={(e) => setForm({ ...form, provider: { ...form.provider, npi: e.target.value } })} />
{validationErrors.npi && <p className="text-red-500 text-xs">{validationErrors.npi}</p>}
</div>
<button type="submit" disabled={loading} className="w-full bg-blue-600 text-white rounded-md p-2 text-sm font-medium hover:bg-blue-700 disabled:opacity-50">{loading ? "Checking..." : "Verify Eligibility"}</button>
</form>
</div>
</div>
{/* Response Column */}
<div className="lg:col-span-2">
<div className="bg-white rounded-lg shadow p-6 min-h-[400px]">
{!response && !error && !loading && (<p className="text-gray-500 text-sm">Select a mock request and click Verify Eligibility to see results.</p>)}
{loading && (<div className="flex items-center justify-center h-64"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div></div>)}
{error && (<div className="bg-red-50 border border-red-200 rounded-md p-4"><p className="text-red-700 text-sm">{error}</p></div>)}
</div>
</div>
</div>
</main>
);
}
What does this layout include?
- A three-column grid where the form takes one column and the response area takes two.
- A mock request dropdown that calls handleMockSelect to pre-fill all fields with test data.
- Inline validation error messages (the red <p> elements) that only render when validationErrors contains a matching key.
- The response column shows a placeholder message, a loading spinner, or an error depending on the current state.
- Save app/page.tsx.
- Return to your browser at http://localhost:3000. You should see the "Eligibility Checker" heading, a mock request dropdown, form fields for Organization Name and NPI, and a blue Verify Eligibility button.
- Select Aetna - Active Coverage from the dropdown. The form fields should auto-fill with test values.
- Click Verify Eligibility. The button text should change to "Checking..." momentarily, then the right column should show the placeholder text (the response display comes in the next step).
- Test validation by clearing the NPI field and clicking Verify Eligibility again. You should see a red "NPI is required" message below the NPI input.
Form not appearing or errors in the console?
- Check that the very first line of app/page.tsx is "use client"; with the semicolon. Without it, React hooks cannot run.
- If you see a module not found error for @/lib/mock-requests, confirm that lib/mock-requests.ts exists in your project root (not inside the app/ folder).
- If the page loads but clicking the button does nothing, make sure the validate function is placed between handleMockSelect and handleSubmit inside the component function.
Help me debug my eligibility form page
✔️ Awesome, I've got everything!
Great. Make sure you have saved app/page.tsx and that the form renders with no console errors.
ⓧ I'd like to double check the full code
Here is the complete app/page.tsx at this point:
"use client";
import { useState } from "react";
import { MOCK_REQUESTS, EligibilityRequest } from "@/lib/mock-requests";
interface BenefitInfo {
code: string;
name: string;
benefitAmount?: string;
benefitPercent?: string;
coverageLevel?: string;
coverageLevelCode?: string;
inPlanNetworkIndicator?: string;
inPlanNetworkIndicatorCode?: string;
serviceTypeCodes?: string[];
serviceTypes?: string[];
timeQualifier?: string;
timeQualifierCode?: string;
planCoverage?: string;
additionalInformation?: { description: string }[];
authOrCertIndicator?: string;
}
interface AAAError {
code: string;
description: string;
followupAction?: string;
possibleResolutions?: string;
}
interface EligibilityResponse {
benefitsInformation?: BenefitInfo[];
subscriber?: {
firstName?: string;
lastName?: string;
memberId?: string;
dateOfBirth?: string;
aaaErrors?: AAAError[];
};
payer?: {
name?: string;
aaaErrors?: AAAError[];
};
planDateInformation?: {
eligibilityBegin?: string;
planBegin?: string;
planEnd?: string;
};
errors?: AAAError[];
meta?: { applicationMode?: string };
}
interface HistoryEntry {
timestamp: string;
patientName: string;
status: "success" | "error";
response: EligibilityResponse | null;
errorMessage?: string;
}
export default function Home() {
const [form, setForm] = useState<EligibilityRequest>({
tradingPartnerServiceId: "",
provider: { organizationName: "", npi: "" },
subscriber: { firstName: "", lastName: "", dateOfBirth: "", memberId: "" },
encounter: { serviceTypeCodes: ["30"] },
});
const [response, setResponse] = useState<EligibilityResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showRaw, setShowRaw] = useState(false);
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
function handleMockSelect(index: number) {
if (index === -1) return;
setForm(MOCK_REQUESTS[index].data);
setValidationErrors({});
}
function validate(): boolean {
const errors: Record<string, string> = {};
if (!form.tradingPartnerServiceId) errors.payerId = "Payer ID is required";
if (!form.provider.organizationName) errors.orgName = "Organization name is required";
if (!form.provider.npi) errors.npi = "NPI is required";
else if (!/^\d{10}$/.test(form.provider.npi)) errors.npi = "NPI must be exactly 10 digits";
if (!form.subscriber.firstName) errors.firstName = "First name is required";
if (!form.subscriber.lastName) errors.lastName = "Last name is required";
if (!form.subscriber.dateOfBirth) errors.dob = "Date of birth is required";
else if (!/^\d{8}$/.test(form.subscriber.dateOfBirth)) errors.dob = "DOB must be 8 digits (YYYYMMDD)";
if (!form.subscriber.memberId) errors.memberId = "Member ID is required";
setValidationErrors(errors);
return Object.keys(errors).length === 0;
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
setLoading(true);
setError(null);
setResponse(null);
try {
const res = await fetch("/api/eligibility", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const data = await res.json();
if (!res.ok) {
const errMsg = data.error || `Request failed with status ${res.status}`;
setError(errMsg);
setHistory((prev) => [
{
timestamp: new Date().toLocaleTimeString(),
patientName: `${form.subscriber.firstName} ${form.subscriber.lastName}`,
status: "error",
response: null,
errorMessage: errMsg,
},
...prev,
]);
return;
}
setResponse(data);
const hasErrors =
(data.errors && data.errors.length > 0) ||
data.subscriber?.aaaErrors?.length > 0 ||
data.payer?.aaaErrors?.length > 0;
setHistory((prev) => [
{
timestamp: new Date().toLocaleTimeString(),
patientName: `${form.subscriber.firstName} ${form.subscriber.lastName}`,
status: hasErrors ? "error" : "success",
response: data,
},
...prev,
]);
} catch {
setError("Failed to connect to the server. Check your network connection.");
setHistory((prev) => [
{
timestamp: new Date().toLocaleTimeString(),
patientName: `${form.subscriber.firstName} ${form.subscriber.lastName}`,
status: "error",
response: null,
errorMessage: "Network error",
},
...prev,
]);
} finally {
setLoading(false);
}
}
return (
<main className="min-h-screen bg-gray-50 p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Eligibility Checker</h1>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Form Column */}
<div className="lg:col-span-1">
<div className="bg-white rounded-lg shadow p-6">
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Select a mock request</label>
<select className="w-full border border-gray-300 rounded-md p-2 text-sm" onChange={(e) => handleMockSelect(Number(e.target.value))} defaultValue={-1}>
<option value={-1}>-- Choose a test scenario --</option>
{MOCK_REQUESTS.map((req, i) => (<option key={i} value={i}>{req.label}</option>))}
</select>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<input type="text" placeholder="Organization Name" className="w-full border border-gray-300 rounded-md p-2 text-sm" value={form.provider.organizationName} onChange={(e) => setForm({ ...form, provider: { ...form.provider, organizationName: e.target.value } })} />
{validationErrors.orgName && <p className="text-red-500 text-xs">{validationErrors.orgName}</p>}
<input type="text" placeholder="NPI (10 digits)" className="w-full border border-gray-300 rounded-md p-2 text-sm" value={form.provider.npi} onChange={(e) => setForm({ ...form, provider: { ...form.provider, npi: e.target.value } })} />
{validationErrors.npi && <p className="text-red-500 text-xs">{validationErrors.npi}</p>}
</div>
<button type="submit" disabled={loading} className="w-full bg-blue-600 text-white rounded-md p-2 text-sm font-medium hover:bg-blue-700 disabled:opacity-50">{loading ? "Checking..." : "Verify Eligibility"}</button>
</form>
</div>
</div>
{/* Response Column */}
<div className="lg:col-span-2">
<div className="bg-white rounded-lg shadow p-6 min-h-[400px]">
{!response && !error && !loading && (<p className="text-gray-500 text-sm">Select a mock request and click Verify Eligibility to see results.</p>)}
{loading && (<div className="flex items-center justify-center h-64"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div></div>)}
{error && (<div className="bg-red-50 border border-red-200 rounded-md p-4"><p className="text-red-700 text-sm">{error}</p></div>)}
</div>
</div>
</div>
</main>
);
}
Your form can now collect patient data, validate it, and fire requests to Stedi through your secure server-side route. Next up, you will build the response display that turns the raw eligibility JSON into a readable breakdown of coverage, copays, and deductibles.
Display the Eligibility Response
Your API route is working and returning real eligibility data from Stedi. The raw JSON response contains dozens of nested fields describing a patient's coverage status, financial limits, and plan details.
In this step, you will parse that response into a human-readable display. Front-desk staff need to see coverage status, deductibles, and copays at a glance without digging through raw JSON.
In this step, get ready to:
- Build helper functions that extract coverage status, plan name, and financial details from the response.
- Render a formatted view showing deductibles, coinsurance, and out-of-pocket maximums.
- Add a Raw JSON toggle so users can inspect the full API response.
Add helper functions for parsing the eligibility response
The eligibility response from Stedi contains an array called benefitsInformation where each entry has a code field identifying what type of benefit it represents. You need helper functions that filter and interpret these codes.
- In app/page.tsx, find the closing brace of the handleSubmit function (just above the return statement).
- Add the following helper functions and computed values below it:
function getAllErrors(): AAAError[] {
if (!response) return [];
const errors: AAAError[] = [];
if (response.errors) errors.push(...response.errors);
return errors;
}
function getCoverageStatus(): string {
if (!response?.benefitsInformation) return "Unknown";
// Code "1" = Active Coverage, Code "6" = Inactive
const active = response.benefitsInformation.find(
(b) => b.code === "1" && b.serviceTypeCodes?.includes("30")
);
if (active) return "Active";
const inactive = response.benefitsInformation.find((b) => b.code === "6");
if (inactive) return "Inactive";
return "Unknown";
}
function getPlanName(): string {
if (!response?.benefitsInformation) return "N/A";
const entry = response.benefitsInformation.find((b) => b.planCoverage);
return entry?.planCoverage || "N/A";
}
function getBenefitsByType(code: string, network: string) {
if (!response?.benefitsInformation) return [];
// Filter by benefit code and in/out of network indicator
return response.benefitsInformation.filter(
(b) => b.code === code &&
b.inPlanNetworkIndicatorCode === network &&
b.serviceTypeCodes?.includes("30")
);
}
function formatDate(dateStr?: string): string {
// Convert YYYYMMDD to MM/DD/YYYY for display
if (!dateStr || dateStr.length !== 8) return "N/A";
return `${dateStr.slice(4, 6)}/${dateStr.slice(6, 8)}/${dateStr.slice(0, 4)}`;
}
const aaaErrors = getAllErrors();
const hasAAAErrors = aaaErrors.length > 0;
What do these benefit codes mean?
Stedi's eligibility response uses standard X12 271 benefit codes. Each entry in benefitsInformation has a code field that identifies the benefit type:
- Code "1" = Active Coverage. Code "6" = Inactive.
- Code "C" = Deductible. Code "G" = Out-of-Pocket Maximum.
- Code "A" = Coinsurance. Code "B" = Co-Payment.
- The inPlanNetworkIndicatorCode field uses "Y" for in-network and "N" for out-of-network.
- Save app/page.tsx.
- You won't see a change yet in the browser. The next chunk uses these functions to render the response.
Build the formatted response display with financial details
Now you will add the JSX that renders the eligibility response. This includes a coverage status badge, patient info, in-network benefits (deductibles, coinsurance, out-of-pocket maximums), and remaining amounts.
- In app/page.tsx, find this line inside the response column:
{error && (<div className="bg-red-50 border border-red-200 rounded-md p-4"><p className="text-red-700 text-sm">{error}</p></div>)}
- Replace that error block with the following updated error display AND the full response rendering block below it:
{error && (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<h3 className="text-red-800 font-medium text-sm">Error</h3>
<p className="text-red-700 text-sm mt-1">{error}</p>
<button
onClick={handleSubmit as () => void}
className="mt-2 text-sm text-red-600 underline"
>
Retry
</button>
</div>
)}
{response && (
<div>
{/* Toggle between Formatted and Raw JSON views */}
<div className="flex gap-2 mb-4">
<button
className={`px-3 py-1 text-sm rounded ${!showRaw ? "bg-blue-600 text-white" : "bg-gray-200 text-gray-700"}`}
onClick={() => setShowRaw(false)}
>
Formatted
</button>
<button
className={`px-3 py-1 text-sm rounded ${showRaw ? "bg-blue-600 text-white" : "bg-gray-200 text-gray-700"}`}
onClick={() => setShowRaw(true)}
>
Raw JSON
</button>
</div>
{showRaw ? (
<pre className="bg-gray-900 text-green-400 p-4 rounded-md text-xs overflow-auto max-h-[600px]">
{JSON.stringify(response, null, 2)}
</pre>
) : hasAAAErrors ? (
<div className="space-y-3">
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<h3 className="text-red-800 font-semibold text-sm">Eligibility Check Rejected</h3>
{aaaErrors.map((err, i) => (
<div key={i} className="mt-3 border-t border-red-100 pt-3">
<p className="text-sm"><span className="font-medium">Code:</span> {err.code}</p>
<p className="text-sm"><span className="font-medium">Description:</span> {err.description}</p>
{err.followupAction && <p className="text-sm"><span className="font-medium">Action:</span> {err.followupAction}</p>}
{err.possibleResolutions && <p className="text-sm text-gray-600 mt-1">{err.possibleResolutions}</p>}
</div>
))}
</div>
</div>
) : (
<div className="space-y-4">
{/* Coverage Status Badge */}
<div className="flex items-center gap-3">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getCoverageStatus() === "Active" ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"}`}>
{getCoverageStatus()}
</span>
<span className="text-sm text-gray-600">{getPlanName()}</span>
</div>
{/* Patient Info Grid */}
<div className="grid grid-cols-2 gap-4 bg-gray-50 rounded-md p-3">
<div>
<p className="text-xs text-gray-500">Subscriber</p>
<p className="text-sm font-medium">{response.subscriber?.firstName} {response.subscriber?.lastName}</p>
</div>
<div>
<p className="text-xs text-gray-500">Member ID</p>
<p className="text-sm font-medium">{response.subscriber?.memberId || "N/A"}</p>
</div>
<div>
<p className="text-xs text-gray-500">Plan Dates</p>
<p className="text-sm">{formatDate(response.planDateInformation?.planBegin)} - {formatDate(response.planDateInformation?.planEnd)}</p>
</div>
<div>
<p className="text-xs text-gray-500">Payer</p>
<p className="text-sm">{response.payer?.name || "N/A"}</p>
</div>
</div>
{/* In-Network Benefits */}
<div>
<h4 className="text-sm font-semibold text-gray-800 mb-2">In-Network Benefits</h4>
<div className="grid grid-cols-2 gap-3">
{getBenefitsByType("C", "Y").filter(b => b.timeQualifierCode === "23").map((b, i) => (
<div key={i} className="bg-blue-50 rounded p-2">
<p className="text-xs text-blue-600">Deductible ({b.coverageLevel})</p>
<p className="text-sm font-semibold">${b.benefitAmount}</p>
</div>
))}
{getBenefitsByType("G", "Y").filter(b => b.timeQualifierCode === "23").map((b, i) => (
<div key={i} className="bg-purple-50 rounded p-2">
<p className="text-xs text-purple-600">Out of Pocket Max ({b.coverageLevel})</p>
<p className="text-sm font-semibold">${b.benefitAmount}</p>
</div>
))}
{getBenefitsByType("A", "Y").filter(b => b.serviceTypeCodes?.includes("30")).slice(0, 2).map((b, i) => (
<div key={i} className="bg-green-50 rounded p-2">
<p className="text-xs text-green-600">Coinsurance ({b.coverageLevel})</p>
<p className="text-sm font-semibold">{Number(b.benefitPercent) * 100}%</p>
</div>
))}
</div>
</div>
{/* Remaining Benefits (Calendar Year) */}
{getBenefitsByType("C", "Y").filter(b => b.timeQualifierCode === "29").length > 0 && (
<div>
<h4 className="text-sm font-semibold text-gray-800 mb-2">Remaining (Calendar Year)</h4>
<div className="grid grid-cols-2 gap-3">
{getBenefitsByType("C", "Y").filter(b => b.timeQualifierCode === "29").map((b, i) => (
<div key={i} className="bg-yellow-50 rounded p-2">
<p className="text-xs text-yellow-700">Deductible Remaining ({b.coverageLevel})</p>
<p className="text-sm font-semibold">${b.benefitAmount}</p>
</div>
))}
{getBenefitsByType("G", "Y").filter(b => b.timeQualifierCode === "29").map((b, i) => (
<div key={i} className="bg-yellow-50 rounded p-2">
<p className="text-xs text-yellow-700">OOP Remaining ({b.coverageLevel})</p>
<p className="text-sm font-semibold">${b.benefitAmount}</p>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
)}
How does the financial display work?
The formatted view uses getBenefitsByType to filter the response by benefit code and network status. It then further filters by timeQualifierCode:
- timeQualifierCode "23" = Calendar Year total (the plan maximum).
- timeQualifierCode "29" = Remaining amount (how much the patient has left before hitting the limit).
- The inPlanNetworkIndicatorCode of "Y" filters to in-network benefits only. Out-of-network benefits use "N".
What about AAA errors?
When the payer rejects the eligibility check (invalid subscriber ID, payer system down, etc.), Stedi returns AAA error objects instead of benefits. The display detects these via hasAAAErrors and shows a red error card with the rejection code, description, and follow-up action.
- Save app/page.tsx.
- Return to your browser at http://localhost:3000. Select the Aetna - Active Coverage mock request from the dropdown and click Verify Eligibility.
- You should see a green "Active" badge, subscriber info, plan dates, and colored benefit cards showing deductibles and out-of-pocket maximums in the right column.
Seeing a blank right column?
- Make sure you placed the helper functions AFTER handleSubmit and BEFORE the return statement. They need access to the response state variable.
- Check that you replaced the old error block entirely. A duplicate conditional will prevent the response from rendering.
- If you see a TypeScript error about hasAAAErrors, confirm the const aaaErrors = getAllErrors() line is placed before the return statement.
Help me debug why my eligibility response isn't displaying in the right column.
Test the Raw JSON toggle and session history
The response display includes a toggle between Formatted and Raw JSON views. The Raw JSON view lets users inspect the complete API response, which is essential for debugging and understanding what data the payer returned.
- With the Aetna response still displayed, click the Raw JSON button at the top of the response column.
- You should see the full JSON response in a dark-themed code block with green text. Scroll through to see benefitsInformation, planDateInformation, and subscriber objects.
- Click Formatted to switch back to the parsed view.
- Now select AAA Error 72 - Invalid Subscriber ID from the mock dropdown and click Verify Eligibility.
- You should see a red error card showing the rejection code, description, and follow-up action instead of benefits.
- Check the Session History section below the form. You should see two entries with colored status dots. Click the first entry to re-display its result.
Why is session history stored in React state only?
The history array lives in component state and resets on page refresh. This is intentional. Persisting verification history to a database would mean storing Protected Health Information (PHI), which triggers HIPAA compliance requirements including encryption at rest, audit logging, and access controls.
By keeping history stateless and in-memory only, the app avoids storing any PHI. This minimizes the compliance surface while still giving the user a useful session-level reference.
AAA error not displaying?
- Make sure you selected the AAA Error 72 option from the dropdown before clicking submit. The mock request with tradingPartnerServiceId set to "AAA72" triggers the error response from Stedi's test API.
- If you see the formatted view instead of an error card, check that getAllErrors() is reading response.errors correctly. The error entries must be pushed into the array.
Help me figure out why my AAA error response isn't showing the red error card.
✔️ Awesome, I've got everything!
Great. Double check you have saved app/page.tsx and confirmed both the Formatted and Raw JSON views work.
ⓧ I'd like to double check the full code
Here is the complete app/page.tsx at the end of this step:
"use client";
import { useState } from "react";
import { MOCK_REQUESTS, EligibilityRequest } from "@/lib/mock-requests";
interface BenefitInfo {
code: string;
name: string;
benefitAmount?: string;
benefitPercent?: string;
coverageLevel?: string;
coverageLevelCode?: string;
inPlanNetworkIndicator?: string;
inPlanNetworkIndicatorCode?: string;
serviceTypeCodes?: string[];
serviceTypes?: string[];
timeQualifier?: string;
timeQualifierCode?: string;
planCoverage?: string;
additionalInformation?: { description: string }[];
authOrCertIndicator?: string;
}
interface AAAError {
code: string;
description: string;
followupAction?: string;
possibleResolutions?: string;
}
interface EligibilityResponse {
benefitsInformation?: BenefitInfo[];
subscriber?: {
firstName?: string;
lastName?: string;
memberId?: string;
dateOfBirth?: string;
aaaErrors?: AAAError[];
};
payer?: {
name?: string;
aaaErrors?: AAAError[];
};
planDateInformation?: {
eligibilityBegin?: string;
planBegin?: string;
planEnd?: string;
};
errors?: AAAError[];
meta?: { applicationMode?: string };
}
interface HistoryEntry {
timestamp: string;
patientName: string;
status: "success" | "error";
response: EligibilityResponse | null;
errorMessage?: string;
}
export default function Home() {
const [form, setForm] = useState<EligibilityRequest>({
tradingPartnerServiceId: "",
provider: { organizationName: "", npi: "" },
subscriber: { firstName: "", lastName: "", dateOfBirth: "", memberId: "" },
encounter: { serviceTypeCodes: ["30"] },
});
const [response, setResponse] = useState<EligibilityResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showRaw, setShowRaw] = useState(false);
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
function handleMockSelect(index: number) {
if (index === -1) return;
setForm(MOCK_REQUESTS[index].data);
setValidationErrors({});
}
function validate(): boolean {
const errors: Record<string, string> = {};
if (!form.tradingPartnerServiceId) errors.payerId = "Payer ID is required";
if (!form.provider.organizationName) errors.orgName = "Organization name is required";
if (!form.provider.npi) errors.npi = "NPI is required";
else if (!/^\d{10}$/.test(form.provider.npi)) errors.npi = "NPI must be exactly 10 digits";
if (!form.subscriber.firstName) errors.firstName = "First name is required";
if (!form.subscriber.lastName) errors.lastName = "Last name is required";
if (!form.subscriber.dateOfBirth) errors.dob = "Date of birth is required";
else if (!/^\d{8}$/.test(form.subscriber.dateOfBirth)) errors.dob = "DOB must be 8 digits (YYYYMMDD)";
if (!form.subscriber.memberId) errors.memberId = "Member ID is required";
setValidationErrors(errors);
return Object.keys(errors).length === 0;
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
setLoading(true);
setError(null);
setResponse(null);
try {
const res = await fetch("/api/eligibility", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const data = await res.json();
if (!res.ok) {
const errMsg = data.error || `Request failed with status ${res.status}`;
setError(errMsg);
setHistory((prev) => [{ timestamp: new Date().toLocaleTimeString(), patientName: `${form.subscriber.firstName} ${form.subscriber.lastName}`, status: "error", response: null, errorMessage: errMsg }, ...prev]);
return;
}
setResponse(data);
const hasErrors = (data.errors && data.errors.length > 0) || data.subscriber?.aaaErrors?.length > 0 || data.payer?.aaaErrors?.length > 0;
setHistory((prev) => [{ timestamp: new Date().toLocaleTimeString(), patientName: `${form.subscriber.firstName} ${form.subscriber.lastName}`, status: hasErrors ? "error" : "success", response: data }, ...prev]);
} catch {
setError("Failed to connect to the server. Check your network connection.");
setHistory((prev) => [{ timestamp: new Date().toLocaleTimeString(), patientName: `${form.subscriber.firstName} ${form.subscriber.lastName}`, status: "error", response: null, errorMessage: "Network error" }, ...prev]);
} finally {
setLoading(false);
}
}
function getAllErrors(): AAAError[] {
if (!response) return [];
const errors: AAAError[] = [];
if (response.errors) errors.push(...response.errors);
return errors;
}
function getCoverageStatus(): string {
if (!response?.benefitsInformation) return "Unknown";
const active = response.benefitsInformation.find((b) => b.code === "1" && b.serviceTypeCodes?.includes("30"));
if (active) return "Active";
const inactive = response.benefitsInformation.find((b) => b.code === "6");
if (inactive) return "Inactive";
return "Unknown";
}
function getPlanName(): string {
if (!response?.benefitsInformation) return "N/A";
const entry = response.benefitsInformation.find((b) => b.planCoverage);
return entry?.planCoverage || "N/A";
}
function getBenefitsByType(code: string, network: string) {
if (!response?.benefitsInformation) return [];
return response.benefitsInformation.filter((b) => b.code === code && b.inPlanNetworkIndicatorCode === network && b.serviceTypeCodes?.includes("30"));
}
function formatDate(dateStr?: string): string {
if (!dateStr || dateStr.length !== 8) return "N/A";
return `${dateStr.slice(4, 6)}/${dateStr.slice(6, 8)}/${dateStr.slice(0, 4)}`;
}
const aaaErrors = getAllErrors();
const hasAAAErrors = aaaErrors.length > 0;
return (
<main className="min-h-screen bg-gray-50 p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Eligibility Checker</h1>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-1">
<div className="bg-white rounded-lg shadow p-6">
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Select a mock request</label>
<select className="w-full border border-gray-300 rounded-md p-2 text-sm" onChange={(e) => handleMockSelect(Number(e.target.value))} defaultValue={-1}>
<option value={-1}>-- Choose a test scenario --</option>
{MOCK_REQUESTS.map((req, i) => (<option key={i} value={i}>{req.label}</option>))}
</select>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<fieldset className="space-y-2">
<legend className="text-sm font-semibold text-gray-800">Provider</legend>
<input type="text" placeholder="Organization Name" className="w-full border border-gray-300 rounded-md p-2 text-sm" value={form.provider.organizationName} onChange={(e) => setForm({ ...form, provider: { ...form.provider, organizationName: e.target.value } })} />
{validationErrors.orgName && <p className="text-red-500 text-xs">{validationErrors.orgName}</p>}
<input type="text" placeholder="NPI (10 digits)" className="w-full border border-gray-300 rounded-md p-2 text-sm" value={form.provider.npi} onChange={(e) => setForm({ ...form, provider: { ...form.provider, npi: e.target.value } })} />
{validationErrors.npi && <p className="text-red-500 text-xs">{validationErrors.npi}</p>}
</fieldset>
<fieldset className="space-y-2">
<legend className="text-sm font-semibold text-gray-800">Subscriber</legend>
<input type="text" placeholder="First Name" className="w-full border border-gray-300 rounded-md p-2 text-sm" value={form.subscriber.firstName} onChange={(e) => setForm({ ...form, subscriber: { ...form.subscriber, firstName: e.target.value } })} />
{validationErrors.firstName && <p className="text-red-500 text-xs">{validationErrors.firstName}</p>}
<input type="text" placeholder="Last Name" className="w-full border border-gray-300 rounded-md p-2 text-sm" value={form.subscriber.lastName} onChange={(e) => setForm({ ...form, subscriber: { ...form.subscriber, lastName: e.target.value } })} />
{validationErrors.lastName && <p className="text-red-500 text-xs">{validationErrors.lastName}</p>}
<input type="text" placeholder="Date of Birth (YYYYMMDD)" className="w-full border border-gray-300 rounded-md p-2 text-sm" value={form.subscriber.dateOfBirth} onChange={(e) => setForm({ ...form, subscriber: { ...form.subscriber, dateOfBirth: e.target.value } })} />
{validationErrors.dob && <p className="text-red-500 text-xs">{validationErrors.dob}</p>}
<input type="text" placeholder="Member ID" className="w-full border border-gray-300 rounded-md p-2 text-sm" value={form.subscriber.memberId} onChange={(e) => setForm({ ...form, subscriber: { ...form.subscriber, memberId: e.target.value } })} />
{validationErrors.memberId && <p className="text-red-500 text-xs">{validationErrors.memberId}</p>}
</fieldset>
<fieldset className="space-y-2">
<legend className="text-sm font-semibold text-gray-800">Encounter</legend>
<input type="text" placeholder="Payer ID" className="w-full border border-gray-300 rounded-md p-2 text-sm" value={form.tradingPartnerServiceId} onChange={(e) => setForm({ ...form, tradingPartnerServiceId: e.target.value })} />
{validationErrors.payerId && <p className="text-red-500 text-xs">{validationErrors.payerId}</p>}
<input type="text" placeholder="Service Type Code (default: 30)" className="w-full border border-gray-300 rounded-md p-2 text-sm" value={form.encounter.serviceTypeCodes[0]} onChange={(e) => setForm({ ...form, encounter: { serviceTypeCodes: [e.target.value] } })} />
</fieldset>
<button type="submit" disabled={loading} className="w-full bg-blue-600 text-white rounded-md p-2 text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">{loading ? "Checking..." : "Verify Eligibility"}</button>
</form>
</div>
{history.length > 0 && (
<div className="bg-white rounded-lg shadow p-4 mt-4">
<h3 className="text-sm font-semibold text-gray-800 mb-2">Session History</h3>
<ul className="space-y-2">
{history.map((entry, i) => (
<li key={i} className="text-xs p-2 rounded cursor-pointer hover:bg-gray-100" onClick={() => { if (entry.response) { setResponse(entry.response); setError(null); } else if (entry.errorMessage) { setError(entry.errorMessage); setResponse(null); } }}>
<span className={`inline-block w-2 h-2 rounded-full mr-2 ${entry.status === "success" ? "bg-green-500" : "bg-red-500"}`}></span>
<span className="font-medium">{entry.patientName}</span>
<span className="text-gray-500 ml-2">{entry.timestamp}</span>
</li>
))}
</ul>
</div>
)}
</div>
<div className="lg:col-span-2">
<div className="bg-white rounded-lg shadow p-6 min-h-[400px]">
{!response && !error && !loading && (<p className="text-gray-500 text-sm">Select a mock request and click Verify Eligibility to see results.</p>)}
{loading && (<div className="flex items-center justify-center h-64"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div></div>)}
{error && (<div className="bg-red-50 border border-red-200 rounded-md p-4"><h3 className="text-red-800 font-medium text-sm">Error</h3><p className="text-red-700 text-sm mt-1">{error}</p><button onClick={handleSubmit as () => void} className="mt-2 text-sm text-red-600 underline">Retry</button></div>)}
{response && (
<div>
<div className="flex gap-2 mb-4">
<button className={`px-3 py-1 text-sm rounded ${!showRaw ? "bg-blue-600 text-white" : "bg-gray-200 text-gray-700"}`} onClick={() => setShowRaw(false)}>Formatted</button>
<button className={`px-3 py-1 text-sm rounded ${showRaw ? "bg-blue-600 text-white" : "bg-gray-200 text-gray-700"}`} onClick={() => setShowRaw(true)}>Raw JSON</button>
</div>
{showRaw ? (
<pre className="bg-gray-900 text-green-400 p-4 rounded-md text-xs overflow-auto max-h-[600px]">{JSON.stringify(response, null, 2)}</pre>
) : hasAAAErrors ? (
<div className="space-y-3">
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<h3 className="text-red-800 font-semibold text-sm">Eligibility Check Rejected</h3>
{aaaErrors.map((err, i) => (<div key={i} className="mt-3 border-t border-red-100 pt-3"><p className="text-sm"><span className="font-medium">Code:</span> {err.code}</p><p className="text-sm"><span className="font-medium">Description:</span> {err.description}</p>{err.followupAction && <p className="text-sm"><span className="font-medium">Action:</span> {err.followupAction}</p>}{err.possibleResolutions && <p className="text-sm text-gray-600 mt-1">{err.possibleResolutions}</p>}</div>))}
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center gap-3">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getCoverageStatus() === "Active" ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"}`}>{getCoverageStatus()}</span>
<span className="text-sm text-gray-600">{getPlanName()}</span>
</div>
<div className="grid grid-cols-2 gap-4 bg-gray-50 rounded-md p-3">
<div><p className="text-xs text-gray-500">Subscriber</p><p className="text-sm font-medium">{response.subscriber?.firstName} {response.subscriber?.lastName}</p></div>
<div><p className="text-xs text-gray-500">Member ID</p><p className="text-sm font-medium">{response.subscriber?.memberId || "N/A"}</p></div>
<div><p className="text-xs text-gray-500">Plan Dates</p><p className="text-sm">{formatDate(response.planDateInformation?.planBegin)} - {formatDate(response.planDateInformation?.planEnd)}</p></div>
<div><p className="text-xs text-gray-500">Payer</p><p className="text-sm">{response.payer?.name || "N/A"}</p></div>
</div>
<div>
<h4 className="text-sm font-semibold text-gray-800 mb-2">In-Network Benefits</h4>
<div className="grid grid-cols-2 gap-3">
{getBenefitsByType("C", "Y").filter(b => b.timeQualifierCode === "23").map((b, i) => (<div key={i} className="bg-blue-50 rounded p-2"><p className="text-xs text-blue-600">Deductible ({b.coverageLevel})</p><p className="text-sm font-semibold">${b.benefitAmount}</p></div>))}
{getBenefitsByType("G", "Y").filter(b => b.timeQualifierCode === "23").map((b, i) => (<div key={i} className="bg-purple-50 rounded p-2"><p className="text-xs text-purple-600">Out of Pocket Max ({b.coverageLevel})</p><p className="text-sm font-semibold">${b.benefitAmount}</p></div>))}
{getBenefitsByType("A", "Y").filter(b => b.serviceTypeCodes?.includes("30")).slice(0, 2).map((b, i) => (<div key={i} className="bg-green-50 rounded p-2"><p className="text-xs text-green-600">Coinsurance ({b.coverageLevel})</p><p className="text-sm font-semibold">{Number(b.benefitPercent) * 100}%</p></div>))}
</div>
</div>
{getBenefitsByType("C", "Y").filter(b => b.timeQualifierCode === "29").length > 0 && (
<div>
<h4 className="text-sm font-semibold text-gray-800 mb-2">Remaining (Calendar Year)</h4>
<div className="grid grid-cols-2 gap-3">
{getBenefitsByType("C", "Y").filter(b => b.timeQualifierCode === "29").map((b, i) => (<div key={i} className="bg-yellow-50 rounded p-2"><p className="text-xs text-yellow-700">Deductible Remaining ({b.coverageLevel})</p><p className="text-sm font-semibold">${b.benefitAmount}</p></div>))}
{getBenefitsByType("G", "Y").filter(b => b.timeQualifierCode === "29").map((b, i) => (<div key={i} className="bg-yellow-50 rounded p-2"><p className="text-xs text-yellow-700">OOP Remaining ({b.coverageLevel})</p><p className="text-sm font-semibold">${b.benefitAmount}</p></div>))}
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
</main>
);
}
Your eligibility checker now presents complex insurance data in a format that front-desk staff can read at a glance. Next up, you will add robust error handling for network failures and edge cases to make the app production-ready.
Handle Errors and Edge Cases
Your eligibility checker displays successful results beautifully. But what happens when a subscriber ID is invalid, a payer system is down, or your own API key expires?
In this step, you'll walk through the error handling patterns built into your page and test them using Stedi's mock scenarios. Robust error handling is critical in healthcare workflows because a silent failure could mean a patient gets turned away at the front desk.
In this step, get ready to:
- Test AAA error detection and display using the error mock request.
- Verify network-level error handling with user-friendly messages and a retry button.
- Explore the stateless session history sidebar designed for PHI compliance.
Test AAA error detection and display
In the 270/271 eligibility transaction standard, payers reject invalid requests using AAA error codes. Your app already detects these errors through the getAllErrors() function in app/page.tsx.
This function checks three locations where AAA errors can appear in Stedi's response:
function getAllErrors(): AAAError[] {
if (!response) return [];
const errors: AAAError[] = [];
// Check the top-level errors array
if (response.errors) errors.push(...response.errors);
return errors;
}
What are AAA error codes?
AAA segments are the 271 transaction's way of saying "this request was rejected." Each AAA error includes a numeric code (like 72 for invalid subscriber ID), a human-readable description, and a follow-up action telling the provider what to do next.
Your code also checks response.subscriber?.aaaErrors and response.payer?.aaaErrors inside handleSubmit to determine the status for the history sidebar.
- Return to your browser at http://localhost:3000.
- From the Select a mock request dropdown, choose AAA Error 72 - Invalid Subscriber ID.
- Click Verify Eligibility.
You should see a red error panel in the right column with the heading "Eligibility Check Rejected." The panel shows the error code, description, follow-up action, and possible resolutions returned by Stedi.
How does the error display work?
When hasAAAErrors is true, the response column renders a red-bordered error view instead of the benefits breakdown. Each error entry displays its code, description, followupAction, and possibleResolutions fields. The red color-coding signals invalid data errors to the user at a glance.
Not seeing the error panel?
Make sure you selected the "AAA Error 72" option from the dropdown before clicking the button. If the form fields are empty, the client-side validation will block submission before it reaches Stedi.
Check that your dev server is still running in the terminal. If it stopped, restart it with npm run dev.
Help me debug why my AAA error mock isn't returning an error panel.
Verify network-level error handling
AAA errors come from Stedi when the payer rejects a request. Network-level errors happen before Stedi even responds. Your app handles three scenarios: the API key is missing, Stedi is unreachable, and the response is malformed.
The key code lives in two places. Your API route in app/api/eligibility/route.ts returns specific HTTP status codes, and your frontend's handleSubmit function catches those errors and displays user-friendly messages.
// In handleSubmit - network error handling
if (!res.ok) {
const errMsg = data.error || `Request failed with status ${res.status}`;
setError(errMsg);
// ... adds to history as error
return;
}
What does this code do?
When the API route returns a non-200 status, res.ok is false. The code extracts the error message from the response body (like "STEDI_API_KEY is not configured") and stores it in state. The catch block handles the case where the fetch itself fails (network timeout, server down), showing "Failed to connect to the server."
- Open .env.local in VS Code.
- Temporarily rename the variable to STEDI_API_KEY_DISABLED (this simulates a missing key).
- Save .env.local and restart the dev server (stop it with Ctrl+C, then run npm run dev again).
- Select any mock request and click Verify Eligibility.
You should see a red error box showing "STEDI_API_KEY is not configured" with a Retry link underneath it. The Retry button lets front-desk staff re-attempt after a transient issue is resolved without re-entering patient details.
- Rename the variable back to STEDI_API_KEY in .env.local.
- Save the file and restart the dev server.
Why stateless error handling matters in healthcare
Notice that error messages never log patient details to the console or persist them anywhere. The error state lives only in React's useState hook and disappears when the page is refreshed. This is intentional for HIPAA compliance. No PHI is written to disk, local storage, or server logs.
Still seeing a successful response after disabling the key?
Next.js caches environment variables at server start. You must restart the dev server after changing .env.local for the change to take effect. Stop the server and run npm run dev again.
Help me debug why my API route isn't detecting the missing key.
Explore the session history sidebar
In a real front-desk workflow, a staff member might verify several patients in a row. The session history sidebar tracks each check so they can quickly review previous results without re-submitting.
// History stored in React state only - not persisted
const [history, setHistory] = useState<HistoryEntry[]>([]);
// Each entry records timestamp, patient name, and result status
interface HistoryEntry {
timestamp: string;
patientName: string;
status: "success" | "error";
response: EligibilityResponse | null;
errorMessage?: string;
}
Why is history stored only in React state?
Persisting eligibility results to a database or local storage would mean storing Protected Health Information (PHI). That triggers HIPAA security requirements like encryption at rest, access logging, and breach notification procedures.
By keeping history in useState only, all data disappears on page refresh. This is a deliberate architectural choice that minimizes your compliance surface while still giving the user a useful workflow within a single session.
- Select Aetna - Active Coverage from the dropdown and click Verify Eligibility.
- Select AAA Error 72 - Invalid Subscriber ID and click Verify Eligibility again.
- Select UHC - Active Coverage and submit a third check.
You should see a "Session History" panel appear below the form with three entries. Each entry shows a colored status dot (green for success, red for error), the patient name, and a timestamp.
- Click the first entry (the Aetna check) in the history list.
The right column should switch back to showing the Aetna benefits breakdown. Clicking any history entry re-displays that result without making another API call to Stedi.
- Refresh the page in your browser.
The history panel disappears completely. This confirms no PHI is persisted anywhere outside of the browser's volatile memory.
History panel not showing up?
The history panel only renders when history.length > 0. If you refreshed the page between checks, the array resets to empty. Run at least one eligibility check to see the panel appear.
Help me figure out why my session history isn't appearing after submitting checks.
✔️ Awesome, I've got everything!
Great. Double check you haven't left any changes in .env.local from the network error test. The variable should be named STEDI_API_KEY (not STEDI_API_KEY_DISABLED).
ⓧ I'd like to double check the full code
Here is the complete app/page.tsx for reference. Your file should match this exactly.
"use client";
import { useState } from "react";
import { MOCK_REQUESTS, EligibilityRequest } from "@/lib/mock-requests";
interface BenefitInfo {
code: string;
name: string;
benefitAmount?: string;
benefitPercent?: string;
coverageLevel?: string;
coverageLevelCode?: string;
inPlanNetworkIndicator?: string;
inPlanNetworkIndicatorCode?: string;
serviceTypeCodes?: string[];
serviceTypes?: string[];
timeQualifier?: string;
timeQualifierCode?: string;
planCoverage?: string;
additionalInformation?: { description: string }[];
authOrCertIndicator?: string;
}
interface AAAError {
code: string;
description: string;
followupAction?: string;
possibleResolutions?: string;
}
interface EligibilityResponse {
benefitsInformation?: BenefitInfo[];
subscriber?: {
firstName?: string;
lastName?: string;
memberId?: string;
dateOfBirth?: string;
aaaErrors?: AAAError[];
};
payer?: {
name?: string;
aaaErrors?: AAAError[];
};
planDateInformation?: {
eligibilityBegin?: string;
planBegin?: string;
planEnd?: string;
};
errors?: AAAError[];
meta?: { applicationMode?: string };
}
interface HistoryEntry {
timestamp: string;
patientName: string;
status: "success" | "error";
response: EligibilityResponse | null;
errorMessage?: string;
}
export default function Home() {
const [form, setForm] = useState<EligibilityRequest>({
tradingPartnerServiceId: "",
provider: { organizationName: "", npi: "" },
subscriber: { firstName: "", lastName: "", dateOfBirth: "", memberId: "" },
encounter: { serviceTypeCodes: ["30"] },
});
const [response, setResponse] = useState<EligibilityResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showRaw, setShowRaw] = useState(false);
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
function handleMockSelect(index: number) {
if (index === -1) return;
setForm(MOCK_REQUESTS[index].data);
setValidationErrors({});
}
function validate(): boolean {
const errors: Record<string, string> = {};
if (!form.tradingPartnerServiceId) errors.payerId = "Payer ID is required";
if (!form.provider.organizationName) errors.orgName = "Organization name is required";
if (!form.provider.npi) errors.npi = "NPI is required";
else if (!/^\d{10}$/.test(form.provider.npi)) errors.npi = "NPI must be exactly 10 digits";
if (!form.subscriber.firstName) errors.firstName = "First name is required";
if (!form.subscriber.lastName) errors.lastName = "Last name is required";
if (!form.subscriber.dateOfBirth) errors.dob = "Date of birth is required";
else if (!/^\d{8}$/.test(form.subscriber.dateOfBirth)) errors.dob = "DOB must be 8 digits (YYYYMMDD)";
if (!form.subscriber.memberId) errors.memberId = "Member ID is required";
setValidationErrors(errors);
return Object.keys(errors).length === 0;
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
setLoading(true);
setError(null);
setResponse(null);
try {
const res = await fetch("/api/eligibility", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const data = await res.json();
if (!res.ok) {
const errMsg = data.error || `Request failed with status ${res.status}`;
setError(errMsg);
setHistory((prev) => [
{
timestamp: new Date().toLocaleTimeString(),
patientName: `${form.subscriber.firstName} ${form.subscriber.lastName}`,
status: "error",
response: null,
errorMessage: errMsg,
},
...prev,
]);
return;
}
setResponse(data);
const hasErrors =
(data.errors && data.errors.length > 0) ||
data.subscriber?.aaaErrors?.length > 0 ||
data.payer?.aaaErrors?.length > 0;
setHistory((prev) => [
{
timestamp: new Date().toLocaleTimeString(),
patientName: `${form.subscriber.firstName} ${form.subscriber.lastName}`,
status: hasErrors ? "error" : "success",
response: data,
},
...prev,
]);
} catch {
setError("Failed to connect to the server. Check your network connection.");
setHistory((prev) => [
{
timestamp: new Date().toLocaleTimeString(),
patientName: `${form.subscriber.firstName} ${form.subscriber.lastName}`,
status: "error",
response: null,
errorMessage: "Network error",
},
...prev,
]);
} finally {
setLoading(false);
}
}
function getAllErrors(): AAAError[] {
if (!response) return [];
const errors: AAAError[] = [];
if (response.errors) errors.push(...response.errors);
return errors;
}
function getCoverageStatus(): string {
if (!response?.benefitsInformation) return "Unknown";
const active = response.benefitsInformation.find(
(b) => b.code === "1" && b.serviceTypeCodes?.includes("30")
);
if (active) return "Active";
const inactive = response.benefitsInformation.find((b) => b.code === "6");
if (inactive) return "Inactive";
return "Unknown";
}
function getPlanName(): string {
if (!response?.benefitsInformation) return "N/A";
const entry = response.benefitsInformation.find((b) => b.planCoverage);
return entry?.planCoverage || "N/A";
}
function getBenefitsByType(code: string, network: string) {
if (!response?.benefitsInformation) return [];
return response.benefitsInformation.filter(
(b) =>
b.code === code &&
b.inPlanNetworkIndicatorCode === network &&
b.serviceTypeCodes?.includes("30")
);
}
function formatDate(dateStr?: string): string {
if (!dateStr || dateStr.length !== 8) return "N/A";
return `${dateStr.slice(4, 6)}/${dateStr.slice(6, 8)}/${dateStr.slice(0, 4)}`;
}
const aaaErrors = getAllErrors();
const hasAAAErrors = aaaErrors.length > 0;
return (
<main className="min-h-screen bg-gray-50 p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Eligibility Checker</h1>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Form Column */}
<div className="lg:col-span-1">
<div className="bg-white rounded-lg shadow p-6">
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Select a mock request
</label>
<select
className="w-full border border-gray-300 rounded-md p-2 text-sm"
onChange={(e) => handleMockSelect(Number(e.target.value))}
defaultValue={-1}
>
<option value={-1}>-- Choose a test scenario --</option>
{MOCK_REQUESTS.map((req, i) => (
<option key={i} value={i}>{req.label}</option>
))}
</select>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<fieldset className="space-y-2">
<legend className="text-sm font-semibold text-gray-800">Provider</legend>
<input
type="text"
placeholder="Organization Name"
className="w-full border border-gray-300 rounded-md p-2 text-sm"
value={form.provider.organizationName}
onChange={(e) => setForm({ ...form, provider: { ...form.provider, organizationName: e.target.value } })}
/>
{validationErrors.orgName && <p className="text-red-500 text-xs">{validationErrors.orgName}</p>}
<input
type="text"
placeholder="NPI (10 digits)"
className="w-full border border-gray-300 rounded-md p-2 text-sm"
value={form.provider.npi}
onChange={(e) => setForm({ ...form, provider: { ...form.provider, npi: e.target.value } })}
/>
{validationErrors.npi && <p className="text-red-500 text-xs">{validationErrors.npi}</p>}
</fieldset>
<fieldset className="space-y-2">
<legend className="text-sm font-semibold text-gray-800">Subscriber</legend>
<input
type="text"
placeholder="First Name"
className="w-full border border-gray-300 rounded-md p-2 text-sm"
value={form.subscriber.firstName}
onChange={(e) => setForm({ ...form, subscriber: { ...form.subscriber, firstName: e.target.value } })}
/>
{validationErrors.firstName && <p className="text-red-500 text-xs">{validationErrors.firstName}</p>}
<input
type="text"
placeholder="Last Name"
className="w-full border border-gray-300 rounded-md p-2 text-sm"
value={form.subscriber.lastName}
onChange={(e) => setForm({ ...form, subscriber: { ...form.subscriber, lastName: e.target.value } })}
/>
{validationErrors.lastName && <p className="text-red-500 text-xs">{validationErrors.lastName}</p>}
<input
type="text"
placeholder="Date of Birth (YYYYMMDD)"
className="w-full border border-gray-300 rounded-md p-2 text-sm"
value={form.subscriber.dateOfBirth}
onChange={(e) => setForm({ ...form, subscriber: { ...form.subscriber, dateOfBirth: e.target.value } })}
/>
{validationErrors.dob && <p className="text-red-500 text-xs">{validationErrors.dob}</p>}
<input
type="text"
placeholder="Member ID"
className="w-full border border-gray-300 rounded-md p-2 text-sm"
value={form.subscriber.memberId}
onChange={(e) => setForm({ ...form, subscriber: { ...form.subscriber, memberId: e.target.value } })}
/>
{validationErrors.memberId && <p className="text-red-500 text-xs">{validationErrors.memberId}</p>}
</fieldset>
<fieldset className="space-y-2">
<legend className="text-sm font-semibold text-gray-800">Encounter</legend>
<input
type="text"
placeholder="Payer ID"
className="w-full border border-gray-300 rounded-md p-2 text-sm"
value={form.tradingPartnerServiceId}
onChange={(e) => setForm({ ...form, tradingPartnerServiceId: e.target.value })}
/>
{validationErrors.payerId && <p className="text-red-500 text-xs">{validationErrors.payerId}</p>}
<input
type="text"
placeholder="Service Type Code (default: 30)"
className="w-full border border-gray-300 rounded-md p-2 text-sm"
value={form.encounter.serviceTypeCodes[0]}
onChange={(e) => setForm({ ...form, encounter: { serviceTypeCodes: [e.target.value] } })}
/>
</fieldset>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white rounded-md p-2 text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Checking..." : "Verify Eligibility"}
</button>
</form>
</div>
{/* History Sidebar */}
{history.length > 0 && (
<div className="bg-white rounded-lg shadow p-4 mt-4">
<h3 className="text-sm font-semibold text-gray-800 mb-2">Session History</h3>
<ul className="space-y-2">
{history.map((entry, i) => (
<li
key={i}
className="text-xs p-2 rounded cursor-pointer hover:bg-gray-100"
onClick={() => {
if (entry.response) {
setResponse(entry.response);
setError(null);
} else if (entry.errorMessage) {
setError(entry.errorMessage);
setResponse(null);
}
}}
>
<span className={`inline-block w-2 h-2 rounded-full mr-2 ${entry.status === "success" ? "bg-green-500" : "bg-red-500"}`}></span>
<span className="font-medium">{entry.patientName}</span>
<span className="text-gray-500 ml-2">{entry.timestamp}</span>
</li>
))}
</ul>
</div>
)}
</div>
{/* Response Column */}
<div className="lg:col-span-2">
<div className="bg-white rounded-lg shadow p-6 min-h-[400px]">
{!response && !error && !loading && (
<p className="text-gray-500 text-sm">Select a mock request and click Verify Eligibility to see results.</p>
)}
{loading && (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<h3 className="text-red-800 font-medium text-sm">Error</h3>
<p className="text-red-700 text-sm mt-1">{error}</p>
<button
onClick={handleSubmit as () => void}
className="mt-2 text-sm text-red-600 underline"
>
Retry
</button>
</div>
)}
{response && (
<div>
<div className="flex gap-2 mb-4">
<button
className={`px-3 py-1 text-sm rounded ${!showRaw ? "bg-blue-600 text-white" : "bg-gray-200 text-gray-700"}`}
onClick={() => setShowRaw(false)}
>
Formatted
</button>
<button
className={`px-3 py-1 text-sm rounded ${showRaw ? "bg-blue-600 text-white" : "bg-gray-200 text-gray-700"}`}
onClick={() => setShowRaw(true)}
>
Raw JSON
</button>
</div>
{showRaw ? (
<pre className="bg-gray-900 text-green-400 p-4 rounded-md text-xs overflow-auto max-h-[600px]">
{JSON.stringify(response, null, 2)}
</pre>
) : hasAAAErrors ? (
<div className="space-y-3">
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<h3 className="text-red-800 font-semibold text-sm">Eligibility Check Rejected</h3>
{aaaErrors.map((err, i) => (
<div key={i} className="mt-3 border-t border-red-100 pt-3">
<p className="text-sm"><span className="font-medium">Code:</span> {err.code}</p>
<p className="text-sm"><span className="font-medium">Description:</span> {err.description}</p>
{err.followupAction && <p className="text-sm"><span className="font-medium">Action:</span> {err.followupAction}</p>}
{err.possibleResolutions && <p className="text-sm text-gray-600 mt-1">{err.possibleResolutions}</p>}
</div>
))}
</div>
</div>
) : (
<div className="space-y-4">
{/* Coverage Status */}
<div className="flex items-center gap-3">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getCoverageStatus() === "Active" ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"}`}>
{getCoverageStatus()}
</span>
<span className="text-sm text-gray-600">{getPlanName()}</span>
</div>
{/* Patient Info */}
<div className="grid grid-cols-2 gap-4 bg-gray-50 rounded-md p-3">
<div>
<p className="text-xs text-gray-500">Subscriber</p>
<p className="text-sm font-medium">{response.subscriber?.firstName} {response.subscriber?.lastName}</p>
</div>
<div>
<p className="text-xs text-gray-500">Member ID</p>
<p className="text-sm font-medium">{response.subscriber?.memberId || "N/A"}</p>
</div>
<div>
<p className="text-xs text-gray-500">Plan Dates</p>
<p className="text-sm">{formatDate(response.planDateInformation?.planBegin)} - {formatDate(response.planDateInformation?.planEnd)}</p>
</div>
<div>
<p className="text-xs text-gray-500">Payer</p>
<p className="text-sm">{response.payer?.name || "N/A"}</p>
</div>
</div>
{/* In-Network Benefits */}
<div>
<h4 className="text-sm font-semibold text-gray-800 mb-2">In-Network Benefits</h4>
<div className="grid grid-cols-2 gap-3">
{getBenefitsByType("C", "Y").filter(b => b.timeQualifierCode === "23").map((b, i) => (
<div key={i} className="bg-blue-50 rounded p-2">
<p className="text-xs text-blue-600">Deductible ({b.coverageLevel})</p>
<p className="text-sm font-semibold">${b.benefitAmount}</p>
</div>
))}
{getBenefitsByType("G", "Y").filter(b => b.timeQualifierCode === "23").map((b, i) => (
<div key={i} className="bg-purple-50 rounded p-2">
<p className="text-xs text-purple-600">Out of Pocket Max ({b.coverageLevel})</p>
<p className="text-sm font-semibold">${b.benefitAmount}</p>
</div>
))}
{getBenefitsByType("A", "Y").filter(b => b.serviceTypeCodes?.includes("30")).slice(0, 2).map((b, i) => (
<div key={i} className="bg-green-50 rounded p-2">
<p className="text-xs text-green-600">Coinsurance ({b.coverageLevel})</p>
<p className="text-sm font-semibold">{Number(b.benefitPercent) * 100}%</p>
</div>
))}
</div>
</div>
{/* Remaining Benefits */}
{getBenefitsByType("C", "Y").filter(b => b.timeQualifierCode === "29").length > 0 && (
<div>
<h4 className="text-sm font-semibold text-gray-800 mb-2">Remaining (Calendar Year)</h4>
<div className="grid grid-cols-2 gap-3">
{getBenefitsByType("C", "Y").filter(b => b.timeQualifierCode === "29").map((b, i) => (
<div key={i} className="bg-yellow-50 rounded p-2">
<p className="text-xs text-yellow-700">Deductible Remaining ({b.coverageLevel})</p>
<p className="text-sm font-semibold">${b.benefitAmount}</p>
</div>
))}
{getBenefitsByType("G", "Y").filter(b => b.timeQualifierCode === "29").map((b, i) => (
<div key={i} className="bg-yellow-50 rounded p-2">
<p className="text-xs text-yellow-700">OOP Remaining ({b.coverageLevel})</p>
<p className="text-sm font-semibold">${b.benefitAmount}</p>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
</main>
);
}
Your eligibility checker now handles every realistic failure mode a front-desk worker might encounter. AAA rejections display actionable payer feedback. Network errors show human-readable messages with a retry option. And the session history gives staff a quick audit trail without storing PHI. Your app is production-ready.
Secret mission
Build the Open Dental Integration Layer
Your eligibility checker works great with mock data. But in a real dental office, patient details live in Open Dental's practice management system. Build a service abstraction layer that maps Open Dental patient records to Stedi eligibility requests, so your app can pull patient data directly from the source.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally with a free Stedi sandbox account. There are no ongoing costs to worry about.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- eligibility-app/ project folder on your Desktop.
- Dev server running at http://localhost:3000.
Keep everything running
No action needed. Choose this if you're still actively building or plan to extend this project further.
- Your Stedi sandbox account stays free indefinitely. Test mode checks incur no charges.
- The dev server keeps running at http://localhost:3000 until you close the terminal.
- Your .env.local file keeps your API key out of version control by default (Next.js adds it to .gitignore automatically).
- When you're ready to move to production, upgrade your Stedi account (starts at $100 prepaid balance, $0.30 per check). Apply for an Open Dental Developer API Key at vendor.relations@opendental.com. Sign BAAs with both Stedi and Open Dental before any real PHI flows.
Pause - I'll come back to this later
Stop the dev server to free up your terminal and port. Your code stays on disk ready to resume.
- In the terminal where your dev server is running, press Ctrl+C to stop the process.
- Your eligibility-app/ folder and all code remain on your Desktop.
- To resume later, open a terminal in the eligibility-app/ folder and run npm run dev.
- Your Stedi sandbox account stays free. No action needed there.
Delete - I don't want to use this again
Remove all project files from your machine. Your Stedi sandbox account remains free and incurs no charges even if left active.
- In the terminal where your dev server is running, press Ctrl+C to stop the process.
- Delete the project folder by running this command on macOS/Linux:
cd ~/Desktop
rm -rf eligibility-app
- On Windows, run this instead:
cd ~\Desktop
rmdir /s /q eligibility-app
- Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows) in your Desktop directory. You should no longer see eligibility-app listed.
Nice Work!
Nice Work!
You just built a real-time insurance eligibility verification app that calls Stedi's JSON Eligibility API and displays formatted coverage results.
You've learned how to:
- Build a server-side API proxy using Next.js App Router route handlers that keeps credentials out of the browser.
- Parse complex 271 eligibility responses into a readable format showing coverage status, copays, deductibles, and out-of-pocket maximums.
- Handle AAA error codes with user-friendly error displays and recovery guidance.
- Secret Mission: Created an Open Dental service abstraction layer with field mapping, ready to connect to your dental office's Remote API.
Ready to quiz yourself?