10 min čteníJohnny UnarJohnny Unar

HTMX v Next.js nám výrazně zjednodušil frontend

Pro interní nástroje a B2B portály je HTMX uvnitř Next.js rychlejší na dodání, lehčí a rozbíjí se míň než plný SPA stack.

hlavní teze

Přestali jsme ke každé business aplikaci přistupovat tak, jako by nutně potřebovala client-heavy single page frontend, protože většinou nepotřebuje. Interní nástroje, ERP obrazovky, zákaznické portály, adminy, schvalovací workflow, prostě software, na kterém firma reálně běží, většinou potřebuje rychlý first render, předvídatelné chování a kód, který pochopí i unavený inženýr ještě za půl roku. HTMX uvnitř Next.js nám tohle dává. Po několika projektech, kde jsme tenhle přístup tlačili naplno, jsme skončili s menšími JavaScript bundly, méně bugy ve state a codebase, kde se proti sobě nepralo pět cache vrstev.

Pointa je jednoduchá: HTML over the wire pořád vyhrává u velké části aplikací. Next.js 14 s App Routerem už sám o sobě míří server-first směrem, React Server Components v podstatě říkají, že posílat méně client kódu je dobré inženýrství, a HTMX tu logiku posune ještě o kus dál. Když server umí vyrenderovat další stav UI, tak prostě vraťte fragment a vyměňte ho. Bez Reduxu, bez retry stormu v React Query, bez form knihovny, která se snaží srovnat touched state se serverovou validací, bez náhodného optimistic updatu, po kterém obchodník kouká na stará data.

Ve Steezru stavíme i dost SaaS a AI-heavy systémů, kde bohatší client dává smysl, hlavně kolem document pipelines nebo složitějších async interakcí. Překvapivě velká část B2B softwaru se ale zhorší ve chvíli, kdy na ni někdo naroubuje SPA architekturu. Zaplatíte to ve velikosti bundlu, pak v koordinačních bugu a nakonec v údržbě. U jednoho zákaznického portálu nám filtrovaná tabulka objednávek tahala 280 kB client JavaScriptu jen kvůli search params, optimistic row akcím a modal editoru. Po přepisu, kde jsme nechali Next.js pro routing a renderování a HTMX použili na partial updaty, spadl page-specific bundle na 132 kB a počet bugů padal ještě rychleji. To byla ta jednodušší část. Větší výhra byla, že jsme smazali state.

odkud se ta složitost bere

Většina frontendové složitosti v business aplikacích vzniká tím, že duplikujete server state na clientu a pak předstíráte, že se to dá ukočírovat správnou sadou knihoven. Obvykle to začne nevinně. Načtete tabulku přes React Query, filtry držíte v URLSearchParams, vybraný řádek v local state, otevřete dialog, odešlete mutation, invalidujete seznam a pak řešíte race mezi invalidací a route transition. O měsíc později čtete stack trace s hydration mismatch, stale closure, aborted fetch a nějakým custom hookem useDebouncedPortalFilters, na který nikdo nechce sáhnout.

Do téhle zdi jsme narazili úplně přesně na CRM-like portálu postaveném na Next.js 14.1.3, React 18.2 a TypeScriptu 5.4. Uživatelé potřebovali stránkované seznamy, inline změny statusu, prohledávatelné detailní pohledy a formulářově těžké editace. První verze měla client komponenty přes většinu plochy, protože to je dnes pro spoustu týmů default reflex. Výsledek byl známý a špatný. Filtry žily v URL i v local state. Tabulky refetchovaly moc často. Mutace na backendu prošla, ale UI pak udělalo rollback, protože starší request doběhl později. Jeden bug nám v konzoli vyrobil krásnou hlášku: Warning: Text content did not match. Server: "Approved" Client: "Pending". Jiný sypal DOMException: The user aborted a request. při každé rychlé změně filtru. Samo o sobě to není fatální, ale zakrývalo to skutečné problémy s pořadím requestů.

