Back to Articles

FHIR Integration Guide: Building Interoperable Healthcare Applications

Fast Healthcare Interoperability Resources (FHIR, pronounced "fire") has become the dominant standard for healthcare data exchange. Developed by HL7 International, FHIR combines the best aspects of previous standards with modern web technologies, making it accessible to developers who may not have deep healthcare IT backgrounds.

This guide provides a comprehensive introduction to FHIR R4 (the current release), covering core concepts, RESTful API patterns, SMART on FHIR authentication, and practical implementation examples you can use in your healthcare applications.

Understanding FHIR Fundamentals

FHIR represents healthcare data as "Resources"—discrete units of clinical or administrative information. Unlike older standards that defined monolithic message formats, FHIR's modular approach allows applications to work with exactly the data they need.

Core FHIR Resources

FHIR defines over 140 resource types, but most applications work with a core set:

  • Patient: Demographics and administrative information about individuals receiving care
  • Practitioner: Healthcare providers (doctors, nurses, therapists)
  • Organization: Healthcare facilities, hospitals, clinics
  • Encounter: Interactions between patient and healthcare system
  • Condition: Clinical conditions, diagnoses, problems
  • Observation: Measurements, test results, vital signs
  • MedicationRequest: Prescriptions and medication orders
  • DiagnosticReport: Lab results, imaging reports
  • AllergyIntolerance: Patient allergies and adverse reactions
  • Immunization: Vaccination records

FHIR Resource Structure

Every FHIR resource shares a common structure. Here's an example Patient resource:

{
  "resourceType": "Patient",
  "id": "example-patient-123",
  "meta": {
    "versionId": "1",
    "lastUpdated": "2026-01-15T10:30:00Z"
  },
  "identifier": [
    {
      "system": "http://hospital.example.org/mrn",
      "value": "MRN12345678"
    },
    {
      "system": "http://hl7.org/fhir/sid/us-ssn",
      "value": "123-45-6789"
    }
  ],
  "active": true,
  "name": [
    {
      "use": "official",
      "family": "Smith",
      "given": ["John", "Robert"],
      "prefix": ["Mr."]
    }
  ],
  "telecom": [
    {
      "system": "phone",
      "value": "(555) 123-4567",
      "use": "home"
    },
    {
      "system": "email",
      "value": "[email protected]"
    }
  ],
  "gender": "male",
  "birthDate": "1970-05-15",
  "address": [
    {
      "use": "home",
      "line": ["123 Main Street", "Apt 4B"],
      "city": "Boston",
      "state": "MA",
      "postalCode": "02101",
      "country": "USA"
    }
  ]
}
Key Insight

Notice how FHIR uses arrays for fields that might have multiple values (identifiers, names, addresses). This flexibility accommodates real-world healthcare scenarios where patients have multiple phone numbers, previous names, or identifiers from different systems.

FHIR RESTful API Patterns

FHIR servers expose resources through a RESTful API. If you're familiar with REST, FHIR will feel natural. Here are the core operations:

CRUD Operations

// TypeScript FHIR Client Examples

// Read a specific patient
async function getPatient(patientId: string): Promise<Patient> {
  const response = await fetch(
    `${FHIR_BASE_URL}/Patient/${patientId}`,
    {
      headers: {
        'Accept': 'application/fhir+json',
        'Authorization': `Bearer ${accessToken}`
      }
    }
  );
  
  if (!response.ok) {
    throw new FHIRError(response.status, await response.json());
  }
  
  return response.json();
}

// Search for patients
async function searchPatients(params: PatientSearchParams): Promise<Bundle> {
  const queryString = new URLSearchParams({
    family: params.lastName,
    given: params.firstName,
    birthdate: params.birthDate,
    _count: '20'
  }).toString();
  
  const response = await fetch(
    `${FHIR_BASE_URL}/Patient?${queryString}`,
    {
      headers: {
        'Accept': 'application/fhir+json',
        'Authorization': `Bearer ${accessToken}`
      }
    }
  );
  
  return response.json();
}

