All postsEngineering

htmx inside Next.js 16: a version that runs, and what it saves

A tested htmx setup for filtered tables in the App Router, the bugs that break naive versions, and bundle sizes measured against RSC and TanStack Query.

Johnny Unar10 min read

01

who this is for and what it really saves

This is for teams with a Next.js App Router codebase full of tables, filters and forms: order lists, admin screens, partner portals. Somewhere along the way those screens became client components with TanStack Query, a debounced search hook and URL syncing, and now every filter change is a small state machine. The pitch for htmx is that the server already knows how to render the table, so on every change you ask it for the new table and swap it in, with no client cache in between.

The draft of this post that circulated earlier promised big bundle cuts and shipped code that couldn't run. So this version starts from a minimal app that does run (Next.js 16.4.5, React 19.3.1, htmx 2.0.11), tested in headless Chromium, and from numbers measured on it. The headline number goes against the old title: on a small filtered table, the htmx version ships more JavaScript than a TanStack Query version, because htmx itself is about 16.5 KB gzipped and the query library plus the component came to 10 KB. Whatever htmx saves you comes from deleting client components and the state they carry, and only if a screen had a lot of them in the first place.

That's still a good reason to use it on some screens. But before reaching for htmx at all, check whether plain server components already solve your problem, because in Next.js 16 they often do.

02

try server components and next/form first

A server component page that reads searchParams and renders the table is the zero-dependency version of the same idea. Pair it with the next/form component and a filter form with a string action turns into a GET navigation: the Next.js docs say it encodes the fields as search params, prefetches the route's shared UI, and does a client-side navigation instead of a full reload. A refresh or a shared link renders the same state because the state is the URL.

tsx
1import Form from "next/form";
2
3<Form action="/rsc" replace>
4 <input id="q" name="q" type="search" defaultValue={query.q} />
5 <select id="status" name="status" defaultValue={query.status}>
6 <option value="all">All</option><option value="paid">Paid</option>
7 </select>
8 <button type="submit">Filter</button>
9</Form>

In the test app this route shipped 1.6 KB of JavaScript on top of the shared runtime, the smallest of the three. If your filters submit on a button press or on Enter, stop here: you get server-owned state, no client data library and less JavaScript than either alternative.

htmx starts to earn its 16.5 KB in a narrower set of cases. Live filtering as you type works without writing a client component, because the trigger, the debounce and the target are attributes. You can swap one region of the page without a router transition, which matters when the rest of the page holds client state you don't want touched. Request races are handled by hx-sync instead of hand-written abort logic, and hx-swap-oob lets one response update a results counter or a flash message elsewhere on the page. And if the same team also maintains Django templates, the mental model is identical on both stacks. If none of those apply, server components are the better tool.

03

three reasons the old example never worked

The earlier version put the fragment endpoint at app/portal/orders/_partials/table/route.ts. Folders whose names start with _ are private folders in the App Router and are left out of routing entirely, so every htmx request would have hit a 404. We checked: the same file under _partials returns 404 in a production build, while the same handler under table/ is listed as a route.

The handler also imported renderToStaticMarkup from react-dom/server at the top of the file. Next.js 16 refuses to build that in the app directory, with the message "You're importing a component that imports react-dom/server. To fix it, render or return the content directly as a Server Component instead for perf and security." A dynamic await import("react-dom/server") inside the function builds and works. The file also contained JSX while being named route.ts, which doesn't compile; it has to be route.tsx.

The third bug is the subtle one. hx-push-url="true" pushes the URL of the request into history, which is the fragment URL, so after one filter change the address bar showed /portal/orders/_partials/table?q=acme and a refresh loaded a bare table with no page around it. The page URL has to be pushed instead, and the cleanest place to decide it is the server, through the HX-Push-Url response header.

04

a version that runs

The data layer parses and normalises the query once, so the page, the fragment and the pushed URL all agree. Treat the query string as hostile input: unknown status values fall back to a default and the page number is validated.

