← Back to Blog

Convex + Astro: The Real-Time Backend Pattern We Use

How we wire Convex into an Astro site for real-time data without giving up Astro's static-by-default speed. The recipe, plus the gotcha that breaks first attempts.

Astro is static-by-default and fast. Convex is a real-time backend with first-class types and reactive queries. They look like an odd couple, but for marketing sites that need a working contact form, comment thread, or admin dashboard, the pairing is hard to beat. This post is the recipe we use on every Astro project that needs a real backend, including the one wrinkle that breaks every first attempt: Astro context providers don’t work in .astro files.

Why Astro + Convex at all

Most Astro sites don’t need a real backend: that’s the point. You ship static HTML, get a 100 Lighthouse score, and call it done. But the moment you need a contact form that doesn’t email itself, a comment thread that updates live, or an admin panel for the team, you’re suddenly shopping for backend infrastructure. The usual options:

For Astro projects in the “I need a real backend, but only for two or three features” zone, Convex is the lowest-friction option we’ve found. This site is built on it: the contact form on the homepage writes to Convex, and the inquiry pipeline lives there.

The recipe

Three pieces have to be wired up:

  1. The Convex provider (a React-only thing) has to wrap any component that talks to Convex.
  2. The CONVEX_URL env variable has to reach the client safely.
  3. The Astro/React boundary has to be drawn in the right place: components that touch Convex are React islands, not Astro components.

Here’s how each piece looks.

Step 1: Install and initialize Convex

The fastest path is the official Convex Astro template: it ships every piece below already wired:

npx create-convex@latest -t astro

If you have an existing Astro project and want to add Convex by hand, install it and run dev:

bun add convex
bunx convex dev

The convex dev command does two things: it creates a convex/ directory in your project and provisions a Convex deployment in the cloud. The folder structure looks like this:

convex/
  _generated/        ← auto-generated; never edit
  schema.ts          ← your tables and indexes
  inquiries.ts       ← server-side functions (queries/mutations/actions)
  comments.ts

Define your schema in convex/schema.ts:

import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  inquiries: defineTable({
    name: v.string(),
    email: v.string(),
    projectType: v.union(
      v.literal("saas_mvp"),
      v.literal("ai_agent"),
      v.literal("automation"),
      v.literal("other"),
    ),
    description: v.string(),
  }).index("by_email", ["email"]),
});

The validators (v.string(), v.union(...)) work as both runtime checks and TypeScript types: the generated client knows the shape of every row. No Zod-shaped duplication, no DTO classes.

Then a mutation in convex/inquiries.ts:

import { mutation } from "./_generated/server";
import { v } from "convex/values";

export const submit = mutation({
  args: {
    name: v.string(),
    email: v.string(),
    projectType: v.string(),
    description: v.string(),
  },
  handler: async (ctx, args) => {
    return await ctx.db.insert("inquiries", args);
  },
});

That’s the entire backend. No Express, no routing layer, no API contract to maintain.

Step 2: Get CONVEX_URL to the client safely

In Astro, environment variables don’t go through process.env for client code: that’s a Next-ism that won’t work. Instead, Astro 5 has astro:env, which validates and exposes env vars at build time.

In astro.config.mjs:

import { defineConfig, envField } from "astro/config";

export default defineConfig({
  env: {
    schema: {
      CONVEX_URL: envField.string({
        access: "public",
        context: "client",
      }),
    },
  },
});

access: "public" means it’s allowed in client bundles. context: "client" means it gets baked into JS that ships to the browser. Now any module can import { CONVEX_URL } from "astro:env/client" and it’ll be statically replaced at build.

Set the value in .env:

CONVEX_URL=https://your-deployment.convex.cloud

The Convex CLI gave you this URL when you ran bunx convex dev.

Step 3: The wrinkle: withConvexProvider

Here’s the part that breaks every first attempt. Convex’s React client needs a <ConvexProvider client={...}> wrapping any component that calls useQuery or useMutation. In Next.js or Vite, you’d put this at the root once and forget about it. In Astro, you can’t. Astro components aren’t React: they don’t run React context. If you wrap a client:load island in <ConvexProvider> from inside an .astro file, the provider exists in Astro’s render tree but not in React’s, and your hooks throw “ConvexProvider not found.” This is a known Astro behavior: context doesn’t cross the framework boundary.