// Create a new patient
async function createPatient(patient: Patient): Promise<Patient> {
  const response = await fetch(
    `${FHIR_BASE_URL}/Patient`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/fhir+json',
        'Accept': 'application/fhir+json',
        'Authorization': `Bearer ${accessToken}`
      },
      body: JSON.stringify(patient)
    }
  );
  
  if (response.status !== 201) {
    throw new FHIRError(response.status, await response.json());
  }
  
  return response.json();
}

// Update a patient (full replacement)
async function updatePatient(patient: Patient): Promise<Patient> {
  const response = await fetch(
    `${FHIR_BASE_URL}/Patient/${patient.id}`,
    {
      method: 'PUT',
      headers: {
        'Content-Type': 'application/fhir+json',
        'Accept': 'application/fhir+json',
        'Authorization': `Bearer ${accessToken}`
      },
      body: JSON.stringify(patient)
    }
  );
  
  return response.json();
}

FHIR Search Parameters

FHIR supports sophisticated search capabilities. Here are common search patterns:

// Common FHIR search examples

// Search by multiple criteria
// GET /Patient?family=Smith&given=John&birthdate=1970-05-15

// Date range searches
// GET /Observation?date=ge2026-01-01&date=le2026-01-31

// Reference searches (find observations for a patient)
// GET /Observation?patient=Patient/123

// Include related resources
// GET /MedicationRequest?patient=Patient/123&_include=MedicationRequest:medication

// Chained searches (find observations for patients named "Smith")
// GET /Observation?patient.family=Smith

// Reverse includes (get patient with all their conditions)
// GET /Patient/123?_revinclude=Condition:patient

// Token searches (by code)
// GET /Observation?code=http://loinc.org|8867-4

// Pagination
// GET /Patient?_count=20&_offset=40

Building a FHIR Client

// Comprehensive FHIR Client Class
class FHIRClient {
  private baseUrl: string;
  private accessToken: string;

  constructor(baseUrl: string, accessToken: string) {
    this.baseUrl = baseUrl.replace(/\/$/, '');
    this.accessToken = accessToken;
  }

  private async request<T>(
    method: string,
    path: string,
    body?: unknown
  ): Promise<T> {
    const response = await fetch(`${this.baseUrl}${path}`, {
      method,
      headers: {
        'Content-Type': 'application/fhir+json',
        'Accept': 'application/fhir+json',
        'Authorization': `Bearer ${this.accessToken}`
      },
      body: body ? JSON.stringify(body) : undefined
    });

    if (!response.ok) {
      const error = await response.json();
      throw new FHIROperationError(response.status, error);
    }

    return response.json();
  }

  // Generic resource operations
  async read<T extends Resource>(
    resourceType: string, 
    id: string
  ): Promise<T> {
    return this.request('GET', `/${resourceType}/${id}`);
  }

  async search<T extends Resource>(
    resourceType: string,
    params: Record<string, string>
  ): Promise<Bundle<T>> {
    const query = new URLSearchParams(params).toString();
    return this.request('GET', `/${resourceType}?${query}`);
  }

  async create<T extends Resource>(resource: T): Promise<T> {
    return this.request('POST', `/${resource.resourceType}`, resource);
  }

  async update<T extends Resource>(resource: T): Promise<T> {
    return this.request(
      'PUT', 
      `/${resource.resourceType}/${resource.id}`, 
      resource
    );
  }

  // Convenience methods for common resources
  async getPatient(id: string): Promise<Patient> {
    return this.read('Patient', id);
  }

  async getPatientObservations(
    patientId: string,
    category?: string
  ): Promise<Bundle<Observation>> {
    const params: Record<string, string> = {
      patient: `Patient/${patientId}`,
      _sort: '-date',
      _count: '50'
    };
    
    if (category) {
      params.category = category;
    }
    
    return this.search('Observation', params);
  }

  async getPatientMedications(
    patientId: string
  ): Promise<Bundle<MedicationRequest>> {
    return this.search('MedicationRequest', {
      patient: `Patient/${patientId}`,
      status: 'active',
      _include: 'MedicationRequest:medication'
    });
  }
}

SMART on FHIR Authentication

SMART on FHIR is the standard for app authorization in healthcare. It extends OAuth 2.0 with healthcare-specific scopes and launch contexts.

