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:
- Roll your own Express/Fastify API: works, but you’re now operating two deployments and writing your own auth, validators, and types.
- Astro endpoints + a database: fine for read-heavy stuff, painful for anything that needs reactivity or careful state.
- Supabase: strong choice, but Postgres + Row-Level-Security is a lot of surface area for a marketing site that needs three tables.
- Convex: backend functions + a typed client + reactive queries + real-time updates, all hosted, with one-line deploys.
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:
- The Convex provider (a React-only thing) has to wrap any component that talks to Convex.
- The
CONVEX_URLenv variable has to reach the client safely. - 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
- No Convex auth on marketing-site forms. The contact form is anonymous; we don’t make people sign in to send a message. For projects that need auth, we swap
ConvexProviderforConvexProviderWithAuthand pass the auth provider’s token through. That’s a separate recipe. - No realtime subscriptions where one-shot mutations would do.
useQueryis reactive by default; that’s a feature, not a default to reach for. The contact form usesuseMutationand that’s it. - No coupling Astro pages to Convex types. The Convex client only runs in React islands. Astro pages stay static and pre-renderable.
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 project needs a Postgres-shaped relational schema with complex joins. Use Postgres.
- The project is mostly read-heavy with rare writes (e.g., a CMS-backed site). Use a static loader or a CMS.
- The project needs a backend you’ll outgrow Convex on within a year. Pick the destination upfront.
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.