In my previous article, I introduced FHIR (Fast Healthcare Interoperability Resources)—the modern, API-first standard that is transforming how developers interact with healthcare data. FHIR is not just another healthcare protocol; it is a REST-driven ecosystem that uses JSON, structured resources, and predictable endpoints.
This modern approach allows developers to build healthcare applications using familiar web technologies while maintaining strict healthcare data standards.
Today, we’ll take a practical deep dive into FHIR by building a real-world project: a FHIR-enabled patient portal.
We’ll use:
- Next.js 16 (App Router)
- TypeScript
- HAPI FHIR Public Test Server (R4)
- REST-based FHIR APIs
- FHIR Patient & Practitioner resources
By the end of this tutorial, you will be able to:
- Understand FHIR resource structures
- Call real FHIR APIs
- Search, paginate, and view Patient and Practitioner data
- Build a reusable service layer
- Display structured healthcare data using modern web UI
- Work with FHIR Bundles, metadata, and search parameters
🔗 GitHub Repository (Full Project Source Code)
You can explore or clone the complete project here:
https://github.com/PaulBoye-py/fhir-tutorials
1. What We’re Building
We will build a simple yet realistic patient portal with:
- Patient Search
- Patient List Page
- Patient Detail Page
- Practitioner Search
- Practitioner Detail Page
- Pagination via FHIR
_countand_offset - Clean service layer with reusable API logic
- Structured healthcare UI
This structure mirrors real-world EHR interfaces and is an ideal foundation for:
- Telemedicine apps
- EHR integration layers
- Health dashboards
- Clinical note systems
- Medical analytics apps
2. Understanding FHIR Resources
2.1 What Is a FHIR Resource?
A FHIR Resource is the core building block of the FHIR standard.
Every piece of healthcare information—patients, practitioners, encounters, medications, observations—is represented as a resource.
Characteristics of FHIR resources:
- Modular
- JSON or XML format
- REST-accessible
- Self-describing
- Extensible
- Standardized
Almost all resources share:
{
"resourceType": "Patient",
"id": "123",
"meta": { ... }
}
FHIR is RESTful:
GET /Patient/123
POST /Patient
PUT /Patient/123
DELETE /Patient/123
Search is simple:
/Patient?name=John
/Patient?phone=08012345678
/Practitioner?identifier=12345
2.2 Understanding the FHIR Patient Resource
FHIR defines the Patient resource as:
“Demographics and administrative information about an individual receiving care.”
Key fields:
| Field | Description |
|---|---|
identifier |
MRN, NIN, hospital numbers |
active |
Indicates if record is active |
name |
Array of names (given, family, prefixes) |
telecom |
Phones and emails |
gender |
male, female, other, unknown |
birthDate |
Date of birth |
address |
Multiple structured addresses |
contact |
Next of kin / guardian |
photo |
Patient picture |
managingOrganization |
Linked facility |
Why arrays?
Healthcare data is complex. A patient can have:
- Multiple names (maiden name, legal name)
- Multiple phones (home, work, mobile)
- Multiple addresses
Patient JSON Example
{
"resourceType": "Patient",
"id": "123",
"active": true,
"name": [
{
"family": "Doe",
"given": ["John"],
"use": "official"
}
],
"telecom": [
{
"system": "phone",
"value": "08012345678"
}
],
"gender": "male",
"birthDate": "1990-01-01"
}
2.3 Understanding the FHIR Practitioner Resource
A Practitioner represents the person providing care.
Key fields:
-
identifier— license number, certifications -
name— same structure as Patient telecomaddress-
qualification— degrees, certifications active
Practitioner vs PractitionerRole
| Resource | Meaning |
|---|---|
| Practitioner | The actual person (identity, demographics) |
| PractitionerRole | Their job role, specialty, and organization |
A practitioner can have many roles — e.g., Consultant Surgeon at Hospital A, Visiting Specialist at Clinic B.
3. Setting Up the Next.js Project
Run:
npx create-next-app@latest fhir-patient-portal
cd fhir-patient-portal
npm install axios
Create .env.local:
NEXT_PUBLIC_FHIR_BASE_URL=https://hapi.fhir.org/baseR4
4. Building the FHIR Service Layer
We separate API logic from UI logic.
Create:
services/
patientService.ts
practitionerService.ts
4.1 Patient Service
import axios from 'axios';
const baseUrl = `${process.env.NEXT_PUBLIC_FHIR_BASE_URL}/Patient`;
const fhirApi = axios.create({ baseURL: baseUrl, headers: {
'Cache-Control' : 'no-cache',
} });
const isPhoneNumber = (searchTerm: string) => {
if (parseInt(searchTerm)) {
return true
}
return false
}
const getAll = async (page: number, searchTerm: string | undefined) => {
let searchParams: {
phone?: string | undefined,
name?: string | undefined,
} = {};
if (searchTerm) {
if (isPhoneNumber(searchTerm)) {
searchParams.phone = searchTerm;
} else {
searchParams.name = searchTerm;
}
}
try {
// First request: Get total count
const countResponse = await fhirApi.get('', {
params: {
_summary: 'count',
...searchParams,
}
});
const totalCount = countResponse.data?.total || 0;
// Second request: Get actual patient data
const dataResponse = await fhirApi.get('', {
params: {
_count: 15,
_offset: (page - 1) * 15, // Convert 1-based page to 0-based offset
...searchParams,
}
});
if (dataResponse.headers['content-type']?.includes('application/fhir+json')) {
console.log('FHIR Response:', dataResponse.data);
console.log('Total Count:', totalCount);
// Merge the total count into the data response
return {
...dataResponse.data,
total: totalCount,
};
} else {
console.error('Unexpected response format:', dataResponse.data);
return null;
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('Error fetching patients:', message);
throw error;
}
};
const getPatient = async (id : any) => {
try {
const response = await fhirApi.get(`/${id}`);
return response.data; // return the patient data from the response
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('Error fetching patient:', message);
}
};
const updatePatient = async (id: any, updatedPatient: object) => {
try {
const response = await fhirApi.put(`/${id}`, updatedPatient);
return response.data; // Return the updated patient data
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('Error updating patient:', message);
throw error;
}
};
const createNew = async (patientData: object) => {
try {
const response = await fhirApi.post('', patientData);
return response.data; // Return the created patient data
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('Error creating patient:', message);
throw error;
}
};
// Correct export statement
const patientService = {
getAll,
getPatient,
updatePatient,
createNew,
};
export default patientService;
4.2 Practitioner Service
import axios from 'axios';
import { Practitioner, Bundle } from '@/types/fhir';
const baseUrl = `${process.env.NEXT_PUBLIC_FHIR_BASE_URL}/Practitioner`;
const fhirApi = axios.create({
baseURL: baseUrl,
headers: {
'Cache-Control': 'no-cache',
'Accept': 'application/fhir+json',
},
});
const isPhoneNumber = (searchTerm: string) => {
return /^\d+$/.test(searchTerm);
};
const getAll = async (page: number, searchTerm: string | undefined) => {
let searchParams: {
phone?: string | undefined,
name?: string | undefined,
} = {};
if (searchTerm) {
if (isPhoneNumber(searchTerm)) {
searchParams.phone = searchTerm;
} else {
searchParams.name = searchTerm;
}
}
try {
// First request: Get total count
const countResponse = await fhirApi.get('', {
params: {
_summary: 'count',
...searchParams,
}
});
const totalCount = countResponse.data?.total || 0;
// Second request: Get actual patient data
const dataResponse = await fhirApi.get('', {
params: {
_count: 15,
_offset: (page - 1) * 15, // Convert 1-based page to 0-based offset
...searchParams,
}
});
if (dataResponse.headers['content-type']?.includes('application/fhir+json')) {
console.log('FHIR Response:', dataResponse.data);
console.log('Total Count:', totalCount);
// Merge the total count into the data response
return {
...dataResponse.data,
total: totalCount,
};
} else {
console.error('Unexpected response format:', dataResponse.data);
return null;
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('Error fetching practitioners:', message);
throw error;
}
};
const getPractitioner = async (id: string): Promise<Practitioner | null> => {
try {
const response = await fhirApi.get<Practitioner>(`/${id}`);
return response.data;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('Error fetching practitioner:', message);
throw error;
}
};
const updatePractitioner = async (id: string, updatedPractitioner: Practitioner): Promise<Practitioner | null> => {
try {
const response = await fhirApi.put<Practitioner>(`/${id}`, updatedPractitioner);
return response.data;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('Error updating practitioner:', message);
throw error;
}
};
const createNew = async (practitionerData: Practitioner): Promise<Practitioner | null> => {
try {
const response = await fhirApi.post<Practitioner>('', practitionerData);
return response.data;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('Error creating practitioner:', message);
throw error;
}
};
const practitionerService = {
getAll,
getPractitioner,
updatePractitioner,
createNew,
};
export default practitionerService;
5. Building the Patient Search Page
File: app/patients/page.tsx
This page:
- Accepts a search input
- Displays results
- Uses pagination
- Links to patient detail pages
Snippet:
{data?.entry?.map((e) => {
const p = e.resource;
return (
<Link href={`/patients/${p.id}`} key={p.id}>
<div>
{p.name?.[0]?.given?.join(" ")} {p.name?.[0]?.family}
</div>
</Link>
);
})}
6. Patient Detail Page
File: app/patients/[id]/page.tsx
Shows:
- Name
- Gender
- Birth date
- Contact
- Identifiers
- Addresses
{/* Demographics Section */}
<div className="bg-white rounded-lg border border-gray-200 p-4 md:p-6 shadow-sm">
<h2 className="text-lg md:text-xl font-semibold mb-4 pb-2 border-b border-gray-200 text-gray-900">
Demographics
</h2>
<div className="grid sm:grid-cols-2 gap-4">
<div>
<div className="text-sm font-medium text-gray-500 mb-1">Gender</div>
<div className="text-gray-900 capitalize">{patient.gender || 'Unknown'}</div>
</div>
<div>
<div className="text-sm font-medium text-gray-500 mb-1">Birth Date</div>
<div className="text-gray-900">{formatDate(patient.birthDate)}</div>
</div>
{patient.maritalStatus && (
<div>
<div className="text-sm font-medium text-gray-500 mb-1">Marital Status</div>
<div className="text-gray-900">
{patient.maritalStatus.text || patient.maritalStatus.coding?.[0]?.display || 'N/A'}
</div>
</div>
)}
</div>
</div>
7. Practitioner Search Page
Same structure as patient search but with additional fields:
- Qualifications
- Specialties
- Practice locations
8. Practitioner Detail Page
Display:
- Full name
- Contact
- Gender
- Qualifications
- Practice addresses
Example FHIR qualification:
{
"qualification": [
{
"code": { "text": "MBBS" },
"period": { "start": "2012-01-01" }
}
]
}
9. Layout & Navigation
Add a simple navigation bar:
<nav className="p-4 border-b flex gap-4">
<Link href="/patients">Patients</Link>
<Link href="/practitioners">Practitioners</Link>
</nav>
10. Understanding FHIR Bundles
FHIR search returns a Bundle:
{
"resourceType": "Bundle",
"type": "searchset",
"total": 150,
"entry": [...]
}
Fields:
| Field | Meaning |
|---|---|
total |
Total matches |
entry |
Actual resources |
link |
Pagination links |
11. Testing With HAPI FHIR Server
Examples:
/Patient?_count=10
/Patient?name=smith
/Practitioner?phone=0803...
Limitations:
- Data resets
- Some resources incomplete
- Occasional slowness
12. Best Practices
- Validate
resourceType - Handle optional fields
- Use TypeScript models
- Cache data when possible
- You can use SMART on FHIR to add a layer of authentication
- Avoid excessive API calls. You'll get rate-limited if this happens.
Useful Links
- FHIR R4 Spec → https://build.fhir.org
- HAPI FHIR → https://hapi.fhir.org
- Full Project Source Code: https://github.com/PaulBoye-py/fhir-tutorials
- Live Deployment: https://fhir-tutorials.vercel.app
13. Conclusion
In this article, we built a functional patient portal powered by FHIR and Next.js—with real-time access to Patient and Practitioner data.
You learned:
- How FHIR structures healthcare data
- How to build reusable services in Next.js
- How to fetch and display Patient and Practitioner data
- How to handle FHIR Bundles and pagination
- How to implement dynamic routes and detail pages
FHIR is a powerful standard, and this project is just the beginning.
You can easily expand this into a complete EHR frontend or a telemedicine dashboard.
If you build something with this tutorial, I’d love to see it—tag me here and on LinkedIn or share your implementation!



Top comments (0)