Back to sh0
sh0

From Flat Lists to Stacks: Redesigning Our Entire UX

We threw away our flat app/database list UI and rebuilt around project-scoped stacks with a dual sidebar, context navigation, and cPanel-style sections.

Thales & Claude | March 25, 2026 11 min sh0
uxdashboardsveltearchitecturedesignproduct

Three days after we built the dashboard, we tore most of it apart.

The original UI -- built during Phases 12 through 14 -- worked. Apps lived in a flat list. Databases lived in another flat list. If you wanted to see which database belonged to which app, you had to remember. If you had 20 services running across 5 projects, the dashboard was a wall of cards with no structure.

We had built a functional interface. We needed an opinionated one.

On March 13, 2026, we redesigned the entire dashboard around a concept we call stacks: project-scoped groups of services with their own navigation, their own settings, and their own mental model. We replaced the 264-pixel text sidebar with a 56-pixel icon sidebar plus a 240-pixel context sidebar. We redesigned the stack detail page into cPanel-style sections. And we added a command palette.

This is the story of that redesign -- six phases completed in a single day.

The Problem with Flat Lists

Flat lists work when you have three apps. They break when you have fifteen.

Consider a typical self-hosted setup: a SaaS product with a Next.js frontend, a FastAPI backend, a PostgreSQL database, and a Redis cache. That is four services for one project. Add a staging environment and you have eight. Add a second product and you are at twelve to sixteen services, all jumbled together in a single list sorted alphabetically.

Every competing PaaS has this problem. Coolify groups services loosely, but the UI does not enforce it. Easypanel has projects, but the navigation still feels flat. Railway's project/service model is the closest to what we wanted, but their UI is optimised for cloud hosting, not self-hosted.

We wanted three things:

1. Grouping. Every service belongs to exactly one stack. A stack is a project -- your SaaS product, your blog, your internal tools. 2. Context. When you are working inside a stack, the sidebar shows that stack's services, not everything on the server. 3. Sections. Within a stack, services are categorised by role: frontend, backend, database, cache, storage, cron, domains, monitoring.

The Dual Sidebar

The most visible change was the sidebar. The old sidebar was 264 pixels wide, displayed navigation labels as text, and included a language selector. It was fine for a prototype. It was wasteful for a production tool where horizontal space matters.

We replaced it with a dual-sidebar layout:

Left sidebar: 56 pixels. Icon-only navigation with hover tooltips. Home, Stacks, Deploy, Backups, Monitoring, Settings, How it works. Fixed position, always visible on desktop, hamburger on mobile.

Right sidebar: 240 pixels. Contextual -- only appears when you are inside a stack. Shows a stack selector dropdown at the top, then a categorised list of services in that stack: apps with status dots, databases with engine badges, a storage section, and an "Add Service" link to the Deploy Hub.

<!-- ContextSidebar.svelte -->
<script lang="ts">
  let { stackId, apps, databases } = $props<{
    stackId: string;
    apps: App[];
    databases: Database[];
  }>();
</script>

```

The layout shift was simple in CSS but important in feel. The main content area went from lg:ml-64 to lg:ml-14 when outside a stack, and lg:ml-14 lg:pl-60 when inside one. The context sidebar is part of the stack's layout route, so it appears and disappears as you navigate -- no manual toggle, no state management.

Stack Routes and Navigation

The routing structure mirrors the mental model. Every stack has its own URL space:

/                           # Home: stack grid + unassigned services
/stacks/[id]                # Stack overview: cPanel-style sections
/stacks/[id]/services/[sid] # App detail (reuses all existing components)
/stacks/[id]/databases/[did]# Database detail
/stacks/[id]/settings       # Stack settings (name, color, members, delete)

The key architectural decision was reuse. The app detail page at /stacks/[id]/services/[sid] renders the exact same AppOverview, LogViewer, EnvEditor, DomainManager, and DeploymentList components we built in Phases 13 and 14. We did not rewrite a single app component. The stack route just wraps them in a different layout with the context sidebar.

<!-- stacks/[id]/+layout.svelte -->
<script lang="ts">
  import ContextSidebar from '$lib/components/layout/ContextSidebar.svelte';
  let { data, children } = $props();
</script>

{@render children()}
```

