Engineering

Why We Rebuilt the Admin Panel from Next.js to Vite (And Killed 19,000 Lines of Code)

The Lumo admin panel started as a Next.js app. Then we rebuilt it as a Vite + TanStack Router SPA, eliminated 13 full-table scans, and shipped a control room that actually loads.

By Lumo EngineeringFeb 11, 20263 min read

The Lumo admin panel started its life as a Next.js app. Server components, API routes, the whole App Router experience. It worked. Then it got slow. Then it got complicated. Then I spent a weekend rewriting it from scratch in Vite and deleted 19,000 lines of code.

This is that story.

Why Next.js Was Wrong for This

Next.js is phenomenal for marketing sites and public-facing apps where SEO matters, static generation saves compute, and the server/client boundary creates real value. The Lumo marketing site runs on Next.js and it's perfect there.

An admin panel is none of those things. It's:

  • 100% authenticated — no public pages, no SEO, no crawlers
  • 100% client-side data — everything comes from Convex subscriptions, not server-rendered HTML
  • 100% interactive — tables, modals, inline editing, real-time updates

We were using Next.js server components to render a loading skeleton, which then hydrated on the client, which then subscribed to Convex queries that replaced the skeleton with real data. Three rendering phases for something that should be one.

The Vite + TanStack Router Stack

The rebuild uses:

  • Vite — instant HMR, no webpack config archaeology
  • TanStack Router — file-based routing with type-safe route params
  • Convex — same backend, same subscriptions, no server middleware layer
  • Tailwind CSS v4 — the new CSS-first config with @theme blocks

The entire admin is a static SPA. Build it, deploy it to any CDN, done. No server runtime, no edge functions, no cold starts.

apps/admin/src/routes/
  index.tsx          # Dashboard overview
  users.tsx          # User management
  subscriptions.tsx  # Tier & feature limits
  builds.tsx         # Release management
  errors.tsx         # Error monitoring
  comments.tsx       # Blog comment moderation
  contacts.tsx       # Contact form submissions
  ...13 routes total

Every route is a single file that imports Convex hooks and renders a UI. No loaders, no actions, no server/client split. Just components and data.

The Backend Crisis: 13 Full-Table Scans

Here's the part that keeps me humble. While rebuilding the frontend, I discovered that the admin dashboard was firing 13 unbounded .collect() queries simultaneously on load. Every table scan was reading every document in the table. For a fresh database, this was fine. For a production database with real data, it was a Convex timeout waiting to happen.

The offenders:

// Before: scan everything, filter in memory
const allUsers = await ctx.db.query("users").collect();
const recentUsers = allUsers.filter(u => u.createdAt > thirtyDaysAgo);
 
// After: use an index, read only what you need
const recentUsers = await ctx.db
  .query("users")
  .withIndex("by_created_at", q =>
    q.gte("createdAt", thirtyDaysAgo)
  )
  .collect();

Every summary query — user growth timeline, error counts, notification stats, build summaries — got the same treatment:

  1. Add an index for the query pattern
  2. Time-bound the query so it only reads recent data
  3. Split expensive aggregations into focused, single-purpose queries

New indexes added:

  • by_created_at on notifications
  • by_source_created_at on errors
  • Time-bounded variants on every summary query

The dashboard went from "sometimes times out" to "loads in under 200ms." The fix wasn't clever engineering — it was the database equivalent of "stop reading the entire book when you only need the last chapter."

Error Monitoring: The Source Field

While optimizing queries, we also overhauled error logging. The errors table now tracks source:

// Schema
source: v.union(v.literal("client"), v.literal("server"))

Client errors come from the mobile app and web frontend via a new public reportClientError mutation (rate-limited to 20/min per user, with input truncation to prevent abuse). Server errors come from internal catches.

The admin panel shows this with colored badges, a source filter dropdown, and expandable detail rows with full stack traces and context JSON. When something breaks at 2 AM, we can tell within seconds whether it's a client rendering bug or a server mutation failure.

The Net Result

Before: Next.js admin app, 13 full-table scans, server components rendering client-side data, slow builds, complex deployment.

After: Vite SPA, index-targeted queries, instant HMR, CDN-deployable, loads in under 200ms.

Code delta: 20,802 insertions, 40,216 deletions. Net -19,414 lines. The best code is the code you delete.


Abdul Rafay Founder, Syntax Lab Technology Still checking the Convex dashboard at 3 AM, but now it loads fast enough to not add to my anxiety

Loading...