HTMX tohle řeší tím, že většinu toho state vůbec nevytváří. Server vlastní pravdu, URL je pořád důležité, formuláře se pořád odesílají jako formuláře a interakce se smrsknou na cílené requesty pro fragmenty. Kliknete na stránkování, stáhnete partial, vyměníte body tabulky. Odešlete filter form, stáhnete aktualizovaný region s výsledky, pushnete nový query string a jedete dál. React client komponenty pořád můžete použít tam, kde si je UI opravdu zaslouží, třeba pro date picker, document annotator nebo graf, který skutečně potřebuje client interaktivitu. Jen se změní výchozí přístup: nejdřív server, pak fragment updaty, client JavaScript až nakonec.

setup, který fungoval

Nejčistší setup, který se nám osvědčil, nechává Next.js jako hlavní framework a HTMX bere jako tenkou enhancement vrstvu, ne jako paralelní aplikaci. App Router řeší routes, layouty, server rendering, auth hranice a fetching dat. HTMX řeší lokální interakce na stránce, které by vás jinak sváděly udělat z půlky obrazovky client komponentu.

Dobře funguje třeba tahle struktura složek:

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

Samotná stránka zůstane server komponenta:

tsx
1// app/portal/orders/page.tsx
2import { OrdersPage } from './_components/OrdersPage'
3
4export default async function Page({ searchParams }) {
5 return <OrdersPage searchParams={searchParams} />
6}

Partial route pak vrací HTML jen pro sekci, kterou HTMX nahradí:

tsx
1// app/portal/orders/_partials/table/route.ts
2import { NextRequest } from 'next/server'
3import { getOrders } from '@/lib/orders'
4import { renderToStaticMarkup } from 'react-dom/server'
5import { OrdersTable } from '../../_components/OrdersTable'
6
7export async function GET(req: NextRequest) {
8 const searchParams = req.nextUrl.searchParams
9 const orders = await getOrders({
10 q: searchParams.get('q') ?? '',
11 status: searchParams.get('status') ?? 'open',
12 page: Number(searchParams.get('page') ?? '1')
13 })
14
15 const html = renderToStaticMarkup(
16 <OrdersTable orders={orders} />
17 )
18
19 return new Response(html, {
20 headers: { 'Content-Type': 'text/html; charset=utf-8' }
21 })
22}

V markupu stránky:

tsx
1<form
2 hx-get="/portal/orders/_partials/table"
3 hx-target="#orders-table"
4 hx-push-url="true"
5 hx-trigger="change delay:200ms, keyup changed delay:300ms from:#q"
6>
7 <input id="q" name="q" type="search" />
8 <select name="status">...</select>
9</form>
10
11<div id="orders-table">
12 <OrdersTable orders={initialOrders} />
13</div>

To je v zásadě celé. Nepotřebujete custom client cache a nepotřebujete dělat stránku interaktivní v React smyslu jen proto, abyste aktualizovali jednu oblast. HTMX obvykle načteme jednou v malé client komponentě zapojené do app/layout.tsx:

tsx
1'use client'
2import 'htmx.org/dist/htmx.min.js'
3export function HtmxProvider() { return null }

Je to trochu ošklivé? Jo. Funguje to? Hodně.

jak vypadá reálný request flow

Tenhle pattern se nejlíp posuzuje na jedné konkrétní interakci a porovnání, kolik má pohyblivých částí. Vezměte si seznam objednávek se status filtrem a fulltextovým hledáním. Uživatel napíše do search boxu acme a pak změní status na overdue. V typickém SPA setupu máte update input state, debounce, effect nebo změnu query key, fetch, loading state, update cache, možná sync do URL, možná držení scrollu, možná suspense boundary a hromadu edge casů kolem rychlé navigace.

S HTMX uvnitř Next.js prohlížeč pošle obyčejný GET na /portal/orders/_partials/table?q=acme&status=overdue&page=1. Route handler rozparsuje query, zavolá stejnou funkci getOrders(), která se používá i při initial renderu, vrátí server-renderované HTML tabulky, HTMX vymění #orders-table a pak aktualizuje URL, protože je nastavené hx-push-url="true". Když uživatel dá refresh, full page render použije stejné search params a vyrobí stejný stav. Tahle symetrie je důležitější, než si lidi připouštějí.

