the thesis
We’ve stopped treating every business app like it needs a client-heavy single page frontend, because most of them don’t. Internal tools, ERP screens, customer portals, admin panels, approval workflows, the usual software that keeps a company running, they mostly need fast first render, predictable interactions, and code that a tired engineer can still understand six months later. HTMX inside a Next.js app gives us that, and after a couple of projects where we pushed this approach hard, we ended up with smaller JavaScript bundles, fewer state bugs, and a codebase that didn’t require a mental model of five caches fighting each other.
The key point is simple, HTML over the wire still wins for a large class of apps. Next.js 14 with the App Router already leans server-first, React Server Components already admit that shipping less client code is good engineering, and HTMX pushes that logic a little further by saying, fine, if the server can render the next state of the UI, just return the fragment and swap it in. No Redux, no React Query retry storm, no form library trying to reconcile touched state with server validation, no accidental optimistic update that leaves a sales rep staring at stale data.
At Steezr we build plenty of SaaS and AI-heavy systems where a richer client makes sense, especially around document pipelines or complex async interactions, yet a surprising amount of B2B software gets worse the moment people force SPA architecture onto it. You pay in bundle size, then in coordination bugs, then in maintenance. We had one customer portal where a filtered orders table pulled in 280 kB of client JavaScript just to support search params, optimistic row actions, and a modal editor. After a rewrite that kept Next.js for routing and rendering, then used HTMX for partial updates, the page-specific bundle dropped to 132 kB and the bug count dropped even faster. That was the easy part. The bigger win was deleting state.
where the complexity came from
Most frontend complexity in business apps comes from duplicating server state on the client, then pretending that duplication is manageable if you add the right libraries. It usually starts innocently. You fetch a table with React Query, keep filters in URLSearchParams, track the selected row in local state, open a dialog, submit with a mutation, invalidate the list, then race the invalidation against a route transition. A month later you’re reading stack traces that mention hydration mismatch, stale closure, aborted fetch, and some custom useDebouncedPortalFilters hook that nobody wants to touch.
We hit this exact wall on a CRM-like portal built on Next.js 14.1.3, React 18.2, TypeScript 5.4. The users needed paginated lists, inline status changes, searchable detail views, and form-heavy editing. Our first pass used client components for most of the surface area because that’s the default reflex many teams have now. The result was familiar and bad. Filters lived in the URL and local state. Tables refetched too often. Mutations succeeded on the backend, then the UI rolled back because an older request resolved later. One bug produced a lovely sequence in the console: Warning: Text content did not match. Server: "Approved" Client: "Pending". Another gave us DOMException: The user aborted a request. on every fast filter change, which by itself isn’t fatal, yet it masked actual request ordering problems.
HTMX fixes this by refusing to create most of that state in the first place. The server owns the truth, the URL still matters, forms still submit like forms, and interactions become targeted fragment requests. Click a pagination link, request a partial, swap the table body. Submit a filter form, request the updated results region, push the new query string, keep moving. You can still use React client components where they earn their keep, for date pickers or a document annotator or a chart that genuinely needs client interactivity, yet the default posture changes. Server first, fragment updates second, JavaScript islands last.
the shape that worked
The cleanest setup we’ve found keeps Next.js as the main framework, then treats HTMX as a thin enhancement layer rather than a parallel app. App Router handles routes, layouts, server rendering, auth boundaries, and data fetching. HTMX handles local page interactions that would otherwise tempt you into making half the screen a client component.
A folder layout like this works well:
app/
portal/orders/page.tsx
portal/orders/_components/OrdersPage.tsx
portal/orders/_components/OrdersTable.tsx
portal/orders/_components/OrdersFilters.tsx
portal/orders/_partials/table/route.ts
portal/orders/_partials/filters/route.ts
lib/
orders.ts
auth.ts
request.ts
components/
htmx/HtmxProvider.tsx
The page itself stays a server component:
// app/portal/orders/page.tsx
import { OrdersPage } from './_components/OrdersPage'
export default async function Page({ searchParams }) {
return <OrdersPage searchParams={searchParams} />
}Then the partial route returns HTML for just the section HTMX will replace:
// app/portal/orders/_partials/table/route.ts
import { NextRequest } from 'next/server'
import { getOrders } from '@/lib/orders'
import { renderToStaticMarkup } from 'react-dom/server'
import { OrdersTable } from '../../_components/OrdersTable'
export async function GET(req: NextRequest) {
const searchParams = req.nextUrl.searchParams
const orders = await getOrders({
q: searchParams.get('q') ?? '',
status: searchParams.get('status') ?? 'open',
page: Number(searchParams.get('page') ?? '1')
})
const html = renderToStaticMarkup(
<OrdersTable orders={orders} />
)
return new Response(html, {
headers: { 'Content-Type': 'text/html; charset=utf-8' }
})
}In the page markup:
<form
hx-get="/portal/orders/_partials/table"
hx-target="#orders-table"
hx-push-url="true"
hx-trigger="change delay:200ms, keyup changed delay:300ms from:#q"
>
<input id="q" name="q" type="search" />
<select name="status">...</select>
</form>
<div id="orders-table">
<OrdersTable orders={initialOrders} />
</div>That’s most of it. You don’t need a custom client cache, and you don’t need to make the page interactive in the React sense just to update one region. We usually load HTMX once in a small client component wired into app/layout.tsx:
'use client'
import 'htmx.org/dist/htmx.min.js'
export function HtmxProvider() { return null }Ugly? Slightly. Effective? Very.
a real request flow
The best way to judge this pattern is to walk through one interaction and compare the moving parts. Take an orders list with a status filter and free-text search. A user types acme into the search box, then changes status to overdue. In a typical SPA setup you’ve got input state updates, debouncing, an effect or query key change, a fetch, loading state, cache update, maybe URL sync, maybe scroll retention, maybe a suspense boundary, and a bunch of edge cases around fast navigation.
With HTMX inside Next.js, the browser sends a plain GET to /portal/orders/_partials/table?q=acme&status=overdue&page=1. The route handler parses the query, calls the same getOrders() function used on initial render, returns server-rendered table HTML, HTMX swaps #orders-table, then updates the URL because hx-push-url="true" is set. If the user hits refresh, the full page render uses the same search params and produces the same state. That symmetry matters more than people admit.
The response can stay dead simple:
<table class="w-full text-sm">
<tbody>
<tr>
<td>#1042</td>
<td>Acme s.r.o.</td>
<td>Overdue</td>
</tr>
</tbody>
</table>You can also return out-of-band fragments if you need to update a count badge or flash area:
<div id="results-count" hx-swap-oob="true">24 results</div>
<table>...</table>A few HTMX attributes carry a lot of weight here. hx-select trims the response to a fragment if your endpoint returns more markup than you want swapped. hx-indicator gives you a cheap loading spinner. hx-sync="this:replace" prevents older requests from winning the race, which killed an entire class of filter bugs for us. That one line replaced a pile of custom abort controller logic. There’s no elegance prize for manually rebuilding browser behavior with hooks.
where we cut bundle size
Bundle size dropped because we stopped shipping code whose only job was to imitate server rendering in the browser. That sounds obvious, yet teams miss it all the time because the overhead is spread across helper hooks, query libraries, form abstractions, modal state, validation wrappers, and component trees marked with 'use client' for reasons nobody can quite justify anymore.
On one portal screen we measured with ANALYZE=true next build and @next/bundle-analyzer on Next.js 14.2.5. Before the rewrite, the route pulled in a client table component, TanStack Query 5, Zod validation on the client, date-fns, a debounced search hook, and a modal workflow for editing rows. The route-level JavaScript for first load landed around 246 kB gzipped, with hydration work that made slower office laptops feel sticky. After pushing filters, pagination, and most edit flows back to the server, the route dropped to 118 kB gzipped. HTMX itself added around 14 kB minified and compressed, which is cheap compared to the React client code we deleted.
The hidden win was dependency pressure. Once a screen stops being a mini-application, engineers stop reaching for app-like solutions. You don’t need TanStack Query for a form post that returns the updated row HTML. You don’t need client-side schema validation for every field if the server is the authority and can rerender the form with errors inline. You don’t need a global store to remember whether a side panel is open if that side panel can be fetched and rendered on demand.
There’s also less hydration. Less hydration means fewer mismatches, fewer expensive client trees, fewer weird bugs caused by useEffect timing or browser-only state leaking into render. Anyone who has debugged a page that worked perfectly in development, then broke in production because streaming, caching, and hydration interacted in a way that felt hostile, already knows how much engineering time goes into preserving an illusion of interactivity that the browser gave us for free twenty years ago.
the pitfalls and the sprint
This pattern isn’t magic, and the sharp edges are real. We ripped out a bunch of client state in one sprint only after getting burned by several details that don’t show up in cute HTMX demos.
Caching was the first one. Next.js route handlers can be cached in ways that surprise you if you’re not explicit. For user-specific partials, especially anything behind auth, set the right headers and make your data access dynamic. We’ve used export const dynamic = 'force-dynamic' on pages that must never drift, and in route handlers we make sure responses carry Cache-Control: no-store where appropriate. Serving another account’s filtered table because of cache confusion is career-limiting.
CSRF and auth were next. If you’re posting with HTMX to Django or a custom backend, make sure the token is sent consistently. HTMX supports hx-headers, which keeps this simple:
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>Against Next.js server actions, we usually don’t bother mixing HTMX directly because the ergonomics are uneven and the failure modes are odd. Route handlers are clearer. Plain old POST endpoints are clearer too.
Third issue, partials can drift from full-page render if you duplicate logic. Don’t. The same server-side function should feed both initial render and fragment render, and the same React component should render the table in both paths. If your partial endpoint starts hand-rolling HTML while the page uses a separate component tree, the divergence will show up in escaped markup, missing classes, or weird conditional branches.
Last one, don’t overdo HTMX. A rich text editor, drag and drop document labeling, realtime AI transcript playback, those deserve client components. We’ve built AI salesperson workflows and document processing UIs where a heavier client was absolutely justified. The mistake goes in the other direction, people keep building CRUD screens as if every row click is the front half of Figma. Most business software needs less cleverness. That’s the whole point.
where this fits
I’d use this approach aggressively for admin surfaces, customer portals, partner dashboards, internal approval tools, inventory screens, order management, support consoles, and most SaaS back offices. Those apps live or die on product velocity and operational calm, not on frontend architecture purity. A CTO should care because every extra client-side abstraction turns into maintenance cost, onboarding time, and bug surface. A senior frontend engineer should care because deleting state is one of the few optimizations that keeps paying rent.
Next.js plus HTMX also plays nicely with teams that already have strong backend habits. We do a lot of work across Next.js, Django, PostgreSQL, Tailwind, and HTMX, and that mix works because responsibilities are clear. The server renders meaningful UI. The browser handles navigation and form semantics. HTMX adds targeted enhancement without demanding a separate frontend state machine for every interaction. Developers can move fast without pretending every button click needs a custom hook and a suspense story.
The strongest argument for this stack is maintenance six months later. Open the route, read the params, fetch the data, render the fragment, done. That’s a sane model. Compare it with tracing a filter bug through useTransition, query invalidation, a controlled form library, and a stale closure inside a callback that somebody memoized to please ESLint. One of these systems respects your time.
If you own an app full of tables, forms, and approval flows, try one screen. Don’t rewrite everything. Pick the most annoying page, measure its client bundle with next build, list the states it currently tracks in the browser, then move one interaction at a time back to server-rendered fragments. You’ll probably end up deleting more code than you expected, which is usually how you know you’re finally moving in the right direction.