ts
1// lib/orders.ts
2export type Order = { id: number; customer: string; status: "open" | "overdue" | "paid" };
3
4const ORDERS: Order[] = Array.from({ length: 60 }, (_, i) => ({
5 id: 1000 + i,
6 customer: ["Acme s.r.o.", "Globex a.s.", "Initech", "Umbrella"][i % 4],
7 status: (["open", "overdue", "paid"] as const)[i % 3],
8}));
9
10export type OrderQuery = { q: string; status: string; page: number };
11
12export function parseQuery(p: URLSearchParams): OrderQuery {
13 const page = Number.parseInt(p.get("page") ?? "1", 10);
14 return {
15 q: (p.get("q") ?? "").slice(0, 100),
16 status: ["open", "overdue", "paid", "all"].includes(p.get("status") ?? "") ? p.get("status")! : "all",
17 page: Number.isFinite(page) && page > 0 ? page : 1,
18 };
19}
20
21export function toSearch(query: OrderQuery): string {
22 return new URLSearchParams({ q: query.q, status: query.status, page: String(query.page) }).toString();
23}
24
25export async function getOrders({ q, status, page }: OrderQuery) {
26 const rows = ORDERS.filter(
27 (o) =>
28 (status === "all" || o.status === status) &&
29 o.customer.toLowerCase().includes(q.toLowerCase()),
30 );
31 return { rows: rows.slice((page - 1) * 10, page * 10), total: rows.length };
32}

The table is an ordinary component with no hooks, so it can render in both places. The pagination link has a real href for people without JavaScript and an hx-get for everyone else.

tsx
1// app/orders/orders-table.tsx
2import { toSearch, type Order, type OrderQuery } from "@/lib/orders";
3
4export function OrdersTable({ rows, total, query }: { rows: Order[]; total: number; query: OrderQuery }) {
5 const next = { ...query, page: query.page + 1 };
6 return (
7 <>
8 <p id="results-count">{total} results</p>
9 <table>
10 <tbody>
11 {rows.map((o) => (
12 <tr key={o.id}>
13 <td>#{o.id}</td>
14 <td>{o.customer}</td>
15 <td>{o.status}</td>
16 </tr>
17 ))}
18 </tbody>
19 </table>
20 {query.page * 10 < total && (
21 <a href={`/orders?${toSearch(next)}`} hx-get={`/orders/table?${toSearch(next)}`} hx-target="#orders-table" hx-sync="#filters:replace">
22 Next page
23 </a>
24 )}
25 </>
26 );
27}

Rendering to a string happens in one helper, with the dynamic import that the build accepts:

ts
1// app/orders/render.ts
2import type { ReactElement } from "react";
3
4export async function renderHtml(el: ReactElement): Promise<string> {
5 const { renderToStaticMarkup } = await import("react-dom/server");
6 return renderToStaticMarkup(el);
7}

The fragment endpoint lives at /orders/table, a normal folder. It returns the same component's markup, tells htmx which page URL to push, and sets Cache-Control: no-store because a filtered list is usually per-user data that no shared cache should keep.

tsx
1// app/orders/table/route.tsx
2import type { NextRequest } from "next/server";
3import { getOrders, parseQuery, toSearch } from "@/lib/orders";
4import { OrdersTable } from "../orders-table";
5import { renderHtml } from "../render";
6
7export async function GET(req: NextRequest) {
8 const query = parseQuery(req.nextUrl.searchParams);
9 const html = await renderHtml(<OrdersTable {...await getOrders(query)} query={query} />);
10
11 return new Response(html, {
12 headers: {
13 "Content-Type": "text/html; charset=utf-8",
14 "Cache-Control": "no-store",
15 "HX-Push-Url": `/orders?${toSearch(query)}`,
16 },
17 });
18}

The page renders the same component through the same helper, and the form also works as a plain GET form with action="/orders" if htmx never loads:

tsx
1// app/orders/page.tsx
2import { getOrders, parseQuery } from "@/lib/orders";
3import { OrdersTable } from "./orders-table";
4import { renderHtml } from "./render";
5
6export default async function OrdersPage({ searchParams }: PageProps<"/orders">) {
7 const params = new URLSearchParams();
8 for (const [k, v] of Object.entries(await searchParams)) if (typeof v === "string") params.set(k, v);
9 const query = parseQuery(params);
10 const html = await renderHtml(<OrdersTable {...await getOrders(query)} query={query} />);
11
12 return (
13 <main>
14 <form
15 id="filters"
16 action="/orders"
17 hx-get="/orders/table"
18 hx-target="#orders-table"
19 hx-trigger="input changed delay:300ms from:#q, change from:#status"
20 hx-sync="this:replace"
21 >
22 <input id="q" name="q" type="search" defaultValue={query.q} />
23 <select id="status" name="status" defaultValue={query.status}>
24 <option value="all">All</option>
25 <option value="open">Open</option>
26 <option value="overdue">Overdue</option>
27 <option value="paid">Paid</option>
28 </select>
29 </form>
30 <div id="orders-table" dangerouslySetInnerHTML={{ __html: html }} />
31 </main>
32 );
33}

In the browser test, typing "acme" and picking "overdue" swapped the table and moved the address bar to /orders?q=acme&status=overdue&page=1, and a reload rendered the same five rows with the inputs filled in.

05

who owns the DOM inside the swap target