Response může zůstat úplně jednoduchý:

html
1<table class="w-full text-sm">
2 <tbody>
3 <tr>
4 <td>#1042</td>
5 <td>Acme s.r.o.</td>
6 <td>Overdue</td>
7 </tr>
8 </tbody>
9</table>

Když potřebujete aktualizovat i badge s počtem výsledků nebo flash area, můžete vrátit i out-of-band fragmenty:

html
1<div id="results-count" hx-swap-oob="true">24 results</div>
2<table>...</table>

Pár HTMX atributů tu odvede hodně práce. hx-select ořeže response na fragment, pokud endpoint vrací víc markupu, než chcete swapnout. hx-indicator vám dá levný loading spinner. hx-sync="this:replace" zajistí, že starší request nevyhraje závod, a nám tím zabil celou jednu třídu bugů ve filtrech. Jediný řádek nahradil hromadu custom logiky s abort controllerem. Ručně přepisovat chování browseru pomocí hooků žádnou cenu za eleganci nevyhraje.

kde jsme uřízli bundle

Bundle size spadla, protože jsme přestali posílat kód, jehož jediná práce byla napodobovat server rendering v browseru. Zní to očividně, ale týmy to přehlížejí pořád, protože ten overhead je rozlezlý mezi helper hooky, query knihovny, form abstrakce, modal state, validační wrappery a stromy komponent označené 'use client' z důvodů, které už dnes nikdo neumí pořádně obhájit.

Na jedné obrazovce portálu jsme měřili přes ANALYZE=true next build a @next/bundle-analyzer na Next.js 14.2.5. Před přepisem route tahala client table komponentu, TanStack Query 5, Zod validaci na clientu, date-fns, debounced search hook a modal workflow pro editaci řádků. JavaScript na úrovni route pro první načtení skončil někde kolem 246 kB gzipped a hydration na pomalejších kancelářských noteboocích působila lepivě. Po přesunu filtrů, stránkování a většiny edit flow zpátky na server route spadla na 118 kB gzipped. Samotné HTMX přidalo asi 14 kB minifikovaného a komprimovaného kódu, což je levné proti React client kódu, který jsme smazali.

Skrytá výhra byla tlak na dependencies. Jakmile obrazovka přestane být miniaturní aplikace, inženýři přestanou sahat po app-like řešeních. Nepotřebujete TanStack Query na form post, který vrátí aktualizovaný HTML řádku. Nepotřebujete client-side schema validaci pro každé pole, když server drží autoritu a umí formulář vyrenderovat znovu s chybami inline. Nepotřebujete global store, aby si pamatoval, jestli je otevřený side panel, když ten panel můžete načíst a vyrenderovat až na vyžádání.

Méně je i hydration. Méně hydration znamená méně mismatchů, méně drahých client stromů, méně divných bugů způsobených timingem useEffect nebo browser-only statem, který proteče do renderu. Kdo někdy debugoval stránku, která v developmentu fungovala perfektně a v produkci se rozbila, protože se streamování, cache a hydration potkaly nějakým vyloženě nepřátelským způsobem, ten už ví, kolik inženýrského času stojí udržovat iluzi interaktivity, kterou nám browser uměl zadarmo už před dvaceti lety.

na co si dát pozor

Tenhle pattern není magie a ostré hrany jsou reálné. Hromadu client state jsme vyházeli během jednoho sprintu až ve chvíli, kdy jsme se spálili o několik detailů, které v roztomilých HTMX demách neuvidíte.

První byla cache. Next.js route handlery se můžou cachovat způsobem, který vás překvapí, pokud nejste explicitní. U user-specific partialů, hlavně za auth, nastavte správné hlavičky a udělejte data access dynamický. Na stránkách, které se nesmí nikdy rozjet, jsme použili export const dynamic = 'force-dynamic' a v route handlerech hlídáme, aby response nesla Cache-Control: no-store, kde to dává smysl. Naservírovat jednomu účtu filtrovanou tabulku jiného účtu kvůli zmatku v cache je dost účinný způsob, jak si zadělat na problém.

