These integration guides are not official documentation and the Strapi Support Team will not provide assistance with them.
What Is HTMX?
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.
Why Integrate HTMX with Strapi
Combining HTMX with Strapi creates a streamlined full-stack architecture that prioritizes simplicity without sacrificing functionality. Here's what makes this pairing effective:
- Eliminate Frontend Build Complexity: HTMX is a 14KB JavaScript library requiring no transpilation or bundling—just include it via CDN and start adding attributes to your HTML.
- Leverage Server-Side Rendering: Extend Strapi's REST API with custom controllers that return HTML fragments instead of JSON, giving templates direct access to content and business logic.
- Reduce JavaScript Payload: HTMX's ~14KB footprint replaces 200KB+ SPA bundles—a 93% reduction that directly improves Core Web Vitals and load times.
- Maintain Development Flexibility: Start with server-rendered pages and progressively enhance interactions where needed, without locking into specific architectures.
How to Integrate HTMX with Strapi
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.
Set Up Your Strapi Backend
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.
Create Content Types
Open the Content-Type Builder in your admin panel and create a new Collection Type called "Article" with these fields:
- Text field:
title - Rich text field:
content - Date field:
publishedAt
Strapi 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 article
Note: Strapi v5 uses string-based documentId instead of numeric IDs, representing a major breaking change from v4.
Configure API Access
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',
];Build Custom Controllers for HTML Responses
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;
},
}));Implement the Frontend
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.
Add Authentication
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.
Optimize Query Performance
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;
}Project Example: Dynamic Blog with Inline Editing
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.
Backend Setup
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);
}
},
}));Adding Real-Time Updates
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.
Strapi Open Office Hours
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.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.