Convex

Connect Convex's real-time backend platform with Strapi 5 to build reactive applications. This guide covers webhook-driven synchronization, TypeScript integration, and a practical live blog project with code examples

Convex logo

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.

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:

  1. Content editors publish or update entries through Strapi's Admin Panel.
  2. Strapi fires a webhook to a Convex HTTP Action endpoint.
  3. The HTTP Action triggers a Convex mutation that writes the synced data to Convex's database.
  4. 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 convex

Initialize your Convex project:

npx convex dev

This 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:

  1. Navigate to Settings → Webhooks.
  2. Click Create new webhook.
  3. Set the URL to https://<your-deployment>.convex.site/strapi-webhook.
  4. Select the events: Entry create, Entry update, Entry delete, Entry publish, Entry unpublish.
  5. 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:

FieldTypeNotes
titleTextRequired
slugUIDBased on title
contentRich TextMain body content
excerptTextShort summary for listing page
categoryEnumeratione.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:

  1. Editor action: A content editor creates an article in Strapi's Admin Panel and clicks publish.
  2. Webhook dispatch: Strapi fires the entry.publish webhook with the article data and documentId.
  3. HTTP Action reception: The Convex HTTP Action at /strapi-webhook verifies the auth header and parses the payload.
  4. Database mutation: The upsertFromStrapi mutation writes or updates the article in Convex's database.
  5. Real-time propagation: Convex's sync engine detects the database change, reruns affected queries, and pushes new results to all subscribed clients.
  6. UI update: React components using useQuery re-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.

Frequently Answered Questions

Convex can serve as the primary data source for your frontend while Strapi remains the content management backend. Once content syncs from Strapi to Convex via webhooks, React components fetch data through Convex's useQuery hook instead of calling Strapi's REST or GraphQL APIs directly. This approach gives you automatic real-time updates and client-side caching. Strapi still handles content creation, editorial workflows, and media management through its Admin Panel.

Strapi stores media through its Media Library, and those files remain hosted by Strapi or your configured storage provider. When syncing to Convex, store the media URL as a string field rather than duplicating the file. Use populate=* in your Strapi API calls to include media URLs in the response, then save those URLs in your Convex table. Convex also supports its own file storage if you need to upload user-generated files separately from CMS-managed assets.

Strapi fires webhooks as HTTP POST requests and expects a 200 response. If the Convex HTTP Action is unreachable, the webhook delivery fails. Strapi doesn't automatically retry failed webhooks by default, so you may want to add a scheduled Convex action that periodically fetches recent content from Strapi's REST API as a reconciliation mechanism. Use Convex's ctx.scheduler.runAfter() to schedule periodic syncs that catch any missed webhook events and keep both systems consistent.

Yes. Strapi v5 includes a Draft and Publish system that separates draft content from published content. The webhook fires on entry.publish and entry.unpublish events, so you can configure the Convex HTTP Action to only sync published content. When an editor unpublishes an article, the webhook triggers a delete mutation in Convex, removing it from the reactive frontend. Draft content stays in Strapi and remains invisible to site visitors until the editor publishes it, giving editorial teams full control over content visibility and publication timing.

You can, but it requires installing the @strapi/plugin-graphql plugin in your Strapi project first. In your Convex action, send a POST request to Strapi's /graphql endpoint with your query. GraphQL is useful when you need specific fields without over-fetching, especially for content types with many relations or Dynamic Zones. Alternatively, the REST API with field selection parameters can be equally effective depending on your query patterns and preferences for endpoint structure.