Další byly CSRF a auth. Pokud přes HTMX posíláte POST do Djanga nebo vlastního backendu, token musí odcházet konzistentně. HTMX podporuje hx-headers, což to drží jednoduché:

html
1<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>

Proti Next.js server actions to s HTMX většinou ani nemícháme, protože ergonomie je nevyrovnaná a failure modes jsou podivné. Route handlery jsou jasnější. Obyčejné POST endpointy taky.

Třetí problém: partialy se můžou rozjet proti full-page renderu, když zduplikujete logiku. Nedělejte to. Stejná server-side funkce má živit initial render i fragment render a stejná React komponenta má renderovat tabulku v obou cestách. Jakmile partial endpoint začne ručně skládat HTML, zatímco stránka používá jiný strom komponent, divergence se dřív nebo později projeví v escapovaném markupu, chybějících třídách nebo podivných condition branchech.

Poslední věc: nepřehánějte to s HTMX. Rich text editor, drag and drop labelování dokumentů nebo realtime přehrávání AI transkriptů si client komponenty zaslouží. Stavěli jsme AI workflows pro obchodní týmy i UI pro zpracování dokumentů, kde těžší client dával naprostý smysl. Chyba je v opačném směru: lidi pořád staví CRUD obrazovky, jako by každý klik na řádek byl první polovina Figmy. Většina business softwaru potřebuje méně chytrosti. O tom to celé je.

kam tohle sedí

Tenhle přístup bych bez váhání použil pro adminy, zákaznické portály, partnerské dashboardy, interní schvalovací nástroje, skladové obrazovky, správu objednávek, support konzole a většinu SaaS back officů. Tyhle aplikace žijí a umírají podle produktové rychlosti a provozního klidu, ne podle čistoty frontend architektury. CTO by to mělo zajímat, protože každá další client-side abstrakce se časem promění v náklad na údržbu, onboarding a bug surface. Senior frontend inženýra by to mělo zajímat proto, že mazání state je jedna z mála optimalizací, která se vyplácí pořád dokola.

Next.js plus HTMX navíc sedí týmům, které už mají silné backendové návyky. Hodně pracujeme s kombinací Next.js, Django, PostgreSQL, Tailwindu a HTMX a funguje to právě proto, že odpovědnosti jsou jasné. Server renderuje smysluplné UI. Browser řeší navigaci a sémantiku formulářů. HTMX přidává cílené vylepšení bez toho, aby pro každou interakci vyžadovalo samostatný frontend state machine. Vývojáři můžou jet rychle bez předstírání, že každé kliknutí na tlačítko potřebuje custom hook a vlastní suspense story.

Nejsilnější argument pro tenhle stack je údržba za půl roku. Otevřete route, přečtete parametry, načtete data, vyrenderujete fragment, hotovo. To je zdravý model. Porovnejte si to s trasováním bugů ve filtrech přes useTransition, query invalidation, controlled form knihovnu a stale closure uvnitř callbacku, který někdo memoizoval jen kvůli ESLintu. Jeden z těch systémů respektuje váš čas.

Jestli máte aplikaci plnou tabulek, formulářů a schvalovacích flow, zkuste jednu obrazovku. Nepřepisujte všechno. Vyberte si nejotravnější stránku, změřte její client bundle přes next build, sepište si, jaké stavy dnes drží v browseru, a pak vracejte jednu interakci po druhé zpátky k server-renderovaným fragmentům. Dost možná skončíte tím, že smažete víc kódu, než jste čekali, a to je obvykle známka, že se konečně hýbete správným směrem.

Johnny Unar

Napsal/a

Johnny Unar

Chcete s námi spolupracovat?

Pro interní nástroje a B2B portály je HTMX uvnitř Next.js rychlejší na dodání, lehčí a rozbíjí se míň než plný SPA stack.