This meant the redesign was additive, not destructive. We added new routes and new layout components. The existing page components slotted in unchanged.

The Backend Changes

The stack redesign required two new API endpoints:

  • GET /projects/:id/apps -- list all apps belonging to a project
  • GET /projects/:id/databases -- list all databases belonging to a project

Both handlers followed the existing patterns in apps.rs and databases.rs: pagination, filtering by project_id, and the same response DTOs. The frontend API client gained a stacksApi module with listApps() and listDatabases() methods.

The backend already had a projects table (created in an earlier phase for organisational grouping), so we did not need schema changes. The stack concept is simply a project with frontend semantics layered on top.

The Home Page: From Stats to Stacks

The old home page showed four stat cards and a recent deployments list. Functional, but it answered the wrong question. Users do not open a PaaS dashboard wondering "how many apps do I have?" They open it to work on a specific project.

The new home page is a stack grid. Each stack is a card showing:

  • A color dot (user-chosen, for visual differentiation)
  • The stack name and description
  • Service count badges (apps, databases)
  • Status dots for each running service
  • A direct link to the stack overview

Below the grid, an "Unassigned Services" section catches any apps or databases that were created before the stack system existed. A "Create Stack" modal lets you name, describe, and colour-pick a new stack.

<!-- StackCard.svelte -->
<script lang="ts">
  let { stack } = $props<{ stack: Stack }>();
  let serviceCount = $derived(stack.apps.length + stack.databases.length);
</script>

{stack.description || 'No description'}

