Skip to content

Frontend Stack Research

Research and decision framework for choosing a frontend stack for large-scale web applications.

Context

Requirements:

  • Real-time reactivity (like Phoenix LiveView)
  • Excellent mobile reliability (no WebSocket issues)
  • Scalable for many clients globally
  • Great user experience ("app-like" feel)
  • Preferably tied to backend (avoid separate API maintenance)

Why Not LiveView?

Phoenix LiveView has WebSocket reliability issues on mobile:

  • iOS aggressively kills background WebSocket connections
  • Users far from server experience latency on every interaction
  • Constant reconnects on mobile feel broken

Options Evaluated

Hypermedia Approaches

Approach Transport Mobile Reliability Client Reactivity
htmx HTTP request/response Excellent Limited
Datastar SSE (persistent) Mixed Good
LiveView WebSocket Poor on mobile Excellent

JavaScript Frameworks

Framework Notes
SvelteKit Best DX, smallest bundles, compiles away framework
Next.js (React) Largest ecosystem, most jobs
Solid.js Best performance benchmarks
Nuxt (Vue) Easier than React, good ecosystem

Hybrid: Inertia.js

Bridges backend frameworks with JS frontends without building a separate API.

┌─────────────────────────────────────┐
│         Single Server               │
│  ┌─────────────┐  ┌──────────────┐  │
│  │ Controllers │──│ Svelte/Vue   │  │
│  │ (Elixir/Go) │  │ Components   │  │
│  └─────────────┘  └──────────────┘  │
└─────────────────────────────────────┘

Pros:

  • No API to maintain
  • Client-side reactivity
  • SPA-like navigation
  • Server-side routing & auth
  • HTTP-based (no WebSocket issues)

Cons:

  • SSR requires Node.js process
  • Large payloads can be slow (props embedded in HTML)
  • Error handling shows modal (jarring UX)
  • Still maturing ("not a finished product yet" - Laravel creator)

Official adapters: Laravel, Rails, Phoenix (v2.5.1)

htmx vs Inertia+Svelte Deep Comparison

Mental Model

htmx:

User clicks → HTTP Request → Server renders HTML → Swap DOM
        └─────── EVERY interaction that needs data ───────┘

Inertia + Svelte:

User clicks → Svelte handles it locally (instant)
                    │
                    ├─ UI state? → No request needed
                    └─ Need data? → HTTP → Update props → Re-render

Scenario Comparison

Scenario htmx Inertia + Svelte
Tab switching Server round-trip (~100-300ms) Instant (0ms) if data preloaded
Form validation Server per validation, or add Alpine.js Client-side instant + server on submit
Drag and drop Needs SortableJS + custom JS Native Svelte with animations
Modal open Server request to open Instant
Search/filter Each change = request Client-side instant (small datasets)

Tradeoffs Summary

Aspect htmx Inertia + Svelte
Instant UI feedback No Yes
Complex client state Painful Native
Animations Basic CSS Full control
Optimistic updates Manual Built-in
SSR/SEO Free Requires Node.js
Bundle size growth None Grows with app
Backend dev friendly Very Needs frontend skills
"App" feel Website feel Native app feel

When to Use Each

htmx better for:

  • Content-heavy sites (blogs, docs, marketing)
  • Simple CRUD (admin panels)
  • Backend-focused teams
  • SEO critical pages
  • Progressive enhancement needed

Inertia + Svelte better for:

  • App-like interactions (dashboards, SaaS)
  • Complex forms (multi-step, validation)
  • Rich interactions (drag-drop, animations)
  • Mobile web apps needing native feel
  • Complex client-side state

For large-scale project with great UX requirements:

# mix.exs
{:inertia, "~> 2.5"}
# Controller
def show(conn, %{"id" => id}) do
  user = Accounts.get_user!(id)
  render_inertia(conn, "Users/Show", %{user: user})
end
<!-- pages/Users/Show.svelte -->
<script>
  export let user
</script>

<h1>{user.name}</h1>

Hybrid Approach: htmx + Inertia

Can mix both in same Phoenix app:

Phoenix
├── Marketing pages (htmx) - simple, SEO
├── Admin CRUD (htmx) - quick to build
└── App dashboard (Inertia + Svelte) - rich UX

UI Consistency Solution: Web Components

Build shared components that work in both HEEx (htmx) and Svelte (Inertia).

Svelte compiles to Web Components:

<!-- AppButton.svelte -->
<svelte:options customElement="app-button" />

<script>
  export let variant = 'primary'
</script>

<button class="btn btn-{variant}">
  <slot />
</button>

Usage in HEEx (htmx):

<app-button variant="primary" hx-post="/save">Save</app-button>

Usage in Svelte (Inertia):

<app-button variant="primary" on:click={save}>Save</app-button>

Web Component Build Options

Tech Bundle Size DX Notes
Vanilla JS 0KB Verbose Simple components only
Lit ~5KB Great Full design system
Svelte ~2KB/component Best Already using Svelte

Project Structure for Hybrid

project/
├── packages/
│   └── ui/                      # Web Components library
│       ├── src/components/
│       │   ├── AppButton.svelte
│       │   ├── AppCard.svelte
│       │   └── index.js
│       └── vite.config.js
│
└── app/                         # Phoenix app
    ├── lib/app_web/
    │   └── templates/           # HEEx (htmx pages)
    └── assets/
        ├── js/inertia/          # Svelte (Inertia pages)
        └── vendor/
            └── ui-components.js  # Built Web Components

Design System with CSS Tokens

/* tokens.css */
:root {
  --color-primary: #3b82f6;
  --color-surface: #ffffff;
  --color-border: #e5e7eb;
  --radius-md: 0.5rem;
  --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
  --space-4: 1rem;
}

Components use tokens → consistent across all pages.

Web Component Gotchas

Issue Solution
Shadow DOM styling Use CSS custom properties
Form participation Use ElementInternals or disable Shadow DOM
SSR Web Components need JS; use slot fallbacks
Svelte bind: Doesn't work; use events instead

For form inputs, disable Shadow DOM:

<svelte:options customElement={{ tag: "app-input", shadow: "none" }} />

Decision Summary

Priority Recommendation
Best UX, large scale Phoenix + Inertia + Svelte
Simple apps, fast dev Phoenix + htmx
Mixed requirements Hybrid with Web Components
Maximum consistency Web Components design system

References