Build a SMART on FHIR App in 30 Minutes

Build a standalone SMART on FHIR app in a single HTML file with zero build tools. You can do it.

the nighthawk · 12 min read · 2026-04-05


SMART on FHIR is how apps talk to electronic health records. (New to FHIR itself? Start with the 101.) It's OAuth 2.0 with a few healthcare-specific conventions: a discovery document that tells your app where to authenticate, scopes that map to FHIR resource types, and a launch context that tells you which patient you're looking at.

This tutorial follows SMART App Launch STU2.1 — the current published standard. If you've built an OAuth login flow before, you already understand 80% of it. The other 20% is what this covers.

We're going to build a standalone SMART app that authenticates against a FHIR server, fetches a patient's vital signs, and renders interactive charts — heart rate, blood pressure, SpO2, temperature, weight, and BMI. The whole thing fits in a single index.html file. No React, no bundler, no npm install. Just a text editor and a browser.

By the end you'll have a working app and a mental model for how every SMART on FHIR app works under the hood — whether it's a one-page demo or a production clinical decision support tool launching inside Epic.

Prerequisites

You need two things:

1. A mock.health account. Go to mock.health and sign up. It's free. This gives you a FHIR R4 server with synthetic patients that actually have realistic clinical data — vital signs, conditions, medications, imaging studies.

2. A registered SMART app. In the mock.health dashboard, go to Sandbox → Register App and create a new app with these settings:

Copy the Client ID — you'll need it in a minute.

A note on scope syntax. SMART App Launch STU2 introduced a new scope format. The v1 format patient/Patient.read became patient/Patient.rs in v2, where the suffix letters map to individual operations: create, read, update, delete, search. So .rs means read + search, and v1's .write maps to v2's .cud. Servers advertise which format they support via permission-v1 and permission-v2 in their capabilities. Most production EHRs accept both formats today. This tutorial uses v2 (*.rs), but v1 (*.read) will also work with mock.health.

The redirect URI matters. It must exactly match the URL where your app is running. When the authorization server sends the user back to your app after login, it appends an authorization code to this URL. If it doesn't match what you registered, the server rejects the request.

Step 1: Discover the SMART Endpoints

Every SMART-enabled FHIR server publishes a discovery document at /.well-known/smart-configuration. This tells your app where to send the user for authorization and where to exchange codes for tokens.

const baseUrl = 'https://api.mock.health';

const response = await fetch(`${baseUrl}/fhir/.well-known/smart-configuration`);
const config = await response.json();

// config.authorization_endpoint → where to redirect the user
// config.token_endpoint         → where to exchange the code for a token

The response looks like this:

{
  "issuer": "https://api.mock.health",
  "authorization_endpoint": "https://api.mock.health/smart/authorize",
  "token_endpoint": "https://api.mock.health/smart/token",
  "grant_types_supported": ["authorization_code"],
  "code_challenge_methods_supported": ["S256"],
  "scopes_supported": ["launch/patient", "patient/Patient.rs", "patient/Observation.rs", "openid", "fhirUser"],
  "capabilities": ["launch-standalone", "client-public", "context-standalone-patient", "permission-v1", "permission-v2", "sso-openid-connect"]
}

The fields that matter for your app: authorization_endpoint (where to redirect the user) and token_endpoint (where to exchange codes for tokens). The capabilities array tells you what the server supports — permission-v1 and permission-v2 indicate which scope syntax formats are accepted, sso-openid-connect means you can get an id_token with user identity.

Why discovery instead of hardcoding? Because every FHIR server puts these endpoints at different paths. Epic uses /oauth2/authorize. Oracle Health (Cerner) uses /tenants/{tenant}/protocols/oauth2/profiles/smart-v1/personas/patient/authorize. The discovery document means your app works against any conformant server without code changes.

Step 2: Generate a PKCE Challenge

PKCE (Proof Key for Code Exchange, pronounced "pixie") prevents authorization code interception attacks. Your app generates a random secret (the verifier), hashes it (the challenge), and sends only the hash to the authorization server. When you exchange the code for a token later, you prove you're the same app by sending the original verifier.

function generateCodeVerifier(len = 64) {
  const arr = new Uint8Array(len);
  crypto.getRandomValues(arr);
  return base64url(arr);
}