The fix is a higher-order component. Wrap each Convex-using island at definition time, inside its own React module. This is the canonical pattern the Convex team ships in their official Astro template. We keep it in src/lib/convex.tsx:

import { CONVEX_URL } from "astro:env/client";
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { type FunctionComponent, type JSX } from "react";

const client = new ConvexReactClient(CONVEX_URL);

export function withConvexProvider<Props extends JSX.IntrinsicAttributes>(
  Component: FunctionComponent<Props>,
) {
  return function WithConvexProvider(props: Props) {
    return (
      <ConvexProvider client={client}>
        <Component {...props} />
      </ConvexProvider>
    );
  };
}

Now every Convex-touching island wraps itself before being imported into Astro:

// src/components/ContactForm.tsx
import { useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import { withConvexProvider } from "../lib/convex";

function ContactForm() {
  const submit = useMutation(api.inquiries.submit);
  // ... form state, handlers ...
  return <form onSubmit={handleSubmit}>{/* ... */}</form>;
}

export default withConvexProvider(ContactForm);

The HOC pattern means each island gets its own provider scope, and the Astro/React boundary stays clean. One client instance is shared across islands because it’s defined at module scope: you don’t pay for it twice.

Step 4: Drop the island into Astro

---
import ContactForm from "../components/ContactForm";
---

<section>
  <h2>Tell us what you need</h2>
  <ContactForm client:load />
</section>

The client:load directive tells Astro to hydrate this React component on page load. Other directives (client:idle, client:visible, client:only) let you defer hydration if the form is below the fold. For a contact form on the homepage, client:load is the right call.

What we don’t do

When we don’t reach for Convex

We use Convex when the project has 1-5 small features that need a backend. We don’t use it when:

The verdict

For an Astro site that needs a real backend without becoming a backend project, this is the recipe we ship. It’s three files of glue (the schema, the HOC, the env field) and you’ve got typed mutations, reactive queries, and a hosted backend with one-line deploys. The hard part is the wrinkle in Step 3: once you have the HOC, everything else falls into place. (For the rest of the stack we run, Convex slots in alongside Next.js, Mastra, and Vercel as one piece of the same system.)

If you want to see this in production, the contact form on this site uses exactly this setup. Submit a message and you’ll watch it work.

// frequently asked

Common questions

Why use Convex with Astro instead of API routes or a separate backend?
Convex gives you typed mutations, reactive queries, and a hosted backend without standing up a separate API service. For Astro sites that need 1-5 small backend features (forms, real-time updates, light dashboards), this is faster to ship and cheaper to maintain than running a Node API or wiring up Astro's server endpoints. You keep Astro static-by-default and Convex handles the live parts inside React islands.
Why does Convex need a React provider in Astro?
Astro's component context does not flow through to React islands the way it does in a Next.js or pure-React app. Astro components run on the server and React islands hydrate independently, so a top-level <ConvexProvider> in an .astro file does not reach the React tree below it. The fix is to wrap each React island that uses Convex with a small higher-order component that mounts ConvexProvider inside the React boundary. The Convex client itself is defined once at module scope, so the wrapping is per-island but the connection is shared.
When should I not use Convex for an Astro project?
Convex is a great fit for project-shaped backends with 1-5 small features that need real-time updates or typed mutations. It is the wrong choice when the project needs a Postgres-shaped relational schema with complex joins (use Postgres), when the project is mostly read-heavy with rare writes (use a static loader or a CMS), or when you will outgrow Convex inside a year and want to avoid a migration. Pick the destination upfront.
How do you handle authentication with Convex on an Astro site?
For anonymous flows (contact forms, public lookups), Convex needs no auth. For projects that require user auth, swap ConvexProvider for ConvexProviderWithAuth and pass through your auth provider's token (Clerk, Auth.js, etc.). The pattern is the same as the unauthenticated provider, just with one extra prop. We treat that as a separate recipe because the auth provider choice changes the wiring more than the Convex side does.
Does Convex work with Astro's static output?
Yes. Astro pages stay static and pre-renderable, and the Convex client only runs in React islands hydrated on the user's browser. The Convex URL is exposed to the client through Astro's astro:env/client module so it ships into the bundle without leaking server secrets. You get a static-by-default site with live data exactly where you need it, and nothing else changes about Astro's deploy story.