Thursday, April 16, 2026

The Stack Simplification Trap

Dan Davidson
Dan Davidson
5 min read
A developer stands at a fork in the road — one path leads to towering server complexity, the other to a simple, focused workspace. Illustration for The Stack Simplification Trap.
The more impressive path isn't always the right one. Sometimes the best architecture is the one you didn't build.

Simple code that ships and works is not a consolation prize. It is the goal.

There's a moment in every project where complexity becomes seductive.

You're moving fast, the product is taking shape, and then — almost imperceptibly — the architecture starts whispering to you. You're going to need a dedicated API layer for this. You'll want centralized state management before this gets out of hand. A native app would really round this out.

The whisper sounds like experience. It sounds like foresight. It sounds like the kind of thinking that separates senior engineers from junior ones.

Sometimes it is. More often, it's a trap.

The Project

UtahDirect is a local business community platform — a place for Utah businesses to get found, build brand awareness, and help people buy local. I'm building it with a small, unconventional team: students from the Davis School District's Catalyst Center, who contribute product ideas and serve as our boots-on-the-ground sales team.

The stack is Next.js with the App Router, Supabase for auth and database, and Tailwind CSS. Straightforward. Intentionally so.

Getting there required saying no — repeatedly — to my own instincts.

Trap #1: The Dedicated Backend

Early on, I seriously considered writing a dedicated Koa or Express backend. It felt like the "real" way to build a production API. Separation of concerns. Clean service boundaries. The kind of architecture you'd draw on a whiteboard and feel good about.

But I kept asking myself: what problem am I actually solving?

UtahDirect needed authentication, a database, and server-side logic. Supabase provides all three — with Row Level Security, a PostgREST API layer, edge functions when I need them, and a developer experience fast enough to keep pace with student collaborators who are still learning.

A custom Express backend would have been an entire second codebase to maintain, document, deploy, and debug. It would have slowed down every contributor and added failure surface for no measurable benefit at this stage.

I closed the Koa docs and moved on.

Trap #2: Redux

I reached for Redux twice during development. The first time, I got as far as installing the package.

Next.js App Router with Server Components changes the state management equation in ways that aren't immediately obvious. When your data fetching happens on the server and your components are leaner on the client, a lot of the problems Redux was invented to solve simply don't exist. The state that remains is mostly local — and React handles local state just fine.

The less-is-more principle isn't laziness. It's recognizing that every abstraction you add is a layer someone has to understand, debug, and maintain. For UtahDirect's current complexity, the built-in tools are the right tools.

Redux is still in my back pocket. It just hasn't earned its place in this codebase yet.

Trap #3: The Native App

The students ask about this regularly: when are we building a native app?

It's a fair question. A native app would expand reach, improve the mobile experience, and frankly, it would be fun to build. I understand the appeal — they're thinking about the product's potential, which is exactly the kind of thinking I want to encourage.

But potential is not the same as priority.

Right now, UtahDirect needs to work. The MVP features need to be solid. The web experience needs to be something people actually use before we start building platforms on top of it. A native app before that would be architecture for architecture's sake — building for the version of the product we imagine instead of the version we have.

We'll get there. When the product earns it.

The Code That Proves the Point

The most honest example of this philosophy lives in the admin duplicates endpoint. Here's the relevant section:

// Fetch all leads with normalized columns
// We'll group them in application code since Supabase doesn't easily support
// window functions for grouping duplicates
const { data: allLeads, error: fetchError } = await supabase
  .from("leads")
  .select(`
    id,
    name,
    address1,
    city,
    state,
    zip,
    phone,
    created_at,
    name_normalized,
    address1_normalized,
    zip_normalized
  `)
  .order("name_normalized")
  .order("address1_normalized")
  .order("zip_normalized")
  .order("created_at", { ascending: true });

// Group duplicates by normalized key
const groupsMap = new Map<string, any[]>();
allLeads?.forEach((lead) => {
  const key = `${lead.name_normalized}|${lead.address1_normalized}|${lead.zip_normalized}`;
  if (!groupsMap.has(key)) {
    groupsMap.set(key, []);
  }
  groupsMap.get(key)?.push(lead);
});


A senior engineer's first instinct here is probably a raised eyebrow. Why not a SQL GROUP BY? A window function? A database RPC? Those are legitimate questions — and in many contexts, the right ones.

But look at what this code actually is: an admin-only tool, running on a bounded dataset, behind authentication, used by one person. The comment isn't a confession — it's documentation of a deliberate tradeoff. The Supabase client path of least resistance is "fetch rows, group in JavaScript," and for this use case, that path is correct.

One obvious code path. Easy to reason about. No migration to a bespoke SQL function. No fighting PostgREST for aggregate query support. It will look unsophisticated long before it stops working — and when it does stop working, memory pressure and latency will tell me clearly. At that point, I'll swap the middle section for a SQL query without touching the UI contract.

That's the plan. It's not a gap in the architecture. It's the architecture.

The Principle

Complexity has a cost that doesn't show up in the initial commit. It shows up at 11pm when something breaks and you're tracing through four abstraction layers to find it. It shows up when a new contributor needs three hours of onboarding just to understand why the state lives where it does. It shows up when you're six months in and the "scalable" solution you built for a problem you don't have yet is the thing slowing you down.

Simple code that ships and works is not a consolation prize. It is the goal.

The trap isn't complexity itself — complex problems sometimes require complex solutions. The trap is complexity that arrives before the problem does. It's the dedicated backend before you have the traffic to justify it. It's the native app before the web product is proven. It's Redux before you've felt the pain of not having it.

The question I've started asking before every architectural decision is this: what does the product actually need right now, and what is the simplest thing that delivers it?

Usually, the simplest thing is more than enough.

Dan Davidson is a full-stack engineer and the founder of UtahDirect, a local business community platform built in partnership with students at the Davis School District's Catalyst Center.

Frequently asked questions