async function computeCodeChallenge(verifier) {
  const digest = await crypto.subtle.digest(
    'SHA-256',
    new TextEncoder().encode(verifier)
  );
  return base64url(new Uint8Array(digest));
}

function base64url(bytes) {
  let bin = '';
  for (const b of bytes) bin += String.fromCharCode(b);
  return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

The base64url encoding is standard base64 with +-, /_, and padding stripped. This makes it safe for URLs without percent-encoding.

Store the verifier in sessionStorage — you'll need it after the redirect.

const verifier = generateCodeVerifier();
const challenge = await computeCodeChallenge(verifier);
sessionStorage.setItem('pkce_verifier', verifier);

Step 3: Redirect to Authorize

Build the authorization URL and redirect the user's browser. Every parameter matters:

const state = crypto.randomUUID();
sessionStorage.setItem('auth_state', state);

const params = new URLSearchParams({
  response_type: 'code',
  client_id: clientId,                              // from your registered app
  redirect_uri: location.origin + location.pathname, // must match registration
  scope: 'launch/patient patient/Patient.rs patient/Observation.rs openid fhirUser',
  state,                                             // CSRF protection
  aud: `${baseUrl}/fhir`,                           // which FHIR server this token is for
  code_challenge: challenge,                          // PKCE
  code_challenge_method: 'S256',
});

location.href = `${config.authorization_endpoint}?${params}`;

What each parameter does:

After this redirect, the user sees a consent screen (on mock.health, this shows which patient and which scopes). When they approve, the server redirects back to your redirect_uri with ?code=...&state=... appended.

Step 4: Handle the Callback and Exchange the Code

When your page loads, check if there's a code parameter in the URL. If so, you're in the OAuth callback.

const params = new URLSearchParams(location.search);
const code = params.get('code');
const state = params.get('state');

if (code) {
  // Verify state matches what we stored
  if (state !== sessionStorage.getItem('auth_state')) {
    throw new Error('State mismatch — possible CSRF');
  }

  // Clean the URL (don't leave the code visible)
  history.replaceState(null, '', location.pathname);

  // Exchange code for token
  const verifier = sessionStorage.getItem('pkce_verifier');

  const body = new URLSearchParams({
    grant_type: 'authorization_code',
    code,
    redirect_uri: location.origin + location.pathname,
    client_id: clientId,
  });
  if (verifier) body.set('code_verifier', verifier);

  const response = await fetch(tokenEndpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body,
  });

  if (!response.ok) {
    const err = await response.json().catch(() => ({}));
    throw new Error(err.error_description || err.error || `Token exchange failed (${response.status})`);
  }

  const tokens = await response.json();
}

The token response includes everything you need:

{
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGci...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "launch/patient patient/Patient.rs patient/Observation.rs openid fhirUser",
  "patient": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "id_token": "eyJhbGciOiJSUzI1NiJ9..."
}

Two SMART-specific fields to note:

Step 5: Fetch Vital Signs

Now you have a bearer token and a patient ID. Fetch the patient demographics and their vital sign Observations:

const headers = {
  Authorization: `Bearer ${tokens.access_token}`,
  Accept: 'application/fhir+json',
};

// Patient demographics (name, DOB, sex)
const patient = await fetch(`${baseUrl}/fhir/Patient/${tokens.patient}`, { headers })
  .then(r => r.json());

// Vital signs — the category=vital-signs search parameter
// returns heart rate, blood pressure, temperature, SpO2, weight, BMI
const observations = await fetch(
  `${baseUrl}/fhir/Observation?patient=${tokens.patient}&category=vital-signs&_sort=-date&_count=200`,
  { headers }
).then(r => r.json());

Each Observation has a LOINC code that identifies what it measures:

Vital Sign LOINC Code Value Location
Heart Rate 8867-4 valueQuantity.value
Blood Pressure 85354-9 component[].valueQuantity.value
Temperature 8310-5 valueQuantity.value
SpO2 2708-6 valueQuantity.value
Weight 29463-7 valueQuantity.value
BMI 39156-5 valueQuantity.value

Blood pressure is the odd one out. It's a panel — the Observation itself has LOINC code 85354-9, but the actual systolic and diastolic values live in component entries with their own LOINC codes (8480-6 for systolic, 8462-4 for diastolic). Every FHIR developer hits this for the first time and wonders why it's nested. Welcome to the club.

Here's how to parse them:

const BP_SYSTOLIC  = '8480-6';
const BP_DIASTOLIC = '8462-4';

for (const obs of observations.entry.map(e => e.resource)) {
  const code = obs.code?.coding?.[0]?.code;
  const date = obs.effectiveDateTime;

  if (code === '85354-9') {
    // Blood pressure panel — extract components
    const sys = obs.component?.find(c => c.code?.coding?.[0]?.code === BP_SYSTOLIC);
    const dia = obs.component?.find(c => c.code?.coding?.[0]?.code === BP_DIASTOLIC);
    console.log(`${date}: ${sys?.valueQuantity?.value}/${dia?.valueQuantity?.value} mmHg`);
  } else if (code === '8867-4') {
    // Simple vital sign — value is directly on the Observation
    console.log(`${date}: ${obs.valueQuantity?.value} bpm`);
  }
}

Step 6: Render the Charts

We use Chart.js for the line charts — one CDN script tag, no build step required.

<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>

Group the observations by type, sort by date, and render a line chart for each:

const chart = new Chart(ctx, {
  type: 'line',
  data: {
    labels: data.map(d => formatDate(d.date)),
    datasets: [{
      label: 'Heart Rate',
      data: data.map(d => d.value),
      borderColor: '#ef4444',
      backgroundColor: 'rgba(239,68,68,0.1)',
      tension: 0.3,
      fill: true,
      pointRadius: 3,
    }],
  },
  options: {
    responsive: true,
    maintainAspectRatio: false,
    scales: {
      y: { title: { display: true, text: 'Heart Rate (bpm)' } },
    },
  },
});

For blood pressure, render two datasets on the same chart — systolic and diastolic — so you can see the spread at a glance.

Add tab buttons to switch between vital sign types. Show/hide by destroying and recreating the chart (Chart.js handles this cleanly). Below the chart, render a simple HTML table with the most recent readings.

The Complete App

The full working app is ~475 lines in a single index.html — HTML structure, CSS, PKCE functions, auth flow, data fetching, and Chart.js rendering. No framework, no build step.

Clone it and run it:

git clone https://github.com/mock-health/samples.git
cd samples
python3 -m http.server 8080

# Open http://localhost:8080/smart-on-fhir-vital-signs/index.html

Enter your Client ID, click Connect, approve access, and you'll see your patient's vital signs charted out.

The full source is on GitHub.

What Changes in Production EHRs

This tutorial uses mock.health, which implements the SMART spec faithfully and gets out of your way. Production EHRs are… different. Here's what to expect:

App registration takes weeks, not seconds. Epic's App Orchard requires a formal review process. Oracle Health has a similar marketplace. You'll fill out questionnaires about data usage, security practices, and business associates agreements. Budget 2–8 weeks for approval. (Yes, really.)

Scopes vary. This tutorial uses SMART v2 scope syntax (patient/Observation.rs), but many production EHRs still only accept v1 syntax (patient/Observation.read). Check the server's .well-known/smart-configuration for permission-v1 and/or permission-v2 in the capabilities array to know which format to use. Some EHRs don't support granular scopes at all — they grant patient/*.read or nothing.

Discovery endpoints aren't always where you expect. The STU2 spec says .well-known/smart-configuration must be relative to the FHIR base URL (e.g., https://fhir.example.com/R4/.well-known/smart-configuration). Some older servers don't support this endpoint at all. In that case, you fall back to fetching the server's CapabilityStatement (GET /metadata) and extracting OAuth URLs from the rest[0].security.extension where url = http://fhir-registry.smarthealthit.org/StructureDefinition/oauth-uris. Your production app should try .well-known/smart-configuration first and fall back to the CapabilityStatement.

Patient data is sparse in different ways. Every EHR has Observations, but the LOINC codes and available vital sign types vary. Some systems record temperature in Celsius, some in Fahrenheit. Some use different LOINC codes for the same measurement. Your production app should handle missing data and varying units gracefully.

Tokens expire and refresh tokens matter. Our demo doesn't handle token refresh because sessions are short. In production, you'll want to request offline_access scope and implement refresh token rotation.

None of this changes the fundamental flow. Discovery → PKCE → authorize → callback → token → API calls. Same everywhere. The operational details differ.

What's Next

You now have a mock.health account and a working SMART app. From here:

The hardest part of building a SMART on FHIR app is getting access to a server that has realistic data and implements the spec correctly. Now you have one.


Related posts

All posts · Home · Docs