The most important line in the page is dangerouslySetInnerHTML on #orders-table, and it's the part the old post never mentioned. htmx replaces the children of that div with new nodes. If React rendered those children as JSX, it still holds references to the old nodes, and the next time it reconciles that subtree it tries to update nodes that are no longer in the document. We reproduced this: with the table rendered as JSX children, one htmx swap followed by router.refresh() threw "Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node." and Next.js replaced the whole page with its error screen. With the dangerouslySetInnerHTML version, React treats the content as one opaque string, leaves it alone while the string is unchanged, and on a refresh replaces it with fresh server HTML for the current URL, which is what you want. renderToStaticMarkup escapes text content, so customer names with angle brackets stay text.

Loading htmx needs two details. Scope it to the segment that uses it with next/script in that segment's layout, so the rest of the app doesn't pay for it. And reprocess the page whenever the layout mounts, because htmx only scans the DOM when it initialises. In our test, a client-side navigation away from /orders and back left the form dead without the onReady call; the Next.js docs describe onReady as running after the first load and again on every remount, and it needs a client component.

tsx
1// app/orders/htmx-script.tsx
2"use client";
3import Script from "next/script";
4
5declare global {
6 interface Window { htmx: { process(elt: Element): void } }
7}
8
9export function HtmxScript() {
10 return <Script src="/htmx.min.js" onReady={() => window.htmx.process(document.body)} />;
11}
tsx
1// app/orders/layout.tsx
2import { HtmxScript } from "./htmx-script";
3
4export default function OrdersLayout({ children }: { children: React.ReactNode }) {
5 return (
6 <>
7 <meta name="htmx-config" content='{"historyCacheSize":0,"refreshOnHistoryMiss":true}' />
8 {children}
9 <HtmxScript />
10 </>
11 );
12}

The htmx-config meta tag fixes the back button. By default htmx snapshots document.body into session storage and, on back, writes that snapshot back into the body. In a Next.js page the body includes the framework's inline scripts, and restoring it re-ran them and threw a series of "Cannot enqueue a chunk into a readable stream" errors; the restored search input was also empty. With historyCacheSize: 0 htmx stores nothing, and refreshOnHistoryMiss: true makes it do a full page load of the URL instead, so back and forward render the correct state from the server, inputs included. A reload per back step is a fair price for not fighting the framework over the body.

06

races, caching and how the numbers were measured

htmx already serialises requests that come from the same element: reading the 2.0.11 source, a second request while one is in flight gets queued, and only the last queued one runs. So within one form, a slow stale response can't overwrite a newer one, but the user waits for it. With hx-sync="this:replace" the in-flight request is aborted instead. We made the "a" query sleep 1.5 seconds and typed "acme" right after it: with replace the final table appeared 334 ms later and the stale request showed up as aborted, without it the table took 1,359 ms.

The real race is between different elements, and it's easy to miss because each element is fine on its own. In the test, clicking "Next page" (made slow on purpose) and then changing the status filter left the table showing all 60 results on page 2 while the select said "paid", because the slower response arrived last. Adding hx-sync="#filters:replace" to the link puts it in the same queue as the form, so the filter change aborts the page request and the table shows 20 paid orders, matching the controls.

Caching needs one rule: every fragment that depends on the user or the query should carry Cache-Control: no-store, and its data access should be dynamic, so neither a CDN nor the framework hands one user's list to another. The same applies to the page. The example is GET-only; if you add htmx POSTs to route handlers, they need the same CSRF and authorisation checks as any other endpoint.

For the bundle numbers we built each variant with next build, served it with next start, opened the route in headless Chromium through Playwright and summed encodedBodySize for every script loaded on first visit. The TanStack variant is the same table as a client component with useQuery, useSearchParams and router.replace; the RSC variant is the next/form page above.

text
1Next.js 16.4.5, React 19.3.1, production build, next start (gzip)
2JS transferred on first load of the route, in KB (gzip)
3
4shared App Router runtime 132.0
5/rsc RSC + next/form 133.6 (+1.6)
6/client TanStack Query 5.107.1 142.0 (+10.0)
7/orders htmx 2.0.11 version 150.5 (+16.5 htmx, +2.0 loader)

The runtime dominates every route, and none of the three approaches gets you under it. A real screen with a form library, client-side validation, a date library and a modal editor would move the client column up a lot more than 10 KB, and that's the case where pushing state back to the server pays off. Measure your own route the same way before and after; if the difference is a few kilobytes, choose on maintainability and on the cases from the second section, not on bundle size.

Thanks for reading.
Johnny Unar

Written by

Johnny Unar

Keep reading

Stop patching.Start building.

Tell us what's slowing you down. We'll bring coffee and opinions.