SMART App Launch Flow

// SMART on FHIR OAuth Implementation

interface SMARTConfig {
  clientId: string;
  redirectUri: string;
  scope: string;
  iss: string;  // FHIR server URL
}

class SMARTAuthClient {
  private config: SMARTConfig;
  private endpoints: {
    authorize: string;
    token: string;
  } | null = null;

  constructor(config: SMARTConfig) {
    this.config = config;
  }

  // Step 1: Discover OAuth endpoints from FHIR server
  async discoverEndpoints(): Promise<void> {
    const metadataUrl = `${this.config.iss}/.well-known/smart-configuration`;
    
    try {
      const response = await fetch(metadataUrl);
      const metadata = await response.json();
      
      this.endpoints = {
        authorize: metadata.authorization_endpoint,
        token: metadata.token_endpoint
      };
    } catch {
      // Fallback to capability statement
      const capabilityUrl = `${this.config.iss}/metadata`;
      const response = await fetch(capabilityUrl);
      const capability = await response.json();
      
      const security = capability.rest[0].security;
      const oauth = security.extension.find(
        (e: any) => e.url === 'http://fhir-registry.smarthealthit.org/StructureDefinition/oauth-uris'
      );
      
      this.endpoints = {
        authorize: oauth.extension.find((e: any) => e.url === 'authorize').valueUri,
        token: oauth.extension.find((e: any) => e.url === 'token').valueUri
      };
    }
  }

  // Step 2: Redirect to authorization server
  initiateAuth(launchContext?: string): void {
    if (!this.endpoints) {
      throw new Error('Endpoints not discovered. Call discoverEndpoints() first.');
    }

    const state = this.generateState();
    sessionStorage.setItem('smart_state', state);

    const params = new URLSearchParams({
      response_type: 'code',
      client_id: this.config.clientId,
      redirect_uri: this.config.redirectUri,
      scope: this.config.scope,
      state,
      aud: this.config.iss
    });

    if (launchContext) {
      params.set('launch', launchContext);
    }

    window.location.href = `${this.endpoints.authorize}?${params}`;
  }

  // Step 3: Exchange authorization code for tokens
  async handleCallback(code: string, state: string): Promise<TokenResponse> {
    const savedState = sessionStorage.getItem('smart_state');
    
    if (state !== savedState) {
      throw new Error('State mismatch - possible CSRF attack');
    }

    if (!this.endpoints) {
      await this.discoverEndpoints();
    }

    const response = await fetch(this.endpoints!.token, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
      },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        code,
        redirect_uri: this.config.redirectUri,
        client_id: this.config.clientId
      })
    });

    if (!response.ok) {
      throw new Error('Token exchange failed');
    }

    const tokens = await response.json();
    
    // Store tokens securely
    this.storeTokens(tokens);
    
    return tokens;
  }

  private generateState(): string {
    const array = new Uint8Array(32);
    crypto.getRandomValues(array);
    return Array.from(array, b => b.toString(16).padStart(2, '0')).join('');
  }

  private storeTokens(tokens: TokenResponse): void {
    // Store in sessionStorage for single-session apps
    sessionStorage.setItem('smart_access_token', tokens.access_token);
    sessionStorage.setItem('smart_patient', tokens.patient || '');
    
    if (tokens.refresh_token) {
      sessionStorage.setItem('smart_refresh_token', tokens.refresh_token);
    }
  }
}

// SMART Scopes for healthcare apps
const SMART_SCOPES = {
  // Patient-level access
  patientRead: 'patient/*.read',
  patientWrite: 'patient/*.write',
  
  // Specific resource access
  patientObservationRead: 'patient/Observation.read',
  patientMedicationRead: 'patient/MedicationRequest.read',
  patientConditionRead: 'patient/Condition.read',
  
  // User-level access (clinician apps)
  userPatientRead: 'user/Patient.read',
  userPatientWrite: 'user/Patient.write',
  
  // Launch context
  launchPatient: 'launch/patient',
  launchEncounter: 'launch/encounter',
  
  // Identity
  openid: 'openid',
  fhirUser: 'fhirUser',
  profile: 'profile'
};
Security Consideration

