DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on • Originally published at stacknotice.com

Convex + Next.js Complete Guide (2026): Realtime Backend Without the Boilerplate

The moment you need realtime in a Next.js app — other users see changes instantly, live feeds, collaborative editing — you face a wall of setup: websockets, pub/sub, cache invalidation, optimistic updates. Convex collapses all of that into one useQuery call that stays current automatically.

This guide covers the full Convex + Next.js App Router setup from schema to production.

Full guide at stacknotice.com/blog/convex-nextjs-complete-guide-2026

Convex vs Drizzle+Neon vs Supabase

Convex Drizzle + Neon Supabase
Database Document (TypeScript) SQL (Postgres) SQL (Postgres)
Realtime Built-in, reactive Manual Subscriptions
Schema TypeScript TypeScript SQL migrations
Self-hostable No Yes Yes
Best for Realtime, collaborative Traditional CRUD Firebase replacement

Choose Convex when your app needs live collaboration, activity feeds, or any data multiple users see simultaneously.

Choose Drizzle + Neon when you need SQL, complex joins, or cost optimization at scale.

Schema in TypeScript

// convex/schema.ts
import { defineSchema, defineTable } from 'convex/server'
import { v } from 'convex/values'

export default defineSchema({
  tasks: defineTable({
    title: v.string(),
    done: v.boolean(),
    userId: v.string(),
    priority: v.union(v.literal('low'), v.literal('medium'), v.literal('high')),
    createdAt: v.number(),
  })
    .index('by_user', ['userId'])
    .index('by_user_done', ['userId', 'done']),
})
Enter fullscreen mode Exit fullscreen mode

No SQL migration files. Convex generates TypeScript types from this automatically — your editor knows the shape of every document instantly.

Server Functions

// convex/tasks.ts
import { query, mutation } from './_generated/server'
import { v } from 'convex/values'

export const list = query({
  args: {},
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity()
    if (!identity) throw new Error('Unauthorized')

    return await ctx.db
      .query('tasks')
      .withIndex('by_user', (q) => q.eq('userId', identity.subject))
      .order('desc')
      .collect()
  },
})

export const create = mutation({
  args: {
    title: v.string(),
    priority: v.union(v.literal('low'), v.literal('medium'), v.literal('high')),
  },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity()
    if (!identity) throw new Error('Unauthorized')

    return await ctx.db.insert('tasks', {
      title: args.title,
      done: false,
      userId: identity.subject,
      priority: args.priority,
      createdAt: Date.now(),
    })
  },
})

export const toggle = mutation({
  args: { id: v.id('tasks'), done: v.boolean() },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity()
    if (!identity) throw new Error('Unauthorized')

    const task = await ctx.db.get(args.id)
    // Always verify ownership before mutating
    if (!task || task.userId !== identity.subject) {
      throw new Error('Not found or unauthorized')
    }

    await ctx.db.patch(args.id, { done: args.done })
  },
})
Enter fullscreen mode Exit fullscreen mode

No await db.connect(), no connection pool, no SQL strings. Functions auto-deploy when you save.

Reactive Client Queries

'use client'
import { useQuery, useMutation } from 'convex/react'
import { api } from '@/convex/_generated/api'

export function TaskList() {
  const tasks = useQuery(api.tasks.list)
  const toggle = useMutation(api.tasks.toggle)

  if (tasks === undefined) return <div>Loading...</div>

  return (
    <ul>
      {tasks.map((task) => (
        <li key={task._id}>
          <input
            type="checkbox"
            checked={task.done}
            onChange={(e) => toggle({ id: task._id, done: e.target.checked })}
          />
          <span className={task.done ? 'line-through' : ''}>{task.title}</span>
        </li>
      ))}
    </ul>
  )
}
Enter fullscreen mode Exit fullscreen mode

When any other user calls toggle, every component using useQuery(api.tasks.list) re-renders instantly. No websocket code, no subscription management, no cache invalidation. tasks === undefined means loading (not null — Convex's convention).

Auth with Clerk

// app/providers.tsx
'use client'
import { ClerkProvider, useAuth } from '@clerk/nextjs'
import { ConvexProviderWithClerk } from 'convex/react-clerk'
import { ConvexReactClient } from 'convex/react'

const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!)

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ClerkProvider>
      <ConvexProviderWithClerk client={convex} useAuth={useAuth}>
        {children}
      </ConvexProviderWithClerk>
    </ClerkProvider>
  )
}
Enter fullscreen mode Exit fullscreen mode

ConvexProviderWithClerk handles token passing automatically. Your server functions call ctx.auth.getUserIdentity() to get the user — no manual JWT handling.

Server-Side Preloading (No Loading Spinner)

// app/dashboard/page.tsx
import { preloadQuery } from 'convex/nextjs'
import { api } from '@/convex/_generated/api'
import { auth } from '@clerk/nextjs/server'

export default async function DashboardPage() {
  const { getToken } = auth()
  const token = await getToken({ template: 'convex' })

  const preloadedTasks = await preloadQuery(api.tasks.list, {}, {
    token: token ?? undefined
  })

  return <TaskList preloadedTasks={preloadedTasks} />
}
Enter fullscreen mode Exit fullscreen mode

Data arrives with the server-rendered HTML. The client component hydrates from it and stays reactive — no extra fetch, no loading state.

Scheduled Jobs (Built-in Cron)

// convex/crons.ts
import { cronJobs } from 'convex/server'
import { api } from './_generated/api'

const crons = cronJobs()

crons.daily(
  'send due date reminders',
  { hourUTC: 8, minuteUTC: 0 },
  api.notifications.sendDueDateReminders
)

export default crons
Enter fullscreen mode Exit fullscreen mode

No external cron service, no separate worker. Scheduled functions are TypeScript alongside your other server functions.

Deploy

npx convex deploy
Enter fullscreen mode Exit fullscreen mode

Atomic — either all functions deploy or none do. Set environment variables in Convex, not Vercel (except NEXT_PUBLIC_ vars):

npx convex env set RESEND_API_KEY re_...
npx convex env set CLERK_JWT_ISSUER_DOMAIN https://...
Enter fullscreen mode Exit fullscreen mode

Convex changes the model: write a query, it stays current. The tradeoff is no SQL and no self-hosting. If either matters, Drizzle + Neon is the better call. For realtime, collaborative apps, or anything where "refresh to see new data" is unacceptable — Convex is the cleanest solution in the Next.js ecosystem right now.

Full guide with file storage, actions, pagination, and production checklist at stacknotice.com/blog/convex-nextjs-complete-guide-2026.

Top comments (0)