htmx with Next.js: Lightweight Interactivity Patterns
Our admin dashboard had grown to 450KB of JavaScript. Users on slower connections waited 4+ seconds for interactive elements. After experimenting with htmx alongside Next.js App Router, we cut that to 65KB and achieved sub-second interactivity. Here's how we integrated htmx into a Next.js application and the patterns that worked in production.
Why htmx in a Next.js Application
Next.js excels at server-side rendering and routing. htmx excels at adding interactivity through HTML attributes without writing JavaScript. Combined, they create applications where the server renders complete HTML and htmx handles dynamic updates by swapping HTML fragments.
I've found this approach particularly effective for:
- Admin dashboards with data tables
- Form-heavy applications
- Content management interfaces
- E-commerce product listings with filters
The core principle is simple: instead of sending JSON and updating DOM with JavaScript, send HTML and let htmx swap it directly. This eliminates client-side state management for many common interactions.
Setting Up htmx in Next.js App Router
Add htmx to your root layout. I prefer loading it from a CDN with the defer attribute for non-blocking loading:
// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<head>
<script
src="https://unpkg.com/htmx.org@2.0.0"
defer
/>
</head>
<body>{children}</body>
</html>
);
}For production, consider self-hosting the script or using a package:
npm install htmx.org// app/layout.tsx
import 'htmx.org';Partial Page Updates Pattern
The most common htmx pattern replaces sections of a page without full navigation. Here's a product listing with load-more functionality:
// app/products/page.tsx
import { getProducts } from '@/lib/products';
export default async function ProductsPage() {
const initialProducts = await getProducts({ limit: 12 });
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-8">Products</h1>
<div
id="product-grid"
className="grid grid-cols-3 gap-6"
>
{initialProducts.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
<button
hx-get="/api/products?offset=12"
hx-target="#product-grid"
hx-swap="beforeend"
hx-indicator="#load-spinner"
className="mt-8 px-6 py-3 bg-blue-600 text-white rounded-lg"
>
Load More
</button>
<div id="load-spinner" className="htmx-indicator">
Loading...
</div>
</div>
);
}The route handler returns only the HTML fragment:
// app/api/products/route.ts
import { getProducts } from '@/lib/products';
import { renderToString } from 'react-dom/server';
export async function GET(request: Request) {
const url = new URL(request.url);
const offset = parseInt(url.searchParams.get('offset') || '0');
const products = await getProducts({ limit: 12, offset });
// Check if this is an htmx request
const isHtmxRequest = request.headers.get('HX-Request') === 'true';
if (isHtmxRequest) {
// Return only the product cards HTML
const html = products.map(product => `
<div class="bg-white rounded-lg shadow p-4">
<img src="${product.image}" alt="${product.name}" class="w-full h-48 object-cover rounded" />
<h3 class="mt-4 font-semibold">${product.name}</h3>
<p class="text-gray-600">$${product.price}</p>
</div>
`).join('');
return new Response(html, {
headers: { 'Content-Type': 'text/html' }
});
}
// For non-htmx requests, redirect to the full page
return Response.redirect('/products');
}The hx-swap="beforeend" attribute appends new products to the existing grid rather than replacing it.
Form Handling with Server Actions
htmx forms work seamlessly with Next.js route handlers. Here's a contact form that provides immediate feedback:
// app/contact/page.tsx
export default function ContactPage() {
return (
<div className="max-w-md mx-auto py-12">
<h1 className="text-2xl font-bold mb-6">Contact Us</h1>
<form
hx-post="/api/contact"
hx-target="#form-response"
hx-swap="innerHTML"
hx-indicator="#submit-spinner"
className="space-y-4"
>
<div>
<label htmlFor="name" className="block text-sm font-medium">
Name
</label>
<input
type="text"
id="name"
name="name"
required
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
type="email"
id="email"
name="email"
required
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium">
Message
</label>
<textarea
id="message"
name="message"
rows={4}
required
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
/>
</div>
<button
type="submit"
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md"
>
Send Message
<span id="submit-spinner" className="htmx-indicator ml-2">
⏳
</span>
</button>
</form>
<div id="form-response" className="mt-4" />
</div>
);
}The route handler processes the form and returns appropriate HTML:
// app/api/contact/route.ts
export async function POST(request: Request) {
const formData = await request.formData();
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const message = formData.get('message') as string;
// Validate
const errors: string[] = [];
if (!name || name.length < 2) errors.push('Name must be at least 2 characters');
if (!email || !email.includes('@')) errors.push('Valid email required');
if (!message || message.length < 10) errors.push('Message must be at least 10 characters');
if (errors.length > 0) {
return new Response(`
<div class="bg-red-50 border border-red-200 rounded-md p-4">
<h3 class="text-red-800 font-medium">Please fix the following:</h3>
<ul class="mt-2 text-red-700 text-sm list-disc list-inside">
${errors.map(e => `<li>${e}</li>`).join('')}
</ul>
</div>
`, {
headers: { 'Content-Type': 'text/html' }
});
}
// Process the submission (save to database, send email, etc.)
await saveContactSubmission({ name, email, message });
return new Response(`
<div class="bg-green-50 border border-green-200 rounded-md p-4">
<h3 class="text-green-800 font-medium">Message Sent!</h3>
<p class="mt-1 text-green-700 text-sm">
Thanks ${name}, we'll get back to you at ${email} soon.
</p>
</div>
`, {
headers: { 'Content-Type': 'text/html' }
});
}Live Search with Debouncing
htmx's trigger modifiers handle debouncing without JavaScript:
// app/users/page.tsx
export default function UsersPage() {
return (
<div className="container mx-auto py-8">
<div className="mb-6">
<input
type="search"
name="q"
placeholder="Search users..."
hx-get="/api/users/search"
hx-trigger="keyup changed delay:300ms, search"
hx-target="#user-list"
hx-swap="innerHTML"
hx-indicator="#search-indicator"
className="w-full px-4 py-2 border rounded-lg"
/>
<span id="search-indicator" className="htmx-indicator text-gray-500">
Searching...
</span>
</div>
<div id="user-list">
{/* Initial content or loading state */}
</div>
</div>
);
}The delay:300ms modifier waits 300ms after the user stops typing before making the request. This prevents excessive API calls during active typing.
// app/api/users/search/route.ts
import { searchUsers } from '@/lib/users';
export async function GET(request: Request) {
const url = new URL(request.url);
const query = url.searchParams.get('q') || '';
const users = await searchUsers(query);
if (users.length === 0) {
return new Response(`
<div class="text-center py-8 text-gray-500">
No users found matching "${query}"
</div>
`, {
headers: { 'Content-Type': 'text/html' }
});
}
const html = users.map(user => `
<div class="flex items-center gap-4 p-4 border-b">
<img
src="${user.avatar}"
alt="${user.name}"
class="w-12 h-12 rounded-full"
/>
<div>
<h3 class="font-medium">${user.name}</h3>
<p class="text-sm text-gray-600">${user.email}</p>
</div>
</div>
`).join('');
return new Response(html, {
headers: { 'Content-Type': 'text/html' }
});
}Inline Editing Pattern
For data tables with inline editing, htmx provides a clean pattern:
// components/EditableRow.tsx
interface User {
id: string;
name: string;
email: string;
role: string;
}
export function EditableRow({ user }: { user: User }) {
return (
<tr id={`user-${user.id}`}>
<td className="px-4 py-2">{user.name}</td>
<td className="px-4 py-2">{user.email}</td>
<td className="px-4 py-2">{user.role}</td>
<td className="px-4 py-2">
<button
hx-get={`/api/users/${user.id}/edit`}
hx-target={`#user-${user.id}`}
hx-swap="outerHTML"
className="text-blue-600 hover:underline"
>
Edit
</button>
</td>
</tr>
);
}The edit endpoint returns an editable form row:
// app/api/users/[id]/edit/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const user = await getUser(params.id);
return new Response(`
<tr id="user-${user.id}">
<td class="px-4 py-2">
<input
type="text"
name="name"
value="${user.name}"
class="border rounded px-2 py-1 w-full"
/>
</td>
<td class="px-4 py-2">
<input
type="email"
name="email"
value="${user.email}"
class="border rounded px-2 py-1 w-full"
/>
</td>
<td class="px-4 py-2">
<select name="role" class="border rounded px-2 py-1">
<option value="user" ${user.role === 'user' ? 'selected' : ''}>User</option>
<option value="admin" ${user.role === 'admin' ? 'selected' : ''}>Admin</option>
</select>
</td>
<td class="px-4 py-2 space-x-2">
<button
hx-put="/api/users/${user.id}"
hx-target="#user-${user.id}"
hx-swap="outerHTML"
hx-include="closest tr"
class="text-green-600 hover:underline"
>
Save
</button>
<button
hx-get="/api/users/${user.id}/view"
hx-target="#user-${user.id}"
hx-swap="outerHTML"
class="text-gray-600 hover:underline"
>
Cancel
</button>
</td>
</tr>
`, {
headers: { 'Content-Type': 'text/html' }
});
}The hx-include="closest tr" attribute includes all form inputs from the table row in the PUT request.
Delete with Confirmation
htmx's hx-confirm attribute provides native browser confirmation dialogs:
<button
hx-delete={`/api/users/${user.id}`}
hx-target={`#user-${user.id}`}
hx-swap="outerHTML swap:1s"
hx-confirm="Are you sure you want to delete this user?"
className="text-red-600 hover:underline"
>
Delete
</button>The route handler returns empty content for successful deletion:
// app/api/users/[id]/route.ts
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
await deleteUser(params.id);
// Return empty response - htmx removes the element
return new Response('', {
headers: { 'Content-Type': 'text/html' }
});
}Response Headers for Enhanced Control
htmx respects special response headers for advanced behaviors:
// Redirect after successful action
return new Response('', {
headers: {
'HX-Redirect': '/dashboard',
}
});
// Trigger a client-side event
return new Response(html, {
headers: {
'Content-Type': 'text/html',
'HX-Trigger': 'userUpdated',
}
});
// Refresh specific elements
return new Response(html, {
headers: {
'Content-Type': 'text/html',
'HX-Trigger': JSON.stringify({
showToast: { message: 'User saved!', type: 'success' }
}),
}
});CSS for Loading States
htmx adds the htmx-request class during requests. Use this for loading indicators:
/* globals.css */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline;
}
.htmx-request.htmx-indicator {
display: inline;
}
/* Disable buttons during request */
button.htmx-request {
opacity: 0.5;
pointer-events: none;
}
/* Fade effect during swap */
.htmx-swapping {
opacity: 0;
transition: opacity 0.2s ease-out;
}When htmx Beats React Client Components
Based on our production experience, htmx outperforms React client components in these scenarios:
Data Tables: Sorting, filtering, and pagination with server-side processing. No need for client-side state management or TanStack Table.
Form Wizards: Multi-step forms where each step validates server-side. The server controls the flow entirely.
Real-time Updates: Polling or SSE-triggered updates that replace HTML sections. htmx's hx-trigger="every 5s" handles polling automatically.
Admin CRUD: Standard create, read, update, delete operations where the server is the source of truth.
When to Use React Client Components Instead
Keep React client components for:
Complex Interactivity: Drag-and-drop, canvas drawing, real-time collaboration features.
Offline Requirements: PWAs that need to function without network connectivity.
Heavy Animation: Coordinated animations across multiple elements.
Third-Party Integrations: Components that require React-specific libraries (charts, maps with complex interactions).
Performance Comparison
Our dashboard metrics after the htmx migration:
| Metric | Before (React) | After (htmx) | |--------|----------------|--------------| | JavaScript Bundle | 450KB | 65KB | | Time to Interactive | 4.2s | 0.9s | | Lighthouse Score | 67 | 94 | | Server Response | JSON parsing | Direct HTML |
The reduced JavaScript means faster parsing and execution. The server does more work, but modern servers handle HTML generation efficiently.
Hybrid Approach
In practice, we use both. The application shell and complex interactive features use React. Data tables, forms, and content sections use htmx. They coexist without conflict:
// app/dashboard/page.tsx
import { ComplexChart } from '@/components/ComplexChart'; // React
import { DataTable } from '@/components/DataTable'; // htmx-powered
export default function Dashboard() {
return (
<div className="grid grid-cols-2 gap-8">
{/* React component for complex visualization */}
<ComplexChart />
{/* htmx-powered data table */}
<DataTable />
</div>
);
}htmx doesn't replace React in Next.js applications. It complements it by handling the 80% of interactions that don't need client-side JavaScript complexity. For teams drowning in client-side state management, htmx offers a simpler path for many common patterns.