// blake_petersen

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.

convexclerknextjsreact-nativeconvexbackendauthrealtime

6 min read · New · 👍 0

$ blink apply skill/convex-patterns

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 domain

A 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.

View full convex/schema.ts →

// decision

Declare an index for every column a query filters on

Convex queries without an index do a full table scan, and the runtime won't stop you. The cost of declaring an index is one line in schema.ts; the cost of debugging a slow query in production is a refactor under load. Pay the cost up front and you also document, in the schema, what access patterns the app actually has.
  • 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(), no Date.now() outside the provided ctx.
  • An action is the escape hatch. Node.js-compatible runtime, can call out to third-party APIs, but cannot touch ctx.db directly — it delegates through ctx.runQuery and ctx.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.

View full convex/tasks.ts →

#// 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 AuthConfig

The 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.

View full convex/http.ts →

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).

View the full artifact (schema, http, users, scheduling, file storage) →

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.