SMART on FHIR tokens provide access to sensitive patient data. Always use HTTPS, validate state parameters to prevent CSRF, and store tokens securely. Never expose tokens in URLs or client-side logs.

Working with FHIR Bundles

FHIR uses Bundles to group multiple resources together—for search results, transactions, or document collections:

// Processing FHIR search result bundles
interface Bundle<T extends Resource> {
  resourceType: 'Bundle';
  type: 'searchset' | 'transaction' | 'document' | 'collection';
  total?: number;
  link?: Array<{
    relation: 'self' | 'next' | 'previous';
    url: string;
  }>;
  entry?: Array<{
    fullUrl?: string;
    resource: T;
    search?: {
      mode: 'match' | 'include';
    };
  }>;
}

// Utility functions for bundle processing
function extractResources<T extends Resource>(bundle: Bundle<T>): T[] {
  return bundle.entry
    ?.filter(entry => entry.search?.mode !== 'include')
    .map(entry => entry.resource) || [];
}

function getNextPageUrl(bundle: Bundle<any>): string | null {
  return bundle.link?.find(l => l.relation === 'next')?.url || null;
}

// Paginated fetching
async function* fetchAllPages<T extends Resource>(
  client: FHIRClient,
  initialBundle: Bundle<T>
): AsyncGenerator<T[]> {
  let bundle = initialBundle;
  
  while (true) {
    yield extractResources(bundle);
    
    const nextUrl = getNextPageUrl(bundle);
    if (!nextUrl) break;
    
    bundle = await client.request('GET', nextUrl);
  }
}

// Usage
async function getAllPatientObservations(
  client: FHIRClient,
  patientId: string
): Promise<Observation[]> {
  const initialBundle = await client.getPatientObservations(patientId);
  const allObservations: Observation[] = [];
  
  for await (const page of fetchAllPages(client, initialBundle)) {
    allObservations.push(...page);
  }
  
  return allObservations;
}

FHIR Implementation Patterns

Patient Summary View

// Comprehensive patient summary using FHIR
async function buildPatientSummary(
  client: FHIRClient,
  patientId: string
): Promise<PatientSummary> {
  // Fetch all needed resources in parallel
  const [
    patient,
    conditions,
    medications,
    allergies,
    vitals,
    immunizations
  ] = await Promise.all([
    client.getPatient(patientId),
    client.search('Condition', {
      patient: `Patient/${patientId}`,
      'clinical-status': 'active'
    }),
    client.search('MedicationRequest', {
      patient: `Patient/${patientId}`,
      status: 'active'
    }),
    client.search('AllergyIntolerance', {
      patient: `Patient/${patientId}`,
      'clinical-status': 'active'
    }),
    client.search('Observation', {
      patient: `Patient/${patientId}`,
      category: 'vital-signs',
      _sort: '-date',
      _count: '10'
    }),
    client.search('Immunization', {
      patient: `Patient/${patientId}`,
      status: 'completed'
    })
  ]);

  return {
    demographics: extractDemographics(patient),
    activeConditions: extractResources(conditions),
    currentMedications: extractResources(medications),
    allergies: extractResources(allergies),
    recentVitals: extractResources(vitals),
    immunizationHistory: extractResources(immunizations)
  };
}

FHIR Implementation Checklist

  • Version: Target FHIR R4 (4.0.1) for new implementations
  • Authentication: Implement SMART on FHIR for EHR integrations
  • Validation: Validate resources against FHIR profiles
  • Error Handling: Process OperationOutcome resources for errors
  • Pagination: Handle paginated search results properly
  • Caching: Respect ETag headers and conditional requests
  • Terminology: Use standard code systems (SNOMED, LOINC, RxNorm)
  • Testing: Use public FHIR test servers for development
Key Takeaway

FHIR has transformed healthcare interoperability by bringing modern web development practices to clinical data exchange. Start with the core resources (Patient, Observation, Condition), implement SMART authentication, and gradually expand your integration as requirements grow.

Next Steps

Ready to start building? Use public FHIR test servers like HAPI FHIR or the SMART Health IT Sandbox for development. For understanding the broader healthcare interoperability landscape, see our guide on HL7 Standards.