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
Recommended Stack: Phoenix + Inertia + Svelte¶
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 |