DEV Community

Cover image for Building an Interactive FDI Tooth Charting Engine with Next.js and Supabase
Dentiva
Dentiva

Posted on Originally published at dentiva.in

Building an Interactive FDI Tooth Charting Engine with Next.js and Supabase

Most hospital EMR systems treat clinical encounters as structured text or generic intake forms. When building Dentiva, a clinical management platform for dental practices, we hit an immediate engineering wall: dentistry does not run on text boxes. It runs on spatial, tooth-by-tooth visual documentation.

A single adult mouth has 32 teeth, each with five distinct anatomical surfaces (occlusal, mesial, distal, buccal, lingual), root structures, and periodontal pocket measurements. If a dentist spends two minutes hunting through dropdown menus to mark a Class II composite restoration on tooth 46, they will close the laptop and go back to physical paper charts.

Here is how we architected our interactive FDI charting canvas using Next.js, SVG paths, and Supabase without choking client-side performance.

1. Why HTML Canvas Failed and SVG Won
Our initial prototype used HTML5 Canvas to render teeth outlines. While canvas is great for games, it turned out to be the wrong tool for an interactive clinical odontogram.

Every tooth needs hover events, distinct surface click triggers, active selection states, and instant visual updates when adding procedures (like crowns, root canals, or extractions). Managing hit-testing for 160 surface polygons on a raw canvas created bloated math routines and messy coordinate scaling across mobile tablets and desktop monitors.

We shifted entirely to componentized, declarative SVGs. Each tooth is an isolated React component mapped to the international FDI two-digit notation (teeth 11 to 48 for adults, 51 to 85 for pediatric arches):

interface ToothSurfaceProps {
  toothId: number;
  surface: 'occlusal' | 'mesial' | 'distal' | 'buccal' | 'lingual';
  status: SurfaceCondition;
  onSurfaceClick: (toothId: number, surface: string) => void;
}

export const ToothSurface = ({ toothId, surface, status, onSurfaceClick }: ToothSurfaceProps) => {
  return (
    <path
      d={surfacePaths[surface]}
      className={getSurfaceColor(status)}
      onClick={() => onSurfaceClick(toothId, surface)}
    />
  );
};
Enter fullscreen mode Exit fullscreen mode

Using vectors gave us sharp rendering on high-DPI displays, native DOM event handling, and tiny bundle sizes without canvas repaint loops.

2. Managing State: Keeping the Operatory Fast
Dentists mark conditions rapidly during chairside exams. If selecting a molar triggers a top-level React re-render of the entire 32-tooth odontogram, frame drops become noticeable.

To prevent this, we decoupled the visual active state from persistent database writes.

  • Local Component State: Surface toggles, active tools (such as cavity prep, fracture, crown, or extraction), and tooth multi-select run through an unopinionated client store. Individual tooth SVGs only re-render if their own props change.
  • Optimistic UI: When an operatory assistant records a root canal procedure, the tooth graphics update instantly on screen. We do not block the UI waiting for a backend round trip.

3. Database Schema: Normalized Tables vs JSONB
A common design mistake in healthcare tools is dumping the entire chart state into a single unstructured JSON blob column. While quick to set up initially, it makes running clinical queries later almost impossible (for example, querying every patient who received an endodontic treatment on tooth 16 this month).

In PostgreSQL on Supabase, we split our data model into structured clinical entities:

  • clinical_charts Table: Houses chart metadata, patient ID, provider ID, and timestamps.
  • chart_entries Table: Tracks discrete procedures and conditions tied to a specific tooth code and surface.
create table public.chart_entries (
  id uuid primary key default gen_random_uuid(),
  chart_id uuid references public.clinical_charts(id) on delete cascade,
  tooth_number smallint not null check (tooth_number between 11 and 85),
  surface text check (surface in ('occlusal', 'mesial', 'distal', 'buccal', 'lingual', 'root', 'all')),
  condition_code text not null,
  notes text,
  created_at timestamptz default now()
);
create index idx_chart_entries_lookup on public.chart_entries(chart_id, tooth_number);
Enter fullscreen mode Exit fullscreen mode

By indexing chart_id and tooth_number together, initial load times for complete dental histories remain under 20 milliseconds even as records grow over years of visits.

4. Handling Offline Resilience Chairside
Clinic internet connections in operatory operatories are notoriously erratic. If a broadband connection hiccups mid-procedure, chart data cannot vanish.

We set up background sync queues using local browser storage. Any change made chairside is written locally first with a pending mutation tag. When network connectivity restores, the mutations reconcile against Supabase using idempotency keys, avoiding duplicated procedure entries.

Building clinical tooling requires prioritizing latency and tactile responsiveness above everything else. Moving to lightweight SVG components and predictable PostgreSQL schemas let us replace bulky paper charting with a digital workflow that feels instant in practical day-to-day clinic operations.

Top comments (0)