Integrate HTMX with Strapi for dynamic, server-driven web experiences without complex JavaScript. Build interactive interfaces with hypermedia-driven updates powered by your Strapi CMS content

HTMX is a 14KB JavaScript library that extends HTML with attributes for AJAX requests, WebSocket communication, and DOM manipulation. Instead of writing JavaScript to fetch data and update the UI, you add attributes like hx-get, hx-post, and hx-target directly to your HTML elements.
HTMX embraces hypermedia-driven architecture: servers respond with HTML fragments instead of JSON, representing a return to server-centric patterns with modern progressive enhancement.
Your backend renders HTML with full access to business logic and data, while HTMX handles dynamic updates through declarative attributes. No virtual DOM, no complex state management, no build tools required.
Combining HTMX with Strapi creates a streamlined full-stack architecture that prioritizes simplicity without sacrificing functionality. Here's what makes this pairing effective:
This step-by-step guide walks you through setting up a Strapi backend with custom HTML endpoints and connecting it to an HTMX-powered frontend.
Start by initializing a new Strapi project using the quickstart template:
npx create-strapi@latest my-project --quickstartThis creates a Strapi instance with SQLite for development. From here, start the server:
npm run developThe admin panel becomes available at http://localhost:1337/admin. Create your first administrator account when prompted.
Open the Content-Type Builder in your admin panel and create a new Collection Type called "Article" with these fields:
titlecontentpublishedAtStrapi automatically generates REST API endpoints:
GET /api/articles: Retrieves all articlesGET /api/articles/:documentId: Retrieves a single articlePOST /api/articles: Creates a new articlePUT /api/articles/:documentId: Updates an articleDELETE /api/articles/:documentId: Deletes an articleNote: Strapi v5 uses string-based documentId instead of numeric IDs, representing a major breaking change from v4.
If you've worked with APIs before, you know the frustration of getting 403 errors because permissions aren't configured. Here's how to avoid that:
Set permissions for public access through Settings, then select the Public role and enable find and findOne permissions for your Articles content type.
For authenticated requests, generate an API token through Settings, then API Tokens. The token displays only once, so store it securely for future use.
Configure CORS to allow your frontend origin. Edit ./config/middlewares.js:
module.exports = [
'strapi::logger',
'strapi::errors',
'strapi::security',
{
name: 'strapi::cors',
config: {
origin: ['http://localhost:3000', 'http://localhost:5173'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
headers: ['Content-Type', 'Authorization', 'Origin', 'Accept'],
credentials: true,
},
},
'strapi::poweredBy',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
];This is where HTMX integration differs from typical SPA architecture. While Strapi's default REST endpoints return JSON, the recommended pattern for HTMX is to have the server return HTML fragments that HTMX inserts directly into the DOM. Rather than fetching JSON and rendering it client-side, create custom controllers that return pre-rendered HTML responses.
Create src/api/article/controllers/article.js:
'use strict';
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::article.article', ({ strapi }) => ({
async findHtml(ctx) {
// Use Document Service API instead of deprecated Entity Service
const entities = await strapi
.documents('api::article.article')
.findMany({
populate: '*', // same semantics as before
sort: 'createdAt:desc', // recommended sort syntax in v5
});
const html = entities
.map((article) => `
<article id="article-${article.documentId}" class="article-card">
<h3>${article.title}</h3>
<p>${article.content.substring(0, 150)}...</p>
<div class="actions">
<button
hx-get="/articles/${article.documentId}/html"
hx-target="#article-${article.documentId}"
hx-swap="outerHTML">
View Full
</button>
<button
hx-delete="/api/articles/${article.documentId}"
hx-target="#article-${article.documentId}"
hx-swap="outerHTML"
hx-confirm="Delete this article?">
Delete
</button>
</div>
</article>
`)
.join('');
ctx.type = 'text/html';
ctx.body = html;
},
}));Now create an HTML file that includes HTMX via CDN:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTMX and Strapi Integration</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
.article-card {
border: 1px solid #ddd;
padding: 1rem;
margin: 1rem 0;
}
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline;
}
</style>
</head>
<body>
<h1>HTMX and Strapi Integration</h1>
<!-- Create Article Form -->
<h2>Create New Article</h2>
<form
hx-post="http://localhost:1337/api/articles/create"
hx-target="#form-response"
hx-swap="innerHTML">
<input type="text" name="title" placeholder="Article Title" required>
<textarea name="content" rows="5" placeholder="Article Content" required></textarea>
<button type="submit">Create Article</button>
</form>
<div id="form-response"></div>
<!-- Articles List -->
<h2>Articles</h2>
<div
id="articles-container"
hx-get="http://localhost:1337/api/articles/html"
hx-trigger="load"
hx-indicator="#loading">
<!-- Articles load here -->
</div>
<div id="loading" class="htmx-indicator">Loading...</div>
<!-- Global error handling -->
<script>
document.body.addEventListener('htmx:responseError', function(evt) {
console.error('HTMX Error:', evt.detail.error);
});
</script>
</body>
</html>The hx-get attribute issues GET requests to fetch content from specified endpoints. When combined with hx-trigger="load", the request fires when the element loads into the DOM.
The hx-indicator attribute references an element that displays a loading state during the request: HTMX automatically adds the htmx-request class to indicator elements, allowing CSS to show or hide loading spinners based on request status.
When you need to protect endpoints, avoid injecting API tokens into all HTMX requests via global event listeners; instead, follow standard web‑security practices such as server‑side session management, HttpOnly cookies, CSRF protection, and fine‑grained authorization.
<script>
document.body.addEventListener('htmx:configRequest', (event) => {
event.detail.headers['Authorization'] = 'Bearer YOUR_API_TOKEN';
});
</script>This listener intercepts all HTMX requests and adds the authorization header.
Most teams start by fetching everything and only optimize when it becomes a performance issue. Strapi's query parameters let you prevent over-fetching from the start. In your custom controllers, accept query parameters for filtering and field selection:
Field Selection: Use the fields parameter to return only the attributes your application needs:
GET /api/articles?fields[0]=title&fields[1]=contentSelective Population: Use the populate parameter with field selection to include only necessary relations:
GET /api/articles?populate[author][fields][0]=name&populate[author][fields][1]=emailFiltering: Apply server-side filters to reduce data transfer with operators like $eq, $contains, $gte, and $lte:
GET /api/articles?filters[status][$eq]=published&filters[publishedAt][$notNull]=truePagination: Implement page-based or offset-based pagination to limit response sizes:
GET /api/articles?pagination[page]=1&pagination[pageSize]=10In your custom controllers, leverage these parameters to construct efficient queries:
module.exports = createCoreController("api::article.article", ({ strapi }) => ({
async find(ctx) {
await this.validateQuery(ctx);
const sanitizedQuery = await this.sanitizeQuery(ctx);
const entities = await strapi.documents("api::article.article").findMany({
filters: sanitizedQuery.filters,
fields: sanitizedQuery.fields,
populate: sanitizedQuery.populate,
sort: sanitizedQuery.sort,
pagination: sanitizedQuery.pagination,
status: sanitizedQuery.status,
locale: sanitizedQuery.locale,
});
// Sanitize output before returning
const sanitizedResults = await this.sanitizeOutput(entities, ctx);
// Optionally wrap with transformResponse if you want meta
ctx.body = sanitizedResults;
},
}));
This approach ensures you fetch and transmit only the data required by your HTMX endpoints, significantly reducing bandwidth usage and improving response times.
You can optimize with these strategies:
async findHtml(ctx) {
const entities = await strapi.documents("api::article.article").findMany({
fields: ["title", "content", "publishedAt"],
populate: {
author: {
fields: ["name"],
},
},
filters: {
publishedAt: {
$notNull: true,
},
},
sort: "publishedAt:desc",
limit: 10,
start: 0,
});
const html = entities.map(article => `
<article id="article-${article.documentId}" class="article-card">
<h3>${article.title}</h3>
<p>${article.content.substring(0, 150)}...</p>
<div class="actions">
<button
hx-get="/api/articles/${article.documentId}/html"
hx-target="#article-${article.documentId}"
hx-swap="outerHTML">
View Full
</button>
</div>
</article>
`).join('');
ctx.type = 'text/html';
ctx.body = html;
}This example demonstrates a blog where authenticated users can edit articles inline without page navigation. The pattern shows how HTMX and Strapi work together for content management interfaces.
Create custom controller methods in Strapi to handle dynamic HTMX requests. Custom controllers extend the core controller using createCoreController and can be configured to return HTML fragments for HTMX integration. Register the custom routes in your routes configuration file, specifying the handler for HTMX requests that return HTML content rather than JSON responses.
// src/api/article/controllers/article.js
'use strict';
const { createCoreController } = require("@strapi/strapi").factories;
module.exports = createCoreController("api::article.article", ({ strapi }) => ({
async updateInline(ctx) {
const { documentId } = ctx.params;
const { title, content } = ctx.request.body;
await this.validateQuery(ctx);
const sanitizedInput = await this.sanitizeInput({ title, content }, ctx);
try {
const entity = await strapi.documents("api::article.article").update({
documentId,
data: sanitizedInput,
});
// Check if this is an HTMX request
const isHtmxRequest = ctx.request.headers["hx-request"] === "true";
if (isHtmxRequest) {
// Return HTML fragment for HTMX to swap into DOM
ctx.type = "text/html";
ctx.body = `
<article id="article-${documentId}" class="article-view">
<h3>${entity.title}</h3>
<p>${entity.content}</p>
<button
hx-get="/api/articles/${documentId}/edit-form"
hx-target="#article-${documentId}"
hx-swap="outerHTML">
Edit
</button>
</article>
`;
} else {
const sanitizedOutput = await this.sanitizeOutput(entity, ctx);
return this.transformResponse(sanitizedOutput);
}
} catch (error) {
ctx.throw(500, error);
}
},
}));
Extend the pattern with polling for collaborative editing awareness:
<div
id="articles"
hx-get="http://localhost:1337/api/articles/html"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
</div>The every 30s modifier refreshes content automatically. For more immediate updates, integrate webhooks that trigger Server-Sent Events when content changes.
If you have any questions about Strapi 5 or just would like to stop by and say hi, you can join us at Strapi's Discord Open Office Hours, Monday through Friday, from 12:30 pm to 1:30 pm CST: Strapi Discord Open Office Hours. For more details, visit the Strapi documentation and HTMX documentation.
HTMX works with REST endpoints that return HTML. While Strapi supports GraphQL for non-HTMX consumers, build HTMX integrations with REST endpoints and custom controllers that return HTML fragments. If you need GraphQL for mobile apps or third-party integrations requiring JSON, maintain both APIs in parallel: use GraphQL for JSON consumers that expect structured data, and REST with HTML responses for HTMX-driven interfaces.
Use hx-post with hx-encoding="multipart/form-data" on your form element. Create a custom Strapi controller that processes the upload and returns HTML showing the uploaded file. Strapi's media library plugin handles storage providers (local, S3, Cloudflare R2) while your controller wraps the response in HTML fragments.
Yes. Strapi's draft and publish system works at the API level. Query for draft content in your custom controllers using filters like { publishedAt: { $null: true } } and return appropriate HTML. Add HTMX attributes to preview buttons that fetch draft versions, letting editors switch between published and draft views without page navigation.
HTMX eliminates the JSON serialization/deserialization cycle and reduces JavaScript bundle size by approximately 93% (14KB vs 200KB+). Initial page loads are faster since the server sends complete HTML. However, every interaction requires a server round-trip, so highly interactive applications with complex client-side state might feel slower. The pattern excels for content-heavy sites where reads vastly outnumber writes. Use database connection pooling and caching strategies to optimize server response times.
Absolutely. Start by creating custom controllers alongside your existing REST endpoints. Add HTMX to specific pages that benefit from progressive enhancement: inline editing forms, live search, infinite scroll, while keeping other areas unchanged. The Strapi admin continues working normally since it's a separate application. Migrate features incrementally based on which patterns benefit most from server-driven rendering versus client-side state management.