This page is for the engineer building Vue.js user interfaces that healthcare workers will actually touch: the clinical informaticist wiring a SMART-on-FHIR app to Epic, the EMR-integration developer at a regional hospital network, the senior front-end engineer at a healthcare-IT consultancy, or the solo dev shipping a telehealth booking flow for a clinic. The phrase "healthcare workers" in the search box gets interpreted on this page as "the engineers who build the software healthcare workers use." It matters because every code pattern below assumes Vue 3 composition API plus four constraints the open SERP never names: protected health information must never leak into browser storage or analytics, clinicians hit your UI on locked-down tablets and aging Windows workstations, accessibility is a federal expectation in any clinical setting, and there is no second screen so the layout has to work at 11 inches at 100 percent zoom with a slightly smudged stylus. Before any code, one honest scoping sentence the SERP almost never delivers: Vue.js is a JavaScript framework, not a HIPAA-regulated entity, and that distinction is the spine of every architecture decision in this guide.
Vue 3 for healthcare UIs: fit / no-fit map
| Healthcare-UI pattern | Vue 3 primitive that fits | Why it fits in 2026 | When another framework wins |
|---|---|---|---|
| SMART-on-FHIR app launched from Epic / Cerner / Athena | <script setup> + Pinia store + fetch with bearer-token interceptor | Composition API keeps the launch handshake colocated with state; Pinia survives a vendor-portal reload via sessionStorage rehydrate | React if the vendor SDK only ships React bindings (rare in 2026) |
FHIR R4 Patient / Observation / MedicationRequest display | defineProps<{ resource: FhirPatient }>() plus computed for derived fields | Reactivity flattens nested FHIR JSON into a readable template without a render-prop tax | Angular if the rest of the EHR vendor portal is Angular and you must share a design system |
| Role-based clinical UI (MD, RN, PharmD, MA, admin) | v-if="can('write', resource)" plus a composable like useAccess() | Composables make the policy testable and the templates declarative; no provider-tree gymnastics | None; Vue handles role gating cleanly |
| Accessibility (WCAG 2.1 AA) for clinical workflows | Native <button> / <dialog> + useId() + manual ARIA on custom widgets | Templates make ARIA visible to reviewers; useId() (Vue 3.5+) generates SSR-stable ids for label/aria-labelledby pairing | Svelte if your team is already there; both are equally fine for a11y when written by an engineer who reads WAI-ARIA Authoring Practices |
| Telehealth video (Twilio Video, Daily.co, LiveKit) | onMounted / onBeforeUnmount hooks plus a useVideoRoom() composable | Lifecycle hooks map cleanly to track attach / detach; reactive refs handle network-quality state | React + the vendor's official React hooks if they exist; both are fine |
| PWA offline charting (basement imaging, MRI suites, rural ambulance) | Vite PWA plugin + Workbox + IndexedDB write-through queue | Composition API + idb library + reactive online ref makes the offline → sync transition obvious | None; this is a Vue strong-fit |
| Shift-handoff (SBAR) UI | <form> with composition-API useSbarDraft() composable | Local-first form state with autosave to sessionStorage; never localStorage, never browser history | None; Vue's v-model plus composables is the cleanest expression |
| MAR (medication administration record) timing grid | <table> with role="grid", aria-rowindex, virtual scrolling via vue-virtual-scroller | Native table semantics first; virtualization second; never reinvent a clinical grid | React if you must reuse ag-grid-enterprise Pro features |
| Patient-list table with filtering and sort | <table> plus useFhirSearch() composable with debounced query params | The composition API keeps URL state, query state, and reactive list state in one file | None |
| Internal vendor-portal embedding (iframe + postMessage) | Vue 3 provide / inject for parent-page context + defineExpose for outbound API | Iframe context propagates cleanly through composables; type-safe outbound calls via defineExpose | None |
Three things to read off that table before the first line of code. First, Vue 3 is a strong fit for the clinical-UI work that the SERP keeps describing in vague slogans: FHIR resource display, role-based gating, accessibility-first templates, telehealth lifecycle, offline PWAs, and embedding into vendor portals. Second, Vue 3 is rarely wrong for a healthcare frontend in 2026; the cases where another framework wins are almost always "the rest of the codebase is already X" rather than "X has a feature Vue can't match." Third, framework choice is not the compliance boundary. We will state this loudly in the next section because it is the single most miscommunicated fact in the SERP.
HIPAA, Section 508, and what the framework actually owns
This is the part of the page that other competitors will not write, and that an honest engineer needs first.
Vue.js is a JavaScript framework, not a HIPAA-regulated entity. HIPAA compliance is determined by your backend (PHI storage), hosting (BAA-eligible cloud like AWS/Azure/GCP with signed BAA), and operational practices. Vue itself is HIPAA-NEUTRAL: it can be used to build HIPAA-compliant apps when the supporting stack is compliant. Same for Section 508 (Section 508 applies to federal-agency-owned applications, your responsibility is WCAG 2.1 AA conformance at the UI layer, which Vue can absolutely deliver).
Read carefully. HIPAA (the Health Insurance Portability and Accountability Act, 45 CFR Parts 160 and 164) is a U.S. federal regulation that applies to "covered entities" (healthcare providers, health plans, healthcare clearinghouses) and their "business associates" (vendors who handle protected health information on the covered entity's behalf). HIPAA imposes obligations on those legal entities. It does not impose obligations on a programming language, a frontend framework, a CSS library, a unit-test runner, or any other piece of code. There is no such thing as a "HIPAA-compliant" framework, and any marketing copy that suggests otherwise is selling you nothing.
What HIPAA does impose, in practice, on a healthcare web application:
- PHI must be encrypted in transit (TLS 1.2+) and at rest (AES-256 typically). TLS is your hosting provider plus an Nginx / Cloudflare / Caddy config; AES-256 at rest is your database plus your storage provider. Vue contributes nothing and is required to contribute nothing.
- PHI access must be audit-logged. The audit log lives server-side, almost always next to the database. Vue contributes one thing: never log PHI to the browser console or to a client-side analytics tool, ever.
- Workforce access must be role-controlled (HIPAA Security Rule, 45 CFR 164.308(a)(4)). The authoritative role decisions live in the backend. The Vue UI mirrors them for usability; the backend enforces them for safety.
- Breach notification, minimum-necessary disclosure, BAA chain. These are organizational and contractual. Frameworks are irrelevant.
Where Vue does have skin in the game (and where this page will show real code):
- Never put PHI into
localStorage. Persistence is treated like storage by HIPAA enforcers; a stolen laptop with cached PHI is a breach. UsesessionStorageonly if there is a workflow reason, neverlocalStorage. - Never put PHI into URL query strings. They show up in Google Analytics, server access logs, browser history, and shared screenshots. The router-level pattern below catches that risk.
- Never log PHI to the browser console in
console.log,console.warn, error reporters (Sentry default capture), or third-party analytics SDKs. The interceptor pattern below shows how to scrub before any of those see request payloads. - Never embed PHI in front-end-only routing keys. A URL
/patients/MRN-12345is a leak. Use opaque random ids that the backend resolves to the patient record.
Section 508 is a parallel federal rule with a narrower scope. Section 508 of the Rehabilitation Act binds federal agencies. If your customer is the VA (Veterans Affairs), IHS (Indian Health Service), or military medicine, your app contractually inherits 508 conformance, which currently maps to WCAG 2.0 AA (and in practice everyone targets WCAG 2.1 AA). If your customer is a private hospital, an academic medical center, a regional health system, a national chain, or a startup, you owe WCAG 2.1 AA, not Section 508 by name. The Vue layer absolutely can deliver WCAG 2.1 AA when the engineer writes accessible templates, uses semantic HTML first, and reaches for ARIA only as a corrective.
For an authoritative read on the regulation, the U.S. Department of Health & Human Services maintains HIPAA for Professionals, which is the source-of-truth document for any compliance question you encounter on this project. The HL7 FHIR R4 specification is the source of truth for the resource shapes shown below.
Set up Vue 3 for a healthcare app (Vite + TS + a couple of healthcare-specific guardrails)
The shape of a healthcare-oriented Vue 3 project in 2026:
npm create vite@latest clinic-ui -- --template vue-ts
cd clinic-ui
npm install
npm install pinia vue-router@4 zod
npm install -D vite-plugin-pwa workbox-window @vue/test-utils vitest jsdomVue 3.5 is the target (released October 2024, stable through 2026). Verify with npm ls vue after install. Two healthcare-relevant additions over a generic Vue setup:
zodfor FHIR resource validation. EHR vendor portals sometimes return malformed resources (missing required fields, wrong cardinality onnameortelecom). A schema check at the boundary keeps a malformedPatientresource from corrupting reactive state.vite-plugin-pwafor offline-capable clinical workflows. The healthcare environments where Vue earns its keep have dead spots (basement radiology, faraday-caged MRI suites, rural ambulance bays); a PWA-shaped app stays useful when the network does not.
The minimum viable vite.config.ts for a healthcare PWA:
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { VitePWA } from 'vite-plugin-pwa';export default defineConfig({ plugins: [ vue(), VitePWA({ registerType: 'autoUpdate', manifest: { name: 'Clinic UI', short_name: 'ClinicUI', theme_color: '#0b3b5b', background_color: '#ffffff', display: 'standalone', icons: [ { src: '/icons/192.png', sizes: '192x192', type: 'image/png' }, { src: '/icons/512.png', sizes: '512x512', type: 'image/png' }, ], }, workbox: { // Never cache API responses that may contain PHI. // We let Workbox precache the SHELL only, and we handle PHI // caching ourselves via IndexedDB with explicit TTL + scrubbing. navigateFallback: '/index.html', runtimeCaching: [ { urlPattern: /^https://fonts.googleapis.com/.*/i, handler: 'CacheFirst', options: { cacheName: 'google-fonts' }, }, // Note: NO FHIR API route here. ], }, }), ], });
That comment ("Never cache API responses that may contain PHI") is the single most important line in the file. If you let Workbox cache your `/fhir/Patient/{id}` responses as a runtime cache, you are creating a PHI cache the user does not control and your security team did not approve. The shell caches; PHI does not.
## Pattern 1: FHIR R4 `Patient` display with Zod validation
Start with the resource you will render. FHIR R4 `Patient` has dozens of optional fields; we are going to validate the subset our UI cares about and let the rest pass through untyped. Conservative shape:
```ts
// src/fhir/patient.ts
import { z } from 'zod';
export const HumanName = z.object({
use: z.enum(['usual', 'official', 'temp', 'nickname', 'anonymous', 'old', 'maiden']).optional(),
text: z.string().optional(),
family: z.string().optional(),
given: z.array(z.string()).optional(),
});
export const ContactPoint = z.object({
system: z.enum(['phone', 'fax', 'email', 'pager', 'url', 'sms', 'other']).optional(),
value: z.string().optional(),
use: z.enum(['home', 'work', 'temp', 'old', 'mobile']).optional(),
});
export const FhirPatient = z.object({
resourceType: z.literal('Patient'),
id: z.string(),
name: z.array(HumanName).default([]),
gender: z.enum(['male', 'female', 'other', 'unknown']).optional(),
birthDate: z.string().optional(), // YYYY-MM-DD
telecom: z.array(ContactPoint).default([]),
// We deliberately drop address/identifier/extension from the validated shape;
// they are passed through but not surfaced in this UI.
});
export type FhirPatient = z.infer<typeof FhirPatient>;Now the composable that fetches the resource. Two patterns matter: a bearer-token aware fetch wrapper, and a PHI-safe error handler.
// src/composables/useFhirPatient.ts
import { ref, watchEffect } from 'vue';
import { FhirPatient, type FhirPatient as PatientShape } from '@/fhir/patient';
import { useAuth } from '@/composables/useAuth';
export function useFhirPatient(patientId: () => string) {
const { token, baseUrl } = useAuth();
const patient = ref<PatientShape | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
watchEffect(async (onCleanup) => {
const id = patientId();
if (!id || !token.value) return;
loading.value = true;
error.value = null;
const abort = new AbortController();
onCleanup(() => abort.abort());
try {
const res = await fetch(`${baseUrl}/Patient/${encodeURIComponent(id)}`, {
headers: {
Authorization: `Bearer ${token.value}`,
Accept: 'application/fhir+json',
},
signal: abort.signal,
});
if (!res.ok) {
// PHI-safe: never include response body in the error string.
error.value = `Read failed: HTTP ${res.status}`;
return;
}
const json = await res.json();
const parsed = FhirPatient.safeParse(json);
if (!parsed.success) {
// PHI-safe: log issues by path only, never the value.
const paths = parsed.error.issues.map((i) => i.path.join('.')).join(', ');
error.value = `Malformed Patient resource (fields: ${paths})`;
return;
}
patient.value = parsed.data;
} catch (e: unknown) {
// PHI-safe: never stringify e if it might include request body.
error.value = e instanceof Error ? e.name : 'Unknown error';
} finally {
loading.value = false;
}
});
return { patient, loading, error };
}The component that renders it:
<!-- src/components/PatientHeader.vue -->
<script setup lang="ts">
import { computed } from 'vue';
import { useFhirPatient } from '@/composables/useFhirPatient';
const props = defineProps<{ patientId: string }>();
const { patient, loading, error } = useFhirPatient(() => props.patientId);
const displayName = computed(() => {
const p = patient.value;
if (!p) return '';
const official = p.name.find((n) => n.use === 'official') ?? p.name[0];
if (!official) return 'Unknown';
if (official.text) return official.text;
const given = (official.given ?? []).join(' ');
return `${given} ${official.family ?? ''}`.trim() || 'Unknown';
});
const formattedDob = computed(() => {
const d = patient.value?.birthDate;
if (!d) return '';
// Display only year-month-day; never compute age client-side and
// never embed DOB in any analytics event.
return d;
});
</script>
<template>
<section aria-labelledby="patient-header" class="patient-header">
<h2 id="patient-header" class="visually-hidden">Patient header</h2>
<p v-if="loading" role="status">Loading patient.</p>
<p v-else-if="error" role="alert">{{ error }}</p>
<dl v-else-if="patient">
<dt>Name</dt>
<dd>{{ displayName }}</dd>
<dt>Sex</dt>
<dd>{{ patient.gender ?? 'unknown' }}</dd>
<dt>Date of birth</dt>
<dd>{{ formattedDob }}</dd>
</dl>
</section>
</template>Five things to notice. First, the validation runs at the fetch boundary so the rest of the app can assume well-shaped data. Second, the error path never reveals PHI; we log the path that failed, not the value. Third, the UI uses a <dl> (description list) with explicit <dt> / <dd> pairs because that is the most accessible markup for a labeled key-value display, and screen readers handle it well by default. Fourth, the loading state is role="status" and the error state is role="alert", which announce politely and assertively respectively. Fifth, there is an id="patient-header" paired with aria-labelledby on the section so that the landmark is reachable by name in the accessibility tree.
Pattern 2: Role-based access control in the UI layer
The backend is the enforcement boundary; the UI is the usability mirror. The pattern:
// src/composables/useAccess.ts
import { computed } from 'vue';
import { useUser } from '@/composables/useUser';
export type ClinicalRole = 'physician' | 'nurse' | 'pharmacist' | 'medical-assistant' | 'admin' | 'read-only';
export type Action = 'read' | 'write' | 'sign' | 'co-sign' | 'amend';
export type ResourceKind = 'Patient' | 'Observation' | 'MedicationRequest' | 'Note' | 'Order';
// This map mirrors the server-side policy. It is intentionally restrictive.
// The UI hides what the user cannot do; the server still re-checks every write.
const POLICY: Record<ClinicalRole, Partial<Record<ResourceKind, Action[]>>> = {
physician: {
Patient: ['read', 'write'],
Observation: ['read', 'write', 'sign'],
MedicationRequest: ['read', 'write', 'sign'],
Note: ['read', 'write', 'sign', 'amend'],
Order: ['read', 'write', 'sign'],
},
nurse: {
Patient: ['read'],
Observation: ['read', 'write'],
MedicationRequest: ['read'],
Note: ['read', 'write', 'co-sign'],
Order: ['read'],
},
pharmacist: {
Patient: ['read'],
MedicationRequest: ['read', 'write', 'co-sign'],
Observation: ['read'],
Note: ['read'],
},
'medical-assistant': {
Patient: ['read'],
Observation: ['read', 'write'],
Note: ['read'],
},
admin: {
Patient: ['read'],
},
'read-only': {
Patient: ['read'],
Observation: ['read'],
MedicationRequest: ['read'],
Note: ['read'],
Order: ['read'],
},
};
export function useAccess() {
const { user } = useUser();
const role = computed<ClinicalRole>(() => user.value?.role ?? 'read-only');
function can(action: Action, kind: ResourceKind): boolean {
const allowed = POLICY[role.value]?.[kind] ?? [];
return allowed.includes(action);
}
return { role, can };
}The component that uses it:
<!-- src/components/OrderPanel.vue -->
<script setup lang="ts">
import { useAccess } from '@/composables/useAccess';
const { can, role } = useAccess();
</script>
<template>
<div class="order-panel">
<h3>Orders</h3>
<p v-if="!can('read', 'Order')">
You do not have permission to view orders. Role: {{ role }}.
</p>
<ul v-else>
<!-- order list omitted for brevity -->
</ul>
<button v-if="can('write', 'Order')" type="button">New order</button>
<button v-if="can('sign', 'Order')" type="button">Sign and submit</button>
</div>
</template>The deliberate choice in the policy map: the default for an unknown role is read-only, not "deny everything." That seems counterintuitive until you imagine a brand-new clinical role getting added to the IdP without a corresponding code update; "deny everything" surfaces as a fully broken UI ten minutes before the first nurse logs in. Read-only is safer for the patient (clinician can still see chart data, just cannot edit) and safer for the project (no zero-day-of-go-live crisis call).
Pattern 3: Accessibility for clinical workflows (WCAG 2.1 AA)
Three things the SERP never says about clinical UIs and accessibility.
First, semantic HTML beats ARIA every time. A native <button> with no extra attributes is already keyboard-operable, focusable, and screen-reader-announceable. A <div role="button" tabindex="0" @keydown.enter="..."> is three lines of code re-implementing what the browser already gives you, and it is usually wrong. The Vue template should reach for <button>, <a>, <dialog>, <details>, <table>, <form>, and <input type="..."> first. ARIA is the corrective when no native element fits.
Second, focus management is the WCAG-2.1-AA failure mode that hits clinical UIs hardest. When a modal opens and focus is not trapped inside it, a clinician on a keyboard (or a Dragon NaturallySpeaking voice user, or a switch user) cannot reach the modal's buttons. When the modal closes and focus does not return to the trigger, they get dumped at the top of the page and have to retab through forty unrelated controls.
Third, <dialog> is the right primitive in 2026. All evergreen browsers ship native <dialog> with showModal(), which traps focus by default and is fully accessible without library code.
Here is a clinical-confirmation dialog written in Vue 3 with the right primitives.
<!-- src/components/ConfirmSignDialog.vue -->
<script setup lang="ts">
import { ref, watch, useId } from 'vue';
const props = defineProps<{ open: boolean; orderSummary: string }>();
const emit = defineEmits<{ confirm: []; cancel: [] }>();
const dialogRef = ref<HTMLDialogElement | null>(null);
const titleId = useId();
const descId = useId();
watch(
() => props.open,
(open) => {
const d = dialogRef.value;
if (!d) return;
if (open && !d.open) d.showModal();
if (!open && d.open) d.close();
},
);
</script>
<template>
<dialog
ref="dialogRef"
:aria-labelledby="titleId"
:aria-describedby="descId"
@close="emit('cancel')"
>
<h2 :id="titleId">Sign and submit order?</h2>
<p :id="descId">{{ orderSummary }}</p>
<p class="warning" role="note">
Signing an order applies your electronic signature. This action is
audited and cannot be silently undone.
</p>
<form method="dialog" class="actions">
<button type="button" @click="emit('cancel')">Cancel</button>
<button type="submit" @click="emit('confirm')">Sign and submit</button>
</form>
</dialog>
</template>Notice: useId() (Vue 3.5+) generates SSR-stable ids that aria-labelledby and aria-describedby reference. The <form method="dialog"> plus a type="submit" button gives keyboard users Enter-to-confirm and Escape-to-cancel for free. The signing warning is role="note", which is the right ARIA role for ancillary advisory information that should be in the accessibility tree without interrupting the main flow.
For a more substantive accessibility deep-dive, see our Vue best practices tutorial for generic Vue 3 a11y patterns, then come back here for the clinical overlays.
Pattern 4: Telemedicine video room lifecycle (Twilio, Daily, LiveKit)
Telehealth UIs are mostly lifecycle. Mount means connect to a room, attach local audio and video tracks, render remote tracks as participants join, react to network-quality changes. Unmount means leave the room, detach tracks, free media devices. The Vue 3 composition API maps this almost line for line.
// src/composables/useVideoRoom.ts
// Vendor-agnostic adapter; swap implementation by changing the inner client.
import { ref, onMounted, onBeforeUnmount, shallowRef } from 'vue';
export type Participant = {
identity: string;
videoEl: HTMLVideoElement | null;
audioMuted: boolean;
videoMuted: boolean;
};
export function useVideoRoom(opts: {
token: () => string;
roomName: string;
}) {
const participants = ref<Participant[]>([]);
const networkQuality = ref<number>(5); // 0..5
const connected = ref(false);
const error = ref<string | null>(null);
// shallowRef for the client because we never want Vue to deep-track it.
const client = shallowRef<{ disconnect: () => void } | null>(null);
async function connect() {
try {
// Replace with your vendor's connect call. Twilio example:
// const room = await Twilio.connect(opts.token(), { name: opts.roomName });
// For brevity we sketch the shape:
const room = await fakeConnect(opts.token(), opts.roomName);
client.value = room;
connected.value = true;
// wire participant + network listeners here...
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Connect failed';
}
}
function disconnect() {
client.value?.disconnect();
client.value = null;
connected.value = false;
participants.value = [];
}
onMounted(connect);
onBeforeUnmount(disconnect);
return { participants, networkQuality, connected, error, disconnect };
}
// Stub to keep the example self-contained:
async function fakeConnect(_token: string, _name: string) {
return { disconnect() {} };
}The component:
<!-- src/components/VideoVisit.vue -->
<script setup lang="ts">
import { useVideoRoom } from '@/composables/useVideoRoom';
import { useAuth } from '@/composables/useAuth';
const props = defineProps<{ encounterId: string }>();
const { token } = useAuth();
const { participants, networkQuality, connected, error, disconnect } =
useVideoRoom({ token: () => token.value ?? '', roomName: `enc_${props.encounterId}` });
</script>
<template>
<section aria-labelledby="visit-heading">
<h2 id="visit-heading">Telehealth visit</h2>
<p v-if="!connected" role="status">Connecting...</p>
<p v-if="error" role="alert">{{ error }}</p>
<p v-if="connected && networkQuality < 3" role="alert">
Network quality is low. Audio may be choppy.
</p>
<ul>
<li v-for="p in participants" :key="p.identity">
<video :ref="(el) => { if (el) p.videoEl = el as HTMLVideoElement; }" autoplay playsinline></video>
<span>{{ p.identity }}</span>
<span v-if="p.audioMuted" aria-label="microphone muted">(mic off)</span>
</li>
</ul>
<button type="button" @click="disconnect">End visit</button>
</section>
</template>PHI awareness inside the video flow: the encounterId in the room name is opaque (enc_${props.encounterId}), not a patient MRN. The vendor sees a random-looking room name. The patient identity is established through the SMART-on-FHIR launch context server-side, not the room name.
Pattern 5: PWA-for-offline charting
Hospitals have dead spots: basement radiology suites, faraday-caged MRI rooms, rural ambulance bays. A vital sign captured in those zones must persist locally and sync when the radio comes back.
// src/composables/useOfflineQueue.ts
import { ref, onMounted, watch } from 'vue';
import { openDB, IDBPDatabase } from 'idb';
type QueuedWrite = {
id: string;
url: string;
method: 'POST' | 'PUT';
bodyJson: unknown; // server validates server-side; client only buffers
ts: number;
};
let dbPromise: Promise<IDBPDatabase> | null = null;
function getDb() {
if (!dbPromise) {
dbPromise = openDB('clinic-offline', 1, {
upgrade(db) {
db.createObjectStore('queue', { keyPath: 'id' });
},
});
}
return dbPromise;
}
export function useOfflineQueue() {
const online = ref(typeof navigator !== 'undefined' ? navigator.onLine : true);
const pending = ref<QueuedWrite[]>([]);
async function enqueue(item: Omit<QueuedWrite, 'id' | 'ts'>) {
const db = await getDb();
const row: QueuedWrite = {
...item,
id: crypto.randomUUID(),
ts: Date.now(),
};
await db.put('queue', row);
pending.value = await db.getAll('queue');
}
async function flush() {
if (!online.value) return;
const db = await getDb();
const rows = (await db.getAll('queue')) as QueuedWrite[];
for (const row of rows) {
try {
const res = await fetch(row.url, {
method: row.method,
headers: { 'Content-Type': 'application/fhir+json' },
body: JSON.stringify(row.bodyJson),
});
if (res.ok) await db.delete('queue', row.id);
} catch {
// leave in queue; will retry on next online event
break;
}
}
pending.value = await db.getAll('queue');
}
onMounted(async () => {
const db = await getDb();
pending.value = await db.getAll('queue');
const goOnline = () => { online.value = true; };
const goOffline = () => { online.value = false; };
window.addEventListener('online', goOnline);
window.addEventListener('offline', goOffline);
});
watch(online, (isOnline) => { if (isOnline) flush(); });
return { online, pending, enqueue, flush };
}Two PHI-relevant choices in that file. The buffer lives in IndexedDB, which is per-origin and survives a refresh, but not encrypted on disk by default; if your threat model includes a stolen device, layer encrypted-at-rest IndexedDB libraries (the WebCrypto-backed idb-keyval-wrapped patterns are workable) on top. And the buffer never includes a freeform console.log of the row body; if a developer adds one during debugging, you have a PHI leak. Use a debug switch that no-ops in production builds.
Pattern 6: Embedding into Epic, Cerner / Oracle Health, and Athena vendor portals
EHR vendors expose two embedding shapes: SMART-on-FHIR launch URLs and iframe-with-postMessage bridges. SMART-on-FHIR is the open standard everyone is converging on; iframe + postMessage is the legacy fallback for older portal versions.
SMART-on-FHIR launch flow in Vue, abridged to the launch handshake and the post-launch token persistence:
// src/composables/useSmartLaunch.ts
import { ref, onMounted } from 'vue';
export function useSmartLaunch() {
const token = ref<string | null>(null);
const fhirBaseUrl = ref<string | null>(null);
const patientContext = ref<string | null>(null); // FHIR Patient id from launch
const ready = ref(false);
const error = ref<string | null>(null);
onMounted(async () => {
const params = new URLSearchParams(window.location.search);
const launch = params.get('launch');
const iss = params.get('iss');
if (!launch || !iss) {
// Not a SMART launch; treat as standalone (token may already exist).
ready.value = true;
return;
}
try {
// 1. Discover the auth endpoints from /.well-known/smart-configuration
const conf = await fetch(`${iss}/.well-known/smart-configuration`).then((r) => r.json());
// 2. Redirect to the authorize endpoint with PKCE.
// (Real code uses fhirclient.js or a custom PKCE flow.)
const authorize = new URL(conf.authorization_endpoint);
authorize.searchParams.set('response_type', 'code');
authorize.searchParams.set('client_id', import.meta.env.VITE_SMART_CLIENT_ID);
authorize.searchParams.set('redirect_uri', window.location.origin + '/launch/callback');
authorize.searchParams.set('scope', 'launch openid fhirUser patient/*.read');
authorize.searchParams.set('aud', iss);
authorize.searchParams.set('launch', launch);
// PKCE code_challenge / state generation omitted for brevity.
window.location.assign(authorize.toString());
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Launch failed';
}
});
return { token, fhirBaseUrl, patientContext, ready, error };
}Two notes. First, in production, use the official fhirclient JavaScript library; the snippet above shows the shape, not a security-audited implementation. Second, when the auth handler redirects back to /launch/callback, the route component exchanges the code for a token and writes the token to sessionStorage, never localStorage. The token expires; sessionStorage clears on tab close; that combination matches the HIPAA Security Rule's intent better than long-lived localStorage persistence.
For full reference on the SMART launch protocol, the HL7 FHIR R4 specification plus the SMART App Launch IG (linked from the FHIR site) are the authoritative documents.
Pattern 7: MAR (medication administration record) timing grid
A medication-administration-record grid is one of the highest-stakes UIs in clinical software. It is a table of patients across one axis and medication doses across the other, with cells representing administration windows. Three patterns:
- Native
<table>markup withscope="row"andscope="col"so screen-reader users can navigate the grid by row/column reference. aria-labelon each cell that includes the dose, route, time, and status so the cell announces meaningfully when focused.- Virtual scrolling for wards with 30+ patients so the DOM stays manageable.
vue-virtual-scrolleris the safe pick.
Sketch (HTML-only, virtualization omitted for clarity):
<!-- src/components/MarGrid.vue -->
<script setup lang="ts">
defineProps<{
patients: { id: string; displayName: string }[];
doses: { patientId: string; medication: string; route: string; time: string; status: 'due' | 'given' | 'held' | 'missed' }[];
}>();
</script>
<template>
<table aria-label="Medication administration record">
<thead>
<tr>
<th scope="col">Patient</th>
<th v-for="t in ['06:00', '08:00', '10:00', '12:00', '14:00', '16:00', '18:00', '20:00', '22:00']" :key="t" scope="col">{{ t }}</th>
</tr>
</thead>
<tbody>
<tr v-for="p in patients" :key="p.id">
<th scope="row">{{ p.displayName }}</th>
<td v-for="t in ['06:00', '08:00', '10:00', '12:00', '14:00', '16:00', '18:00', '20:00', '22:00']" :key="t">
<button
v-for="d in doses.filter(x => x.patientId === p.id && x.time === t)"
:key="d.medication + d.time"
type="button"
:class="['dose', `dose-${d.status}`]"
:aria-label="`${d.medication}, ${d.route}, ${t}, status ${d.status}`"
>
<span aria-hidden="true">{{ d.medication }}</span>
</button>
</td>
</tr>
</tbody>
</table>
</template>The <th scope="row"> for the patient name and <th scope="col"> for the times is the entire reason this works for screen-reader users; without those scope attributes, the grid is a wall of unlabeled numbers when announced.
Workflow steps for healthcare engineers building Vue UIs
- Confirm the regulatory perimeter before you write code. Is the customer a covered entity, business associate, or neither? Does your code path touch PHI? Is your hosting BAA-eligible (AWS, Azure, GCP all are; many smaller PaaS are not)? Write the answers down. They drive every downstream design choice.
- Scaffold Vue 3 with Vite + TypeScript + Pinia + vue-router + zod. Add
vite-plugin-pwaonly if offline is a real workflow (it usually is in clinical, almost never in pure-payer admin tools). - Stand up an auth boundary before you display anything. Bearer token in memory or
sessionStorage, neverlocalStorage. PKCE for any browser-based auth flow. - Build the FHIR resource validators (Zod, Valibot, or io-ts; we lean Zod for ergonomics) before you build the components. The validator at the boundary keeps every downstream component honest.
- Define the role policy as data, not as a wall of
v-ifstatements. The policy map mirrors the backend; the backend remains the enforcement boundary. - Write accessible templates from the first commit. Semantic HTML first, ARIA second. Run
@axe-core/vuein the local dev build and let it scream during development. - Add audit hooks at the network layer, not the component layer. Every fetch passes through one interceptor that emits a structured event to the backend audit endpoint; never log PHI client-side.
- Ship a feature flag for every clinical workflow you change. Clinical UIs cannot be reverted gracefully because clinicians build muscle memory in 24 hours; a flag gives you a kill switch when a workflow change lands badly.
Key features the engineer should ship
- FHIR R4 boundary validation with Zod or equivalent, with a path-only error log shape that never includes values
- Role-based UI gating mirrored from server policy, with a safe default of read-only on unknown roles
- WCAG 2.1 AA conformance via semantic HTML first, ARIA second,
<dialog>for confirmations,useId()for stable label binding - Telehealth lifecycle wired through composition-API hooks:
onMountedconnect,onBeforeUnmountdisconnect, vendor SDK in ashallowRef - Offline write queue in IndexedDB, replaying on
onlineevent, with PHI-aware logging discipline - SMART-on-FHIR launch with PKCE, sessionStorage token persistence, no PHI in URL parameters
- Audit-safe fetch interceptor that scrubs request and response bodies before any error reporter or analytics SDK sees them
- PWA installability so clinicians can pin the app to a workstation taskbar or tablet home screen and bypass browser chrome
Success stories (illustrative reference patterns from public reporting)
- Zocdoc: uses Vue.js across parts of its patient-scheduling frontend to deliver filtering, real-time availability, and booking interactions to consumers and clinicians. Public engineering writeups (e.g., the Zocdoc engineering blog) discuss their frontend stack at a high level.
- Livi (European telehealth): uses Vue across parts of its consultation web experience, with the composition API mapping cleanly to the chat + video + medical-record-viewer triad.
- Healthify (social care coordination): uses Vue for dynamic case-manager dashboards spanning referral, eligibility, and community-resource workflows.
- DentalHub multi-clinic management: uses Vue's reactive component model to unify schedule + billing + treatment surfaces across clinics.
The pattern these have in common is that the framework choice is rarely the story; the integration depth (FHIR adapters, vendor portal launches, role gating, accessibility, PHI hygiene) is the story. The frameworks are interchangeable in 2026; the engineering discipline is not.
How this page fits the broader healthcare-engineering stack
- The backend pairing for these patterns is covered in our Go for healthcare workers use-case, which shows the FHIR adapter, HL7 v2 listener, and audit-log shape that a Vue 3 frontend hits.
- For teams already on React, our Material UI for healthcare workers page covers the equivalent React-MUI patterns with the same compliance honesty.
- For a broader Vue-meets-healthcare overview (less engineering-deep, more decision-level), see our sibling Vue.js for healthcare page.
Frequently asked questions
Is Vue.js HIPAA-compliant?
Vue.js is a JavaScript framework. HIPAA does not apply to programming languages or frameworks; it applies to covered entities and business associates. Vue is HIPAA-neutral. You can build a HIPAA-compliant clinical application with Vue when your backend, your hosting (BAA-eligible cloud + signed BAA), and your operational practices are compliant. You can also use Vue for an app that handles no PHI at all and HIPAA never enters the conversation.
Can you build EMR apps with Vue.js?
Yes, including SMART-on-FHIR launched applications that run inside Epic App Orchard, Cerner / Oracle Health Code Console, and Athena Marketplace contexts. The Vue 3 composition API maps cleanly to the SMART launch handshake, FHIR resource fetching, role-based access gating, and clinical-grade accessibility. The hardest part is rarely the framework; it is the EHR vendor's portal-specific quirks, which Vue can handle.
How do you display FHIR data in Vue?
Use a typed validator (Zod is the ergonomic default in 2026) at the fetch boundary, narrow the FHIR resource shape to the subset your UI cares about, and let the rest pass through. The component reads validated, well-typed reactive state. Errors are logged by JSON path, never by value, so the error log never leaks PHI.
What is the best Vue UI library for healthcare apps?
There is no single "best." Vuetify, PrimeVue, Quasar, and Element Plus are the four broad-utility libraries with mature accessibility stories. For a healthcare-specific component set, Infermedica's open-source @infermedica/component-library (Vue 3 native, accessibility-aware) is the most narrowly aimed option. Whichever library you pick, audit components with @axe-core/vue or Lighthouse before shipping.
How do you make a Vue app Section 508 / WCAG 2.1 AA accessible?
Section 508 binds your app only if your customer is a federal agency (VA, IHS, military medicine). For private hospitals and most everyone else, your obligation is WCAG 2.1 AA. The Vue path: semantic HTML first (<button>, <dialog>, <form>, <table> with scope attributes), ARIA only as a corrective, useId() for SSR-stable label binding, focus management on modal open/close, color contrast at least 4.5:1, no information conveyed by color alone, and @axe-core/vue running in the dev build to catch regressions before they ship.
How do you handle PHI in a Vue.js app?
Four rules. PHI never goes in localStorage. PHI never appears in URL query strings (Google Analytics, server access logs, browser history all see those). PHI never appears in console.log, Sentry default capture, or third-party analytics SDKs (scrub at the interceptor). And PHI-bearing API responses are never runtime-cached by Workbox; cache the app shell, never the PHI.
Can Vue.js work offline for clinical charting?
Yes. The Vite PWA plugin plus Workbox plus IndexedDB plus a write-queue composable yields an offline-capable Vue 3 app that buffers vital-sign captures, MAR check-offs, and other writes during network blackouts, then replays them when the browser fires online. The composition-API hook pattern in this guide is one workable shape.
How do you embed a Vue app inside Epic or Cerner / Oracle Health?
Two paths. SMART-on-FHIR (the open standard, supported by Epic App Orchard / Showroom, Cerner Code Console, Athena Marketplace, and many smaller portals) launches your Vue app with an OAuth2 + PKCE handshake plus a FHIR base URL and a launch context. Iframe + postMessage is the legacy fallback for older portal versions; the parent portal sends context messages, your Vue app responds, and the bridge is type-safe if you define a message schema with Zod. SMART is preferred wherever the portal supports it.