These integration guides are not official documentation and the Strapi Support Team will not provide assistance with them.
What Is Convex?
Convex is an open-source, reactive backend platform that bundles a document-relational database, serverless TypeScript functions, and a real-time sync engine into a single service. Instead of building REST endpoints and managing WebSocket infrastructure yourself, you write queries and mutations in TypeScript that run directly on Convex servers.
When underlying data changes, the sync engine automatically reruns affected queries and pushes updated results to all subscribed clients via WebSockets.
The platform provides three function types: queries (read data, cached, reactive), mutations (write data with ACID guarantees), and actions (call external APIs like Strapi). All functions get automatic TypeScript type generation, so types flow from your database schema through backend logic to React hooks without manual type definitions drifting out of sync.
Convex uses V8 JavaScript isolates for low-latency execution and supports React, Next.js, React Native, and Expo out of the box.
Sign up for the Logbook, Strapi's Monthly newsletter
Why Integrate Convex with Strapi
Strapi 5 excels at content modeling, editorial workflows, and API generation. Convex excels at reactive queries and real-time synchronization. Together, they create a separation of concerns where each system handles what it does best.
- Real-time content delivery without custom infrastructure. Strapi webhooks trigger Convex mutations, and Convex's sync engine pushes updates to every connected client automatically. No polling, no manual cache invalidation.
- Optimistic UI with content-backed data. Convex supports optimistic updates where the UI reflects changes immediately before server confirmation, even when the data originates from Strapi's Content API.
- End-to-end TypeScript type safety. Convex generates types from your database schema, and Strapi v5 supports TypeScript-first backend customization. Shared type definitions eliminate the fragile API contract problem.
- Independent scaling. Scale Strapi for content management and editorial load while scaling Convex independently for high-frequency reactive queries and WebSocket connections.
- Query specialization. Use Strapi's REST API for complex content queries with relations and media, and Convex's reactive queries for live dashboards and instant updates requiring sub-second latency.
- Framework flexibility. Both platforms support multiple frontends. Build with React, Next.js, or other frameworks while consuming Strapi APIs and Convex reactive queries in the same application.
How to Integrate Convex with Strapi
Prerequisites
Before starting, confirm you have:
- Node.js 18+ installed.
- Strapi 5 project running locally or deployed (follow the Strapi quickstart if needed).
- Convex account — sign up at convex.dev (free tier available).
- A Strapi Collection Type created with at least a few published entries.
- Basic familiarity with TypeScript and React.
The integration uses Strapi's webhook system to notify Convex when content changes, and Convex HTTP Actions to receive those notifications. Here's the architecture at a glance:
- Content editors publish or update entries through Strapi's Admin Panel.
- Strapi fires a webhook to a Convex HTTP Action endpoint.
- The HTTP Action triggers a Convex mutation that writes the synced data to Convex's database.
- Convex's sync engine pushes the update to all subscribed React components via WebSockets.
Step 1: Set Up the Convex Project
Start by adding Convex to your frontend project. If you're working with a Next.js or React app, install the Convex client library:
npm install convexInitialize your Convex project:
npx convex devThis command creates a convex/ directory in your project, provisions a Convex deployment, and starts a dev process that watches for changes. You'll see a deployment URL in the terminal output — keep that handy.
Create a schema file to define the table where synced Strapi content will live:
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
articles: defineTable({
strapiDocumentId: v.string(),
title: v.string(),
content: v.string(),
slug: v.string(),
publishedAt: v.string(),
lastSynced: v.number(),
}).index("by_strapi_id", ["strapiDocumentId"]),
});The by_strapi_id index is critical. It enables fast lookups when Strapi sends a webhook update for a specific document. Convex recommends indexes for any field you query frequently.
Step 2: Create the Convex Sync Functions
Next, build the server functions that handle incoming Strapi data. This follows Convex's recommended action-mutation workflow where actions handle external API calls and mutations write to the database with transactional guarantees.
Define a mutation to upsert articles from Strapi:
// convex/articles.ts
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
export const upsertFromStrapi = mutation({
args: {
strapiDocumentId: v.string(),
title: v.string(),
content: v.string(),
slug: v.string(),
publishedAt: v.string(),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query("articles")
.withIndex("by_strapi_id", (q) =>
q.eq("strapiDocumentId", args.strapiDocumentId)
)
.first();
if (existing) {
await ctx.db.patch(existing._id, {
title: args.title,
content: args.content,
slug: args.slug,
publishedAt: args.publishedAt,
lastSynced: Date.now(),
});
} else {
await ctx.db.insert("articles", {
...args,
lastSynced: Date.now(),
});
}
},
});
export const removeByDocumentId = mutation({
args: { strapiDocumentId: v.string() },
handler: async (ctx, args) => {
const existing = await ctx.db
.query("articles")
.withIndex("by_strapi_id", (q) =>
q.eq("strapiDocumentId", args.strapiDocumentId)
)
.first();
if (existing) {
await ctx.db.delete(existing._id);
}
},
});
export const list = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("articles").order("desc").collect();
},
});
export const getBySlug = query({
args: { slug: v.string() },
handler: async (ctx, args) => {
return await ctx.db
.query("articles")
.filter((q) => q.eq(q.field("slug"), args.slug))
.first();
},
});The upsertFromStrapi mutation checks if an article with that strapiDocumentId already exists, then patches or inserts accordingly. This is idempotent. Calling it multiple times with the same data produces the same result, which matters when webhooks occasionally fire more than once.
Step 3: Build the HTTP Action for Webhook Reception
Convex HTTP Actions expose endpoints at https://<your-deployment>.convex.site. Create one to receive Strapi webhook payloads:
// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { api } from "./_generated/api";
const http = httpRouter();
http.route({
path: "/strapi-webhook",
method: "POST",
handler: httpAction(async (ctx, request) => {
// Verify the webhook secret
const authHeader = request.headers.get("Authorization");
const expectedToken = process.env.STRAPI_WEBHOOK_SECRET;
if (!expectedToken || authHeader !== `Bearer ${expectedToken}`) {
return new Response("Unauthorized", { status: 401 });
}
const payload = await request.json();
const { event, model, entry } = payload;
// Only process article content type
if (model !== "article") {
return new Response(JSON.stringify({ skipped: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (event === "entry.delete" || event === "entry.unpublish") {
await ctx.runMutation(api.articles.removeByDocumentId, {
strapiDocumentId: entry.documentId,
});
} else if (
event === "entry.create" ||
event === "entry.update" ||
event === "entry.publish"
) {
await ctx.runMutation(api.articles.upsertFromStrapi, {
strapiDocumentId: entry.documentId,
title: entry.title ?? "",
content: entry.content ?? "",
slug: entry.slug ?? "",
publishedAt: entry.publishedAt ?? new Date().toISOString(),
});
}
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}),
});
export default http;A few things to note here. Strapi v5 uses documentId (a unique string) instead of numeric IDs. The webhook payload includes an event field indicating what happened (entry.create, entry.update, entry.delete, entry.publish, entry.unpublish). The response format in v5 is flattened, so fields like title and content sit directly on the data object rather than nested under attributes.
Set the STRAPI_WEBHOOK_SECRET environment variable in your Convex dashboard under Settings → Environment Variables. Never hardcode secrets in source files.
Step 4: Configure the Strapi Webhook
In your Strapi project, configure webhook headers and the target URL. Open or create config/server.js:
// config/server.js
module.exports = ({ env }) => ({
host: env("HOST", "0.0.0.0"),
port: env.int("PORT", 1337),
app: {
keys: env.array("APP_KEYS"),
},
webhooks: {
defaultHeaders: {
"Authorization": `Bearer ${env("WEBHOOK_TOKEN")}`,
},
},
});Add WEBHOOK_TOKEN to your Strapi .env file. This value should match the STRAPI_WEBHOOK_SECRET you set in the Convex dashboard.
Now register the webhook through the Strapi Admin Panel:
- Navigate to Settings → Webhooks.
- Click Create new webhook.
- Set the URL to
https://<your-deployment>.convex.site/strapi-webhook. - Select the events:
Entry create,Entry update,Entry delete,Entry publish,Entry unpublish. - Save the webhook.
Alternatively, you can use Strapi v5's database lifecycle hooks defined in per-content-type lifecycles.js files for more granular control over which content types trigger synchronization.
These hooks support before/after operations for create, update, delete, and batch operations, allowing you to execute custom logic when specific content types change. However, for external system synchronization, webhooks are generally more reliable than lifecycle hooks, as lifecycle hooks cannot directly access relational data (the webhooks.populateRelations configuration has been removed in v5).
Step 5: Create an Action for Initial Data Sync
Webhooks handle ongoing changes, but you need to pull existing content during initial setup. Convex actions can call external APIs. This is where you fetch from Strapi's REST API:
// convex/sync.ts
import { action } from "./_generated/server";
import { api } from "./_generated/api";
export const initialSync = action({
args: {},
handler: async (ctx) => {
const strapiUrl = process.env.STRAPI_API_URL;
const strapiToken = process.env.STRAPI_API_TOKEN;
if (!strapiUrl || !strapiToken) {
throw new Error("Missing Strapi environment variables");
}
const response = await fetch(
`${strapiUrl}/api/articles?pagination[pageSize]=100&sort=publishedAt:desc`,
{
headers: {
Authorization: `Bearer ${strapiToken}`,
"Content-Type": "application/json",
},
}
);
if (!response.ok) {
throw new Error(`Strapi API returned ${response.status}`);
}
const result = await response.json();
for (const entry of result.data) {
await ctx.runMutation(api.articles.upsertFromStrapi, {
strapiDocumentId: entry.documentId,
title: entry.title ?? "",
content: entry.content ?? "",
slug: entry.slug ?? "",
publishedAt: entry.publishedAt ?? "",
});
}
return { synced: result.data.length };
},
});Generate a Strapi API token with read permissions for your content types and add both STRAPI_API_URL and STRAPI_API_TOKEN to the Convex dashboard environment variables.
Run the sync once from your frontend or the Convex dashboard to populate the initial data. After that, webhooks keep everything current.
Step 6: Wire Up the React Frontend
Set up the Convex provider in your React or Next.js app. For a Next.js project, create a client component wrapper:
// app/ConvexClientProvider.tsx
"use client";
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { ReactNode } from "react";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function ConvexClientProvider({ children }: { children: ReactNode }) {
return <ConvexProvider client={convex}>{children}</ConvexProvider>;
}Wrap your root layout:
// app/layout.tsx
import { ConvexClientProvider } from "./ConvexClientProvider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<ConvexClientProvider>{children}</ConvexClientProvider>
</body>
</html>
);
}Now use Convex's useQuery hook to subscribe to real-time article updates. Create a server component page and a client component for the reactive data:
// app/articles/page.tsx
import { ArticlesClient } from './_components/articles-client';
export default function ArticlesPage() {
return <ArticlesClient />;
}// app/articles/_components/articles-client.tsx
"use client";
import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
export function ArticlesClient() {
const articles = useQuery(api.articles.list);
if (articles === undefined) {
return <p>Loading articles...</p>;
}
return (
<div>
<h1>Articles</h1>
{articles.map((article) => (
<article key={article._id}>
<h2>{article.title}</h2>
<p>{article.content}</p>
<time>{article.publishedAt}</time>
</article>
))}
</div>
);
}The useQuery hook subscribes to the articles.list query. When a content editor publishes or updates an article in Strapi, the webhook fires, Convex updates the database, and the sync engine pushes the new data to this component. No refresh required.
Project Example: Real-Time Blog with Live Content Updates
This project combines Strapi's content management features with Convex's reactivity to build a blog where published articles appear on readers' screens instantly. Editors work in Strapi's familiar Admin Panel, while readers see updates in real time through Convex's WebSocket subscriptions.
Define the Strapi Content Type
Create an Article Collection Type in Strapi with these fields:
| Field | Type | Notes |
|---|---|---|
title | Text | Required |
slug | UID | Based on title |
content | Rich Text | Main body content |
excerpt | Text | Short summary for listing page |
category | Enumeration | e.g., tech, design, ops |
Configure the REST API permissions so the Public role has find and findOne access to Article. Generate an API token with read access for the Convex sync action.
Extend the Convex Schema
Update the schema to match your Strapi content type:
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
articles: defineTable({
strapiDocumentId: v.string(),
title: v.string(),
slug: v.string(),
content: v.string(),
excerpt: v.string(),
category: v.string(),
publishedAt: v.string(),
lastSynced: v.number(),
})
.index("by_strapi_id", ["strapiDocumentId"])
.index("by_slug", ["slug"])
.index("by_category", ["category"]),
});Adding indexes on slug and category supports the query patterns you'll use on the frontend.
Build Filtered Queries
Add a category query that takes advantage of the new index, and update the getBySlug query defined earlier in Step 2 to use the by_slug index for better performance:
// convex/articles.ts
import { query } from "./_generated/server";
import { v } from "convex/values";
export const listByCategory = query({
args: { category: v.string() },
handler: async (ctx, args) => {
return await ctx.db
.query("articles")
.withIndex("by_category", (q) => q.eq("category", args.category))
.order("desc")
.collect();
},
});
// Updated getBySlug using the by_slug index instead of filter
export const getBySlug = query({
args: { slug: v.string() },
handler: async (ctx, args) => {
return await ctx.db
.query("articles")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
.first();
},
});Build the Frontend Components
The article list component subscribes to real-time updates and supports category filtering:
// app/blog/page.tsx
"use client";
import { useState } from "react";
import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
import Link from "next/link";
const CATEGORIES = ["all", "tech", "design", "ops"];
export default function BlogPage() {
const [category, setCategory] = useState("all");
const articles =
category === "all"
? useQuery(api.articles.list)
: useQuery(api.articles.listByCategory, { category });
return (
<div>
<h1>Blog</h1>
<nav>
{CATEGORIES.map((cat) => (
<button
key={cat}
onClick={() => setCategory(cat)}
style={{ fontWeight: category === cat ? "bold" : "normal" }}
>
{cat}
</button>
))}
</nav>
{articles === undefined ? (
<p>Loading...</p>
) : (
<ul>
{articles.map((article) => (
<li key={article._id}>
<Link href={`/blog/${article.slug}`}>
<h2>{article.title}</h2>
</Link>
<p>{article.excerpt}</p>
<span>{article.category}</span>
</li>
))}
</ul>
)}
</div>
);
}The individual article page uses getBySlug for a reactive single-document subscription:
// app/blog/[slug]/page.tsx
"use client";
import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
import { useParams } from "next/navigation";
export default function ArticlePage() {
const { slug } = useParams<{ slug: string }>();
const article = useQuery(api.articles.getBySlug, { slug });
if (article === undefined) {
return <p>Loading...</p>;
}
if (article === null) {
return <p>Article not found.</p>;
}
return (
<article>
<h1>{article.title}</h1>
<time>{article.publishedAt}</time>
<span>{article.category}</span>
<div>{article.content}</div>
</article>
);
}When a Strapi editor updates the article's title or content through the Admin Panel, the webhook fires, Convex processes the mutation, and anyone currently reading that article sees the updated version appear without reloading the page. No WebSocket setup code, no polling intervals, no manual cache busting.
Add a Live Article Count Component
Here's a small component that demonstrates how multiple parts of the UI stay in sync. Drop this into your blog layout to show a live count of published articles:
// components/LiveArticleCount.tsx
"use client";
import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
export function LiveArticleCount() {
const articles = useQuery(api.articles.list);
const count = articles?.length ?? 0;
return <span>{count} articles published</span>;
}When a new article gets published in Strapi, the count updates across every page where this component renders. That's the core value of the integration: Strapi stays the content authority, and Convex makes the delivery reactive.
How It All Connects
The data flow for this project looks like this:
- Editor action: A content editor creates an article in Strapi's Admin Panel and clicks publish.
- Webhook dispatch: Strapi fires the
entry.publishwebhook with the article data anddocumentId. - HTTP Action reception: The Convex HTTP Action at
/strapi-webhookverifies the auth header and parses the payload. - Database mutation: The
upsertFromStrapimutation writes or updates the article in Convex's database. - Real-time propagation: Convex's sync engine detects the database change, reruns affected queries, and pushes new results to all subscribed clients.
- UI update: React components using
useQueryre-render with the latest data.
The entire pipeline runs automatically after the initial setup. Strapi manages the content lifecycle, including drafts, review workflows, and media uploads with its built-in media library feature, while handling role-based access control for secure content management. Convex handles the real-time delivery to frontends through its reactive sync engine that automatically synchronizes database queries to all subscribed clients.
This pattern extends naturally to other Strapi content types. Add more tables to your Convex schema, add more event handlers to the HTTP Action, and the same webhook-driven sync keeps everything current. If you need to enrich content with user-generated data like comments or reactions, those can live natively in Convex tables alongside the synced CMS content, all accessible through the same reactive query system.
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 (UTC-6): Strapi Discord Open Office Hours.
For more details, visit the Strapi documentation and Convex 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.