HTML Partials + Server Reducers: An Alternative to React-Style SPAs
Learn how we escaped the SPA vs. server-side dilemma by building UI that feels instant to users but stays simple for developers. No frameworks, no hydration, no client-side state headaches.
Struggling with React hydration errors, heavy Next.js bundles, and duplicated client/server logic? We were too. Our HTML‑first, Server‑Side Reducers (SSR+) approach—similar to HTMX in transport and to React’s useReducer in state transitions—avoids hydration entirely. We render HTML fragments on the server, control polling via headers, and use SSR-friendly patterns that improve Core Web Vitals and Time to Interactive.
We work with several large GitHub projects in closed beta, and this architecture has been a game changer for how fast we render and show data to users—here’s the exact recipe we used so you can adopt it in your stack.
From Local-First to SSR: Picking the Right Weapon
Over my career I’ve built very different kinds of apps, and I’ve never been comfortable with the default community pattern of SPA + REST API + hydration. In practice I keep returning to two architectures that sit at opposite ends of a spectrum:
- Full client-side apps, local/offline-first, where the backend is optional and helps sync data. Think CRDTs for conflict-free replication and user-owned state.
- Server-side rendered UI, where the entire UI is described and rendered on the server, and the client only adds small, UX‑polish interactions.
Both deliver best‑in‑class performance with simplicity at the core. The deciding factor is data ownership:
- If the app primarily holds one‑person‑centered data with sporadic or partial sharing, go local‑first.
- If the data is owned and used by multiple users with collaboration at the center, prefer a centralized model with UI generated on the server.
That’s exactly how we chose Cimatic’s frontend: collaborative, multi‑user, server‑owned truth. We expand classic Server-Side Rendering to handle fine-grained user interactions on the server and call our approach Server-Side Reducers (SSR+ for short)—it gives us speed, determinism, and a calmer system our users can trust.
Server-Side Reducers combine the familiar mental model of React's useReducer
with server authority. We render HTML fragments on the server, process typed/reducer-style actions on the server, and control update cadence with headers. The browser patches fragments, keeps a couple of timers, and stays out of the way. When data changes, updates are deterministic. When nothing changes, nothing repaints. If the network misbehaves, a single partial quietly pauses—the page doesn’t crash.
If you’ve used htmx, our transport will feel familiar: HTML-first. The differences are in the discipline: server-validated actions, server-controlled polling, and a single endpoint per page (GET to load, POST to progress and interact) that make the system calmer, faster, and easier to reason about.
The concept in 30 seconds: counter example
Think of each partial as a self-contained component identified by its data-partial
type. We wire up partial behavior by scanning for these attributes on page load. Actions are dispatched through a unified system that batches requests, reduces state server-side, and swaps HTML fragments.
- One URL: GET paints; POST progresses.
data-partial="Counter"
defines partial type.- Partial system: auto-wires actions, batching, and fragment swapping.
- State co-location: each instance carries its own state in
data-state
.
Rendered HTML can be simple like this:
<div id="page">
<h1>Counters</h1>
<div id="counter-A" data-partial="Counter" data-state='{"count":0}'>
<div class="count">0</div>
<button data-action="inc">+</button>
<button data-action="dec">−</button>
</div>
<div id="counter-B" data-partial="Counter" data-state='{"count":5}'>
<div class="count">5</div>
<button data-action="inc">+</button>
<button data-action="dec">−</button>
</div>
</div>
Partial system auto-wires all partials:
function init() {
// Wire up all partials on the page
document.querySelectorAll("[data-partial]").forEach(wirePartial);
}
function wirePartial(partial) {
const type = partial.dataset.partial;
partial.querySelectorAll("[data-action]").forEach((actionEl) => {
const action = actionEl.dataset.action;
actionEl.addEventListener("click", (e) => {
e.preventDefault();
const state = JSON.parse(partial.dataset.state || "{}");
dispatchAction({ targetId: partial.id, type, action, state });
});
});
}
async function dispatchAction({ targetId, type, action, state }) {
try {
const res = await fetch(location.pathname, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Partial": "action",
},
body: JSON.stringify({ targetId, type, action, state }),
});
const { html } = await res.json();
// Replace the partial with new HTML (reinitializes event listeners)
// For smoother updates, consider using DOM morphing libraries like idiomorph
document.querySelector(`#${targetId}`).outerHTML = html;
// Re-wire the new partial since outerHTML replaces the entire element
const newPartial = document.querySelector(`#${targetId}`);
if (newPartial) wirePartial(newPartial);
} catch (error) {
console.error("Action failed:", error);
}
}
// Initialize on page load
document.addEventListener("DOMContentLoaded", () => init());
Server handles any partial actions and run the reducer:
const reducers = {
Counter: (prev, { action }) => {
switch (action) {
case "inc":
return { count: (prev.count || 0) + 1 };
case "dec":
return { count: (prev.count || 0) - 1 };
}
},
};
app.get("/counters", (req, res) => {
res.type("html").send(
renderPage([
{ id: "counter-A", type: "Counter", state: { count: 0 } },
{ id: "counter-B", type: "Counter", state: { count: 5 } },
]),
);
});
app.post("/counters", async (req, res) => {
const { targetId, type, action, state } = req.body || {};
// Validate required fields and action allowlist
if (!type || !targetId || !action) {
return res.status(400).json({ error: "Missing required fields" });
}
const reducer = reducers[type];
if (!reducer) {
return res.status(400).json({ error: "Unknown partial type" });
}
// Validate action is allowed for this partial type
const allowedActions = { Counter: ["inc", "dec"] };
if (!allowedActions[type]?.includes(action)) {
return res.status(400).json({ error: "Invalid action for partial type" });
}
// Run reducer to get new state
const nextState = reducer(state || {}, { action });
// Render updated HTML fragment
const html = renderPartial(type, { id: targetId, state: nextState });
res.json({ html });
});
Server partial renderer returns minimal HTML:
function renderPartial(type, { id, state }) {
if (type === "Counter") {
return `
<div id="${id}" data-partial="Counter" data-state='${JSON.stringify(state)}'>
<div class="count">${state.count}</div>
<button data-action="inc">+</button>
<button data-action="dec">−</button>
</div>
`.trim();
}
}
How this simplified system works:
- GET request → Server returns full HTML page.
- Declarative
data-partial="Counter"
defines behavior. - Generic action handler works for all partial types and actions.
- User interaction → Client POSTs to the same page URL with
X‑Partial: action
. - Server → Validates action, runs reducer, renders fragment.
- Simple swap DOM with ready to use HTML from action response.
- State co-location in the DOM; No client-side state store.
- Progressive enhancement → Works without JS, enhanced with JS.
That’s the minimal counter—now let’s see how it scales in production.
Real-world SSR+: Powering Cimatic's dashboard architecture
This SSR+ pattern powers Cimatic's production dashboards, delivering super-responsive UX even during long-running background operations. Here's how we apply it in practice:
CI/CD Pipeline Dashboard
Our main dashboard shows live pipeline analytics with multiple concurrent partials:
- Pipeline overview: Real-time status of running workflows
- Metrics widgets: CI Vitals (WET, NFR, POT) updating as jobs complete
- Import progress: GitHub repository ingestion running in background threads
- Calculation status: Heavy analytics jobs processing historical data
Each partial updates independently based on server-side job states. When a GitHub import starts, the import progress partial begins polling. When metrics calculations make progress, those widgets refresh with new data. Small independent and fast UI updates.
What does it mean for users?
Immediate feedback: Button clicks (refresh, retry, configure) execute instantly with optimistic UI updates.
Background resilience: Long-running jobs (imports, calculations) update their partials without affecting other dashboard sections.
Server authority: All business logic lives server-side. Complex filtering, aggregation, and job orchestration happens in Rust—not JavaScript.
State isolation: Each partial carries its own state. A failing import doesn't crash the metrics display.
Production Architecture
The JavaScript examples in this article are simplified for clarity. Cimatic's production SSR+ system is implemented in Rust with additional robustness features:
- Typed partial contracts: Rust structs define state shape and valid actions per partial type
- Background job integration: Job processing system updates partial states in the UI
- Error boundaries: Partial failures are contained; errors render as HTML error partials
- Performance optimizations: Server-side caching, connection pooling, and minimal DOM diffing
Language Agnostic Pattern
This pattern transplants easily to any backend stack:
- Python/Django: Views handle POST actions, templates render partials, Celery manages background jobs
- Node.js/Express: Route handlers with EJS/Handlebars templates, Bull queues for async work
- Go/Gin: Handler functions with html/template, goroutines for background processing
- Ruby/Rails: Controller actions with ERB partials, Sidekiq for job processing
The key insight: server-rendered HTML + immediate actions + background job integration = responsive dashboards without SPA complexity.
Convinced to try? There's a lot more you can benefit from this SSR alternative to React SPAs
The counter example shows SSR+ fundamentals, but production systems unlock much more sophistication. Here's how to evolve your SSR+ architecture for enterprise-grade performance and reliability.
Transport layer: From simple to sophisticated
Start simple: The immediate POST-per-action pattern works perfectly for most interactions. Users click, server responds, DOM updates. This covers 80% of dashboard use cases with zero complexity.
Add smart polling: For live data (metrics, job progress), implement conditional polling. Server controls cadence via response headers—fast updates during active jobs, slower refresh for stable data, stop when complete.
Upgrade to WebSockets: For high-frequency updates or collaborative features, WebSockets push partial updates directly. The same reducer pattern applies—server sends partial type, state, and HTML.
Batch for efficiency: Group multiple actions into single requests during rapid user interactions. Queue actions for 100ms, then send batch. Reduces server load and provides smoother UX during bulk operations.
Debug with time-travel: record and replay HTTP
All state transitions flow through HTTP. That means you get Redux‑style time‑travel without any client tooling—just a proxy.
Record requests with mitmproxy:
mitmproxy -w flows.mitm
You can bisect bugs by replaying to a specific request, inspecting state, and continuing.
Caching: Speed through intelligent state management
State versioning: Hash partial state to detect changes. If state hasn't changed, skip expensive rendering and return 304 Not Modified. Works especially well for expensive queries or complex calculations.
// Only re-render if state actually changed
let state_hash = hash(¤t_state);
if request.if_none_match == Some(state_hash) {
return HttpResponse::NotModified();
}
Pure reducer caching: Cache reducer outputs for identical inputs. Many dashboard partials show the same data filtered differently—cache the expensive work, vary the presentation.
Incremental updates: Instead of re-rendering entire partials, send minimal HTML diffs. Particularly powerful for large tables or lists where only one row changed.
DOM optimization: Smooth updates without jank
Replace simple swapping: HTML replacement works for prototypes, but causes focus loss and scroll jumps.
DOM morphing: Libraries like morphdom or idiomorph update only the changed nodes. Preserves focus, scroll position, and CSS transitions while applying state changes.
// Smooth updates that preserve user context
import { morph } from 'idiomorph';
async action({ target, type, action, state }) {
const { html } = await this.postAction({ target, type, action, state });
const container = document.querySelector(target);
morph(container, html); // Only changes what's different
}
Progressive enhancement layers: Start with zero-JS form posts. Add JS for immediate feedback. Layer on optimistic updates for perceived speed. Each layer degrades gracefully.
Animation and feedback: SSR+ enables smooth micro-interactions. Add CSS transitions for state changes, loading spinners during actions, success/error feedback—all server-controlled through class changes in returned HTML.
Error resilience: Fail gracefully, recover automatically
Partial failure isolation: One broken partial doesn't crash the dashboard. Render error states as HTML partials with retry buttons.
Automatic retries: Implement exponential backoff for failed actions. Show degraded UI during network issues, restore full functionality when connectivity returns.
Optimistic updates: For immediate feedback, apply state changes instantly, then reconcile with server response. If server rejects, revert smoothly.
Why this scales better than SPAs
Simpler state management: No client-side stores to sync, no complex state transitions to debug. Server state is always the source of truth.
Easier testing: Test server reducers independently. Test HTML rendering separately. No complex client-side state scenarios to maintain.
Time-travel debugging: Since all state transitions flow through HTTP requests, a simple proxy gives you Redux DevTools-level debugging for free. Record all requests, replay them in sequence, inspect state at any point in time. No special tooling needed—just HTTP logs.
Performance predictability: Server controls complexity. Network latency is the main variable, not JavaScript execution time or bundle size.
Is your project trapped? When SSR+ sets you free (and when it doesn't)
There are no silver bullets, and Server-Side Reducers is not an exception. We said when it can be a solution in the opening of this article. Let's repeat when to use it or not once again.
SSR+ liberates these trapped projects:
- ✅ Admin dashboards — Server has all the data, updates are event-driven
- ✅ Real-time status widgets — Progress bars, health checks, live metrics
- ✅ Content management — Forms, tables, filtered lists with server-side logic
- ✅ B2B applications — Complex business rules, audit trails, user permissions
Stay in SPA-land for these use cases:
- ❌ Offline-first apps — Need client-side storage and sync
- ❌ Creative tools — Rich interactions, animations, canvas manipulation
How this compares to other approaches
- Similarities to HTMX: HTML-first transport, server renders fragments, progressive enhancement.
- Differences from HTMX: typed/validated actions, server-controlled polling cadence via headers.
- Relation to Phoenix LiveView: Shares real-time HTML updates philosophy; differs in explicit action dispatch vs. LiveView's stateful processes.
- Relation to Hotwire/Turbo: Similar HTML-over-wire philosophy, but explicit reducer semantics vs. DOM-annotated behavior.
- Similarities to React: reducer-style state transitions (useReducer mental model), partial-based architecture.
- Differences from React SPAs: no hydration, minimal client state, server as single source of truth, smaller bundles, improved Core Web Vitals and Time to Interactive.
- When React is better: rich offline UX, highly interactive canvas/3D tools, complex client gestures.
- When HTML-first wins: dashboards, status widgets, B2B forms/tables, admin panels.
FAQ
Q: What is SSR+? A: SSR+ (Server-Side Reducers) is our enhanced approach to server-side rendering that combines HTML-first transport with reducer-style state management. Unlike traditional SSR which renders full pages, SSR+ handles dynamic interactions through server-hosted reducers that process actions and return HTML fragments. It's an SSR alternative to React SPAs.
Q: How does this affect SEO and search engine indexing? A: Since pages render complete HTML server-side, search engines index content normally. Dynamic partials that update after page load don't affect initial SEO crawling. Use proper semantic HTML and meta tags as usual.
Q: When should I choose WebSockets over polling for real-time updates? A: Use polling for periodic updates (metrics every 5s, job status checks). Choose WebSockets for high-frequency data (chat, live collaboration, real-time charts) or when you need bidirectional communication. Polling is simpler to implement and debug.
Key takeaways
- Server authority: Let the server control state, polling, and business logic
- Progressive enhancement: Pages work without JS, enhanced with JS
- HTML over JSON: Simpler transport, better caching, easier debugging
- Framework agnostic: Works with any backend language and framework
HTML partials with server reducers offer a compelling path away from SPA complexity while preserving the responsiveness users expect. You get immediate user feedback, live updates, and sophisticated state management—all with zero client-side frameworks and significantly less code to maintain. Start with the simple counter pattern in your next dashboard feature and experience how server-first architecture can simplify your development workflow.
References and further reading
- HTMX Documentation: htmx.org - HTML-first approach that inspired our transport layer
- Phoenix LiveView: hexdocs.pm/phoenix_live_view - Elixir's real-time HTML updates, pioneer in server-driven UI
- Hotwire/Turbo Streams: hotwired.dev - Rails' HTML-over-wire philosophy
- DOM Morphing Libraries: idiomorph, morphdom - Smooth DOM updates
- mitmproxy: mitmproxy.org - HTTP traffic capture and replay for debugging
Next: How we escaped GitHub API slowness with smart job queues
Next post will reveal how we broke free from GitHub API bottlenecks entirely. We built a job system that delivers instant data regardless of external API slowness. We'll cover decoupled ingestion, priority queues, and adaptive fetch strategies that scale with repository velocity. SSR+ partials keep users informed while background jobs normalize and backfill data—no more waiting on third-party APIs.