```

cPanel-Style Stack Sections

The stack detail page was where the redesign became opinionated. Instead of listing services in a flat grid, we divided them into eight sections inspired by cPanel's category model:

SectionContainsExample
FrontendStatic sites, SPAs, SSR appsNext.js, SvelteKit, Vite
BackendAPI servers, workersFastAPI, Express, Go services
DatabaseRelational and document storesPostgreSQL, MySQL, MongoDB
CacheIn-memory storesRedis, Memcached
StorageObject storage, volumesMinIO, mounted volumes
CronScheduled tasksBackup schedules, cleanup jobs
DomainsDomain managementCustom domains, SSL certs
MonitoringMetrics and alertsCPU/memory gauges, alert rules

Each section is a card with a gradient icon, a count badge, and a list of up to three services with status dots. If a section is empty, it shows a dashed border, a description of what belongs there, and an "Add" button that links to the Deploy Hub pre-filtered to the relevant category.

{#each sections as section}
  {#if section.services.length > 0}
    <div class="rounded-lg border border-[var(--border)] shadow-sm p-4">
      <div class="flex items-center gap-3 mb-3">
        <div class="p-2 rounded-lg bg-gradient-to-br {section.gradient}">
          <svelte:component this={section.icon} size={20} class="text-white" />
        </div>
        <h3 class="font-medium">{t(section.labelKey)}</h3>
        <Badge>{section.services.length}</Badge>
      </div>
      {#each section.services.slice(0, 3) as svc}
        <a href="/stacks/{stackId}/services/{svc.id}" class="flex items-center gap-2 py-1">
          <StatusDot status={svc.status} />
          <span class="text-sm">{svc.name}</span>
        </a>
      {/each}
    </div>
  {:else}
    <div class="rounded-lg border-2 border-dashed border-[var(--border)] p-4 opacity-60">
      <p class="text-sm text-[var(--text-secondary)]">{t(section.emptyKey)}</p>
      <a href="/deploy?stack={stackId}&category={section.category}"
         class="text-sm text-[var(--accent)]">
        + {t('stacks.addService')}
      </a>
    </div>
  {/if}
{/each}

The classification logic was extracted into a shared stack-sections.ts module. It categorises services based on their tech stack, engine, or tags -- a Next.js app goes to Frontend, a FastAPI service goes to Backend, a Redis instance goes to Cache. This centralised the logic so both the stack overview page and the context sidebar could use it.

The Add Service Modal (and Its Death)

Phase 5 of the redesign created an AddServiceModal.svelte with three tabs: Services (quick-pick databases), Templates, and Custom deploy. It worked, but it was the wrong abstraction.

By Phase 8, when we built the Deploy Hub (covered in the next article), the modal became redundant. The Deploy Hub does everything the modal did, but better -- with 183 options, search, categories, and form components. So we deleted the modal entirely and replaced the "Add Service" button in the context sidebar with a simple link: . The URL parameter ?stack= pre-selects the stack in the Deploy Hub, so the flow is seamless: click "Add Service" in a stack, land on the Deploy Hub with that stack already selected, pick what you want to deploy.

This is a pattern we came back to repeatedly: build the quick solution, then replace it with the proper one and delete the scaffolding. The willingness to delete working code is what keeps a codebase clean.

The Command Palette

We added a Cmd+K (or Ctrl+K on Linux) command palette for keyboard-driven navigation. Type a stack name, an app name, or a page like "settings" and jump directly there. The implementation is a modal with a text input, fuzzy search across stacks and services, and arrow-key navigation with Enter to select.

This was a small feature in terms of code but a large one in terms of the user experience we were signalling: sh0 is a developer tool, and developer tools should respect keyboard workflows.

Mobile Responsiveness

The dual sidebar posed an obvious mobile challenge. On screens narrower than lg (1024px), the icon sidebar collapses behind a hamburger menu, and the context sidebar becomes a slide-in drawer triggered by a swipe or button tap.

The slide-in drawer uses a Svelte transition:

{#if sidebarOpen}
  <div class="fixed inset-0 z-40 bg-black/50" onclick={() => sidebarOpen = false} />
  <aside transition:fly={{ x: -240, duration: 200 }}
         class="fixed left-0 top-0 z-50 w-60 h-full
                bg-[var(--bg-secondary)] border-r border-[var(--border)]">
    <ContextSidebar {stackId} {apps} {databases} />
  </aside>
{/if}

On mobile, the context sidebar is a full-screen overlay with a semi-transparent backdrop. Tap the backdrop to close. The main content never resizes -- only the overlay appears and disappears.

Deleting Legacy Routes

After the redesign, the old /apps, /databases, and /templates routes were dead code. They still worked, but they represented the flat-list model we had abandoned. We considered keeping them as alternative views, but that would mean maintaining two navigation models, two sets of UI states, and two mental models for the user.

We deleted them. The stacks model is the only model. This was a deliberate product decision: we would rather have one great navigation paradigm than two mediocre ones.

The i18n Cost

Every new component meant new translation keys. The stack redesign added a comprehensive stacks section to all five locale files, plus nav.home, nav.stacks, and nav.domains. We also added welcome, how_it_works, and stack_sections sections during the onboarding work that accompanied the redesign.

The total was roughly 65 new keys per language. Because we had built the i18n system in Phase 12 and maintained the discipline of adding translations alongside components, this was a copy-paste-translate operation, not a retrofit.

What Drove the Redesign

Looking back, the redesign was inevitable. We built the flat-list UI first because it was the fastest path to a working dashboard. But the moment we started using the dashboard to manage real services, the flat model's limitations were obvious.

The stack model is not original -- it draws from Railway's projects, Vercel's teams, and cPanel's categories. What we did differently was combine all three: project scoping (Railway), a compact icon sidebar (Vercel), and category sections (cPanel). The result is a dashboard that scales from "I have one WordPress site" to "I run 50 microservices across 8 projects" without changing the navigation paradigm.

The entire redesign -- six phases, 11 new files, 13 modified files, backend API additions, five-language i18n updates -- was completed in a single day. Not because we rushed. Because the component model from Phases 12-14 was solid enough that the new layout could reuse everything.

That is the real lesson: if your components are well-abstracted, a UI redesign is a layout exercise, not a rewrite.

---

Next in the series: The Deploy Hub: 183 Options, One Page -- how we built a Softaculous-style deployment experience with 183 options, 7 form components, and a split-panel UX.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles