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:
- App Name: Vital Signs Chart (or whatever you want)
- Redirect URI:
http://localhost:8080/smart-on-fhir-vital-signs/index.html - Scopes:
launch/patient,patient/Patient.rs,patient/Observation.rs,openid,fhirUser
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.readbecamepatient/Patient.rsin v2, where the suffix letters map to individual operations: create, read, update, delete, search. So.rsmeans read + search, and v1's.writemaps to v2's.cud. Servers advertise which format they support viapermission-v1andpermission-v2in 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:
response_type: 'code'— We want an authorization code (not an implicit token). Alwayscode.client_id— Identifies your app. The server looks up your registered redirect URIs and allowed scopes.redirect_uri— Where to send the user back. Must exactly match what you registered.scope— What data you're requesting access to.launch/patientasks for a patient context during standalone launch.patient/Observation.rsasks to read and search Observations scoped to that patient (the.rssuffix means read + search in SMART v2 syntax).openid fhirUserrequests an identity token with the user's FHIR resource URL.state— A random string you verify on callback to prevent CSRF attacks. If someone crafts a malicious authorize URL, the state won't match.aud— The audience. Tells the auth server which FHIR endpoint this token should work with. Prevents token replay across servers.code_challenge+code_challenge_method— The PKCE challenge. The server stores this and verifies it when you exchange the code.
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:
-
patient— The launch context. The ID of the patient the user selected during the consent flow. Your app doesn't choose the patient; the authorization server tells you which patient the user authorized you to access. This field is present because you requested thelaunch/patientscope with a standalone launch. -
id_token— A JWT containing the authenticated user's identity. Because you requestedopenid fhirUserscopes, this token includes afhirUserclaim with the FHIR URL of the authorized user (e.g.,https://api.mock.health/fhir/Practitioner/123). This is how your app knows who is using it, separate from which patient they selected.
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.
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:
- Explore the API — mock.health's FHIR server has patients with conditions, medications, imaging studies, diagnostic reports, and clinical notes. Try adding
patient/Condition.rsorpatient/DiagnosticReport.rsscopes to your app registration. - Browse the data — The patient browser shows you what data is available before you write API calls against it.
- Read the spec — The full SMART App Launch STU2.1 specification covers EHR launch (where the EHR starts your app with context), backend services (server-to-server with no user), token introspection, and the complete scope grammar.
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
- How to Make Claude Write Valid Synthea Modules — LLMs generate valid Synthea JSON but hallucinate the medical codes. Here's a Skill to grounds every SNOMED and LOINC lookup.
- Testing FHIR Integrations Without a Hospital — You can't get hospital access without a working integration. You can't build a working integration without hospital data. Here's how to break the catch-22.
- FHIR, USCDI, and US Core: What They Are, How They Fit — FHIR says how to send data. USCDI says what data. US Core says exactly how to format it. Here's how the three standards fit together.