Convex in a Next.js codebase
An architectural orientation to Convex for Next.js developers — where the convex/ directory lives in your project, how it relates to the App Router, and the handful of patterns that ship with it.
6 min read · New · 👍 0
If you've built Next.js apps, the request-response shape is in your bones — a route handler runs, returns JSON or HTML, browser figures out the rest. Convex inverts that. A query in Convex isn't a one-shot read; it's a subscription. Your component asks "what are the user's tasks?" once, and Convex keeps the answer current until the component unmounts. That single property is what makes the patterns below look slightly off until they don't.
#// What is Convex, in App Router terms
Convex is a reactive document database with first-class TypeScript functions. Every backend function is a typed RPC the client can call, and queries also act as subscriptions that re-run when their data changes. No polling, no refetch, no websocket plumbing — you write the query, the client component subscribes, and the data stays current.
Translated to App Router habits: Convex replaces most of your route.ts data handlers, most of your server actions, and the bulk of your revalidatePath calls. Server components and route handlers still exist for the things Convex isn't — HTML rendering, third-party webhooks Convex shouldn't see — but the data layer largely moves out.
#// Where the files live
A Convex project lives in a convex/ directory at the repo root, peer to your app/ directory. It is not a Next.js folder — it has its own deploy lifecycle (npx convex dev), its own runtime, and its own generated TypeScript client at convex/_generated/ that you commit to git. Your Next.js app imports the api object from there to call functions in a typed way; Convex never imports anything from your Next.js code.
The directory looks roughly like this:
convex/
_generated/ # committed; client imports `api` from here
schema.ts # tables + indexes
auth.config.ts # identity providers (Clerk, Auth0, etc.)
http.ts # public HTTP endpoints (webhooks live here)
tasks.ts # queries/mutations/actions for one domain
users.ts # ...another domainA Convex codebase grows by adding domain files rather than routes — each file exports any combination of queries, mutations, and actions, referenced from the client through the generated api namespace (api.tasks.listForUser).
#// Schema is structural, indexes are mandatory
Two things matter in convex/schema.ts: every column you intend to filter on needs an explicit index, and every value type is wrapped in a runtime validator from convex/values. The validators are how Convex type-checks the database at both compile time and write time — no separate migration layer trying to keep them in sync.
// convex/schema.ts
export default defineSchema({
tasks: defineTable({
text: v.string(),
completed: v.boolean(),
userId: v.id('users'),
})
.index('by_completed', ['completed'])
.index('by_user', ['userId']),
users: defineTable({
name: v.string(),
externalId: v.string(),
}).index('byExternalId', ['externalId']),
})The indexes feel like overhead at first — you have to declare them up front rather than chaining .filter(...) on a hot path. That's the point. A filter() call walks the full table in memory; on a 100-row dev fixture it's invisible, on a 100,000-row production table it's catastrophic. Declaring the index in the schema pays the cost once at write time and keeps the read path bounded.
// decision
Declare an index for every column a query filters on
- filter() on small tables: Defensible only when the table is bounded — config rows, enums, single-tenant scratch tables.
#// Three function kinds, enforced by the runtime
The split between query, mutation, and action is the part of Convex most worth internalizing. They aren't naming conventions — they're separate runtime contracts, and the type you pick decides what ctx you get and what you can do with it.
- A query is a pure, reactive read. No external side effects. The runtime tracks every row it touches and re-runs the function when any of those rows change; subscribed client components get the new result, no manual invalidation.
- A mutation is a transactional write. Reads see a consistent snapshot, writes commit together or roll back together. Mutations are auto-retried on conflict, so they need to be deterministic — no
Math.random(), noDate.now()outside the providedctx. - An action is the escape hatch. Node.js-compatible runtime, can call out to third-party APIs, but cannot touch
ctx.dbdirectly — it delegates throughctx.runQueryandctx.runMutation, which gives you the transactional guarantees back at the cost of a function-call boundary.
// convex/tasks.ts
export const listForUser = query({
args: { userId: v.id('users') },
handler: async (ctx, args) =>
ctx.db
.query('tasks')
.withIndex('by_user', (q) => q.eq('userId', args.userId))
.order('desc')
.collect(),
})
export const create = mutation({
args: { text: v.string(), userId: v.id('users') },
handler: async (ctx, args) =>
ctx.db.insert('tasks', { text: args.text, completed: false, userId: args.userId }),
})Query terminators matter: collect() for "all matching rows," first() / unique() for one, take(n) for a cap, paginate(opts) for cursor pagination. Don't reach for collect() on a table you can't fully load. From the Next.js side, client components call into Convex through hooks: useQuery(api.tasks.listForUser, { userId }) for the subscription, useMutation(api.tasks.create) for the write. The same hooks work in Expo with convex/react.
#// Clerk-backed auth lives at the boundary
Clerk handles login and issues a JWT; Convex verifies it and exposes the identity through ctx.auth.getUserIdentity(). Two files connect them. convex/auth.config.ts registers Clerk as a provider — the applicationID field must match the Clerk JWT template name ("convex" by convention; ConvexProviderWithClerk looks for that name specifically).
// convex/auth.config.ts
export default {
providers: [
{
domain: process.env.CLERK_JWT_ISSUER_DOMAIN!,
applicationID: 'convex',
},
],
} satisfies AuthConfigThe second file, convex/http.ts, mounts a webhook endpoint for Clerk. Clerk fires user.created, user.updated, and user.deleted events at it; the handler verifies the svix signature and routes each into an internal mutation that owns the users table. The endpoint is reachable at https://<deployment>.convex.site/clerk-users-webhook — configure that URL in the Clerk dashboard and store the signing secret as CLERK_WEBHOOK_SECRET in the Convex dashboard.
The provisioning shape matters. The tempting alternative — read ctx.auth.getUserIdentity() inside a query and lazily insert if missing — fuses a write into every read path. Queries are read-only by contract; the moment you insert from inside one, every page in the app is responsible for a write that should have happened once. The webhook approach keeps the contracts clean: writes happen at Clerk events, every other query is a pure indexed read against users.
#// Next.js wiring is one provider
ConvexProviderWithClerk calls Clerk's useAuth() to get the JWT, so Clerk has to be the outer provider and Convex nests inside it. The whole stack lives in a client component ('use client' at the top of app/ConvexClientProvider.tsx) and gets pulled into app/layout.tsx once.
// app/ConvexClientProvider.tsx
'use client'
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!)
export function ConvexClientProvider({ children }: { children: ReactNode }) {
return (
<ConvexProviderWithClerk client={convex} useAuth={useAuth}>
{children}
</ConvexProviderWithClerk>
)
}From there, any client component can call useQuery(api.tasks.listForUser, { userId }). Server components can also fetch through the server client (fetchQuery from convex/nextjs), but they get a one-shot value rather than a subscription — the reactive part requires a live client, which only client components carry.
#// Scheduling and file storage round out the surface
ctx.scheduler.runAfter(ms, ref, args) and ctx.scheduler.runAt(timestampMs, ref, args) schedule internal functions — scheduled mutations are exactly-once, scheduled actions are at-most-once, and you always await the scheduler call so it lands inside the triggering transaction. File uploads are a three-step dance: a mutation hands the client a short-lived signed upload URL (ctx.storage.generateUploadUrl()), the client POSTs the file body, a second mutation persists the returned storageId. Read access goes through ctx.storage.getUrl(storageId).
The companion artifact has a working version of everything described here. Drop the files into convex/, set CLERK_JWT_ISSUER_DOMAIN and CLERK_WEBHOOK_SECRET in the Convex dashboard, run npx convex dev, and the typed api object is ready for your client components to import.
// decisions
Treat convex/ as a peer of app/, not a tenant inside it
Convex functions are not Next.js code. They run on Convex's own runtime, get their types from a generated client, and have their own deploy lifecycle. Putting convex/ at the repo root — alongside app/ — mirrors how the code actually behaves at runtime, and stops people from reaching into it from server components by accident.
Webhook-driven user provisioning over read-time upsert
Reading the auth identity inside a query and lazily inserting users couples every read path to a write. Clerk's user.created webhook fires once, lands the row, and every subsequent query is a pure indexed read. The mental model stays clean: queries read, mutations write, and webhooks are the boundary.