IoT telemetry on React Router v8, Postgres (Neon) and Clerk
Type-checked against the real SDKs, migration applied to a live Postgres (Neon), connection clients load-tested, then tracked for upstream drift and re-verified when it moves. How we verify
session validation runs in server components and route handlers, not at the edge
What you're getting
React Router v8 (framework mode) — SSR, config/file routes under app/, loaders/actions, and resource routes for API endpoints.
Postgres on Neon via Drizzle ORM and the postgres-js driver.
Clerk — hosted identity (sign-in UI, sessions, user management) mounted via middleware + provider.
IoT telemetry — user-owned devices, append-only sensor readings, threshold alert rules, and fired alert instances.
Setup
bun add react-router react react-dom drizzle-orm postgres @clerk/nextjsDATABASE_URLNeon pooled (-pooler) connection stringNEXT_PUBLIC_CLERK_PUBLISHABLE_KEYCLERK_SECRET_KEYCLERK_WEBHOOK_SECRETsvix secret that verifies Clerk webhook signaturesApply the schema with bunx drizzle-kit push
Initialization
Database client
IoT telemetry schema: devices, readings, alert rules & alerts
Devicesuser-owned device registry with a unique device_key and an online/offline status CHECK
Sensor readings (time-series)append-only rows of metric + numeric value per device, indexed for time-range rollups
Alert rules & alertsper-device threshold rules (gt/lt/gte/lte comparator CHECK) and the alert instances they fire, with firing/resolved lifecycle
Verified identity sync (Clerk)
Deploy targets
Decisions and compatibility
Framework mode (not data/library mode): routes live under app/, declared in app/routes.ts. API endpoints are resource routes (a route module exporting loader/action but no default component).
prepare: false is mandatory — Neon's pooled endpoint is PgBouncer in transaction mode, where server-side prepared statements break across the pool.
Drizzle is paired here (not Prisma): Prisma's prepared-statement reliance is incompatible with transaction-mode pooling.
Hosted: Clerk owns identity and does NOT create a local `user` table. Store `clerk_user_id` as text without a foreign key, or sync Clerk users into a local table via webhook before relying on FKs to `user`.
devices.device_key carries a unique constraint — it is the physical device's identity token and must be generated once at provisioning time, never regenerated.
sensor_readings is append-only (no update path, composite index on device_id + recorded_at) — roll up by device + time window for dashboards rather than mutating any running aggregate.
Clerk is a hosted identity provider and does not create a local `user` table. This schema's foreign keys to `user` assume a local identity table (as Better Auth provides). With Clerk, store `clerk_user_id` as a text column without a foreign key, or sync Clerk users into a local `users` table via webhook before relying on these FKs.