DEV Community

Cover image for Building a FHIR-Enabled Patient Portal with Next.js 16 (Step-by-Step Guide)
Paul Aderoju
Paul Aderoju

Posted on

Building a FHIR-Enabled Patient Portal with Next.js 16 (Step-by-Step Guide)

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 _count and _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": { ... }
}
Enter fullscreen mode Exit fullscreen mode

FHIR is RESTful:

GET /Patient/123
POST /Patient
PUT /Patient/123
DELETE /Patient/123
Enter fullscreen mode Exit fullscreen mode

Search is simple:

/Patient?name=John
/Patient?phone=08012345678
/Practitioner?identifier=12345
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
  • telecom
  • address
  • 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
Enter fullscreen mode Exit fullscreen mode

Create .env.local:

NEXT_PUBLIC_FHIR_BASE_URL=https://hapi.fhir.org/baseR4
Enter fullscreen mode Exit fullscreen mode

4. Building the FHIR Service Layer

We separate API logic from UI logic.

Create:

services/
  patientService.ts
  practitionerService.ts
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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

Patient Search Page

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>
  );
})}
Enter fullscreen mode Exit fullscreen mode

6. Patient Detail Page

File: app/patients/[id]/page.tsx

Shows:

  • Name
  • Gender
  • Birth date
  • Contact
  • Identifiers
  • Addresses

Patient Detail Page

{/* 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>
Enter fullscreen mode Exit fullscreen mode

7. Practitioner Search Page

Same structure as patient search but with additional fields:

  • Qualifications
  • Specialties
  • Practice locations

Practitioner Search Page

8. Practitioner Detail Page

Display:

  • Full name
  • Contact
  • Gender
  • Qualifications
  • Practice addresses

Example FHIR qualification:

{
  "qualification": [
    {
      "code": { "text": "MBBS" },
      "period": { "start": "2012-01-01" }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Practitioner Detail Page


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>
Enter fullscreen mode Exit fullscreen mode

10. Understanding FHIR Bundles

FHIR search returns a Bundle:

{
  "resourceType": "Bundle",
  "type": "searchset",
  "total": 150,
  "entry": [...]
}
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

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


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)