
Convex Nextjs
- 73 installs
- 3 repo stars
- Updated June 29, 2026
- tristanmanchester/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
convex-nextjs is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- convex-nextjs
- AI & Agent Building
- AI-coding skill
Convex Nextjs by the numbers
- 73 all-time installs (skills.sh)
- Ranked #5,583 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tristanmanchester/agent-skills --skill convex-nextjsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 73 |
|---|---|
| repo stars | ★ 3 |
| Last updated | June 29, 2026 |
| Repository | tristanmanchester/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Convex + Next.js
Use this skill to
- Bootstrap Convex in a new or existing Next.js app.
- Add a feature end to end: schema, indexes, functions, UI hooks, auth, and deployment.
- Debug typical integration failures: missing provider, missing generated code, bad client/server boundaries, missing env vars.
- Review whether Convex is a good fit for a realtime or collaborative Next.js feature.
Do not use this skill when
- The task is plain Next.js UI work with no Convex dependency.
- The backend is definitely not Convex and the user is not considering migration.
- The problem is generic database theory with no Next.js/Convex implementation work.
Default posture
- Use
npx convex devfor development. - Keep reactive hooks in Client Components.
- Prefer indexed queries over
.filter(...). - Treat unbounded lists as paginated by default.
- Put external I/O in actions; add
"use node"only if Node APIs or unsupported packages are required. - Require input validation on public functions; add return validation unless there is a good reason not to.
- Add explicit auth and ownership checks for user data.
- Prefer helper functions or custom wrappers when the same auth/tenant checks repeat.
Starting questions to answer from the repo
1. Is this a new app, an existing Next.js app, or a migration? 2. App Router, Pages Router, or both? 3. Does the feature need reactivity, SSR, server actions, or all three? 4. Is the dataset bounded or should it paginate? 5. Is auth already present? If yes, is it client-only or needed on the server too? 6. Would a Convex component make this feature more reusable or isolated?
Workflow 1 — Choose the right starting path
A. Brand new project
Prefer:
npm create convex@latestIf the user already has a Next.js app structure they want to keep, use path B instead.
B. Existing Next.js app
Install Convex and start dev sync:
npm install convex
npx convex devExpected outcomes:
convex/exists, or the custom functions directory fromconvex.json- generated files appear under
_generated/ - a dev deployment or local deployment is connected
NEXT_PUBLIC_CONVEX_URLis available for the frontend
See references/01-setup-and-decision-tree.md.
Workflow 2 — Model data for query patterns, not screen shapes
Before writing UI, define:
- the tables
- the ownership fields
- the indexes needed for the main reads
- whether lists are bounded or paginated
- whether files should live in Convex File Storage instead of large documents
Rules:
- Prefer flat relational-style documents over deep nested blobs.
- Use
v.id("table")for relationships. - Add indexes for every repeated filter/sort path you know you need.
- If the query would scan an unbounded table, redesign the index or paginate it.
See references/02-schema-and-indexes.md.
Workflow 3 — Pick the correct Convex function shape
Query
Use for pure reads. Keep them small, indexed, and predictable.
Mutation
Use for writes and transactional read-write logic.
Action
Use for external APIs, long-running work, or non-transactional orchestration.
- Stay in the default Convex runtime if
fetchis enough. - Add
"use node"only when you need Node-only APIs or unsupported packages. - Files with
"use node"should contain actions only.
Detailed patterns: references/03-functions-and-safety.md
Workflow 4 — Enforce validation, auth, and ownership early
For public functions:
- define
args - usually define
returns - call
ctx.auth.getUserIdentity()when the function is protected - check ownership or team membership, not just authentication
- move repeated checks into helpers or custom wrappers once duplication starts to spread
If auth or tenant checks repeat in many functions, consider:
convex/lib/auth.tshelpers- thin wrappers/custom functions for
query,mutation, oraction - shared policy helpers for tenant/resource checks
See references/05-auth-and-access-control.md.
Workflow 5 — Respect Next.js boundaries
useQuery,useMutation,useAction,usePaginatedQuery, andusePreloadedQuerybelong in Client Components.- For reactive-first pages with good first paint, use
preloadQueryin a Server Component andusePreloadedQueryin a Client Component. - For server-only reads, use
fetchQuery. - For Server Actions or Route Handlers, use
fetchMutationorfetchAction.
Do not call React hooks from Server Components.
See references/04-nextjs-client-and-server-boundaries.md.
Workflow 6 — Treat large lists as a pagination problem
Use pagination by default when:
- the user says “all”, “feed”, “activity”, “history”, “messages”, “notifications”, “search results”, or “infinite scroll”
- the table can grow without a natural hard limit
- you would otherwise reach for
.collect()on a user-facing list
Pattern:
- backend query uses
.paginate(paginationOpts) - React client uses
usePaginatedQuery
See references/06-pagination-performance-and-realtime.md.
Workflow 7 — Consider components when the feature wants isolation
A Convex component is often worth it when the feature:
- has its own schema, functions, and internal jobs
- should be reusable across apps
- would otherwise pollute the root
convex/folder with tightly-coupled code
Use normal app code when the feature is small and specific to one app.
See references/07-components-migrations-and-reuse.md.
Workflow 8 — Choose the right development mode
- On your own machine or in a local coding agent, standard
npx convex devis usually right. - In remote or background agents that cannot log in, use Agent Mode.
- For isolated local-only development, use local deployments.
See references/08-local-dev-agent-mode-and-cloud-agents.md.
Workflow 9 — Validate before you stop
Run:
python {baseDir}/scripts/validate_project.py --root .Useful flags:
python {baseDir}/scripts/validate_project.py --root . --strict
python {baseDir}/scripts/validate_project.py --root . --jsonThe validator checks for the common failures this skill is designed to catch:
- missing Convex installation or generated code
- missing provider or env wiring
- hook usage in non-client components
- implicit table access
.collect()or.filter()smells in queries- missing validators on Convex functions
- risky
"use node"file mixes - scheduler calls aimed at public functions
- missing TypeScript strictness or missing Convex ESLint plugin
Workflow 10 — Deploy cleanly
During normal development, keep using:
npx convex devFor production or CI:
npx convex deployFor Vercel builds, the common pattern is:
npx convex deploy --cmd "npm run build"See references/09-deploy-ci-and-vercel.md.
What a strong final implementation usually includes
- updated
convex/schema.ts - new or updated indexes
- public functions with
argsand usuallyreturns - auth or ownership checks where needed
- UI wired through the generated
api "use client"only where it is actually needed- paginated lists instead of unbounded collects
- a note about required env vars
- commands the user should run to verify the change
Response shape to prefer when making code changes
1. State the files to add or edit. 2. Explain the architectural choice in one sentence. 3. Apply the code changes. 4. Run the validator or describe the exact checks to run. 5. Call out any follow-up env vars, auth setup, deploy steps, or migration concerns.
Reference map
- Setup and choosing a path: references/01-setup-and-decision-tree.md
- Schema and index design: references/02-schema-and-indexes.md
- Functions, validation, Node actions, scheduler safety: references/03-functions-and-safety.md
- Next.js client/server boundaries and SSR: references/04-nextjs-client-and-server-boundaries.md
- Auth and access control: references/05-auth-and-access-control.md
- Pagination, performance, and realtime: references/06-pagination-performance-and-realtime.md
- Components, migrations, and reuse: references/07-components-migrations-and-reuse.md
- Local dev, Agent Mode, and local deployments: references/08-local-dev-agent-mode-and-cloud-agents.md
- Deploy and CI: references/09-deploy-ci-and-vercel.md
- Troubleshooting and smoke tests: references/10-troubleshooting-and-smoke-tests.md
{
"functional": [
{
"name": "Existing Next.js app: realtime notifications feed",
"prompt": "Add a notifications feed to my Next.js app using Convex. It needs realtime updates, auth, and infinite scroll.",
"expected": [
"Uses or mentions `npx convex dev` during development",
"Adds schema and indexes for user-scoped notifications",
"Uses `.paginate(paginationOpts)` plus `usePaginatedQuery`",
"Keeps Convex React hooks in Client Components",
"Includes auth and ownership checks"
]
},
{
"name": "Server rendering with reactive hydration",
"prompt": "I need a project dashboard page in App Router with a fast first paint, but I still want live updates after load.",
"expected": [
"Uses `preloadQuery` in a Server Component",
"Uses `usePreloadedQuery` in a Client Component",
"Mentions the client/server boundary clearly",
"Avoids calling Convex React hooks directly in a Server Component"
]
},
{
"name": "Third-party API orchestration",
"prompt": "Create a Convex feature that calls an external summarisation API and then stores the result back in my app.",
"expected": [
"Uses an action for the external call",
"Runs database writes through a mutation",
"Uses `\"use node\"` only if a Node-only SDK or API is actually required"
]
},
{
"name": "Access-control-heavy feature",
"prompt": "Add workspace-scoped tasks to my Convex backend. Different users can only see tasks for workspaces they belong to.",
"expected": [
"Models membership or ownership explicitly",
"Checks membership on reads and writes",
"Suggests helpers or wrappers if auth logic repeats"
]
},
{
"name": "Performance review",
"prompt": "Review this Convex repo for slow queries and bad patterns.",
"expected": [
"Looks for `.filter(...)` instead of `.withIndex(...)`",
"Looks for unbounded `.collect()` calls",
"Checks pagination, indexes, and validator coverage",
"Mentions the validation script"
]
},
{
"name": "Cloud agent setup",
"prompt": "My cloud coding agent cannot log in to Convex. What's the safest development flow?",
"expected": [
"Uses Agent Mode for remote/background agents",
"Uses normal `npx convex dev` for local agents on the user's machine",
"Mentions local deployments as an alternative for local-only isolation"
]
}
]
}
{
"should_trigger": [
"Wire Convex into this existing Next.js app and add a comments feature.",
"Why is `NEXT_PUBLIC_CONVEX_URL` undefined in my App Router project?",
"Add infinite-scroll notifications with `usePaginatedQuery`.",
"Set up authenticated server actions with `convex/nextjs`.",
"Should I use Convex for chat in my Next.js SaaS?",
"Help me clean up slow Convex queries and missing indexes.",
"I need to deploy Convex plus Next.js to Vercel with preview environments.",
"Review this `convex/schema.ts` and suggest better indexes."
],
"should_not_trigger": [
"Refactor this plain React component.",
"Explain SQL joins to me.",
"Fix my Prisma schema in a NestJS backend.",
"Help me style this Next.js landing page hero section.",
"Write a Postgres migration for Supabase."
]
}
{
"should_trigger": [
"My `useQuery` call is crashing because the page is a Server Component.",
"Create a Convex action that talks to OpenAI and stores the result.",
"How do I run Convex in a remote coding agent without sharing my account?",
"Turn this growing activity log into a paginated Convex feed."
],
"should_not_trigger": [
"Optimise this MongoDB aggregation pipeline.",
"Add Clerk auth to a Next.js app that does not use Convex.",
"Build a static marketing site in Next.js only.",
"Troubleshoot a Vercel build for a Firebase app."
]
}
Setup and decision tree
Pick the correct entry point
- New project: prefer
npm create convex@latest. - Existing Next.js repo:
npm install convexthennpx convex dev. - Remote/background agent with no login: use Agent Mode.
- Need local-only backend isolation: use
npx convex dev --local.
What npx convex dev should give you
- a Convex deployment connection for development
- a functions directory (
convex/by default, or the path fromconvex.json) - generated files under
_generated/ - a frontend URL such as
NEXT_PUBLIC_CONVEX_URL
Honour convex.json if it exists
Do not assume the functions directory is always convex/. Some repos move it to src/convex/ or another location via:
{
"functions": "src/convex/"
}Recommended App Router layout
app/
layout.tsx
ConvexClientProvider.tsx
page.tsx
convex/
convex.config.ts # optional, especially for components
schema.ts
tasks.ts
users.ts
lib/
auth.ts
_generated/
api.ts
server.tsProvider baseline
Create a singleton client in a Client Component:
"use client";
import { ConvexProvider, ConvexReactClient } from "convex/react";
import type { ReactNode } from "react";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function ConvexClientProvider({ children }: { children: ReactNode }) {
return <ConvexProvider client={convex}>{children}</ConvexProvider>;
}Wrap it near the top of your tree:
import { ConvexClientProvider } from "./ConvexClientProvider";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<ConvexClientProvider>{children}</ConvexClientProvider>
</body>
</html>
);
}Pages Router note
Use the same client and provider pattern in pages/_app.tsx. The React hook rules do not change.
First smoke test
1. Keep npx convex dev running. 2. Create one tiny query. 3. Render it from a Client Component. 4. Confirm the generated api import resolves and data appears.
If any of those steps fail, go straight to the troubleshooting reference.
Schema and indexes
Design for read patterns
Model tables around the queries you expect to run repeatedly, not around the exact shape of a screen.
Ask first
- What is the ownership boundary: user, team, workspace, org?
- Which lists are filtered by owner, status, parent, or time?
- Which reads must be unique?
- Which lists can grow forever and therefore need pagination?
Baseline example
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
users: defineTable({
tokenIdentifier: v.string(),
name: v.string(),
email: v.optional(v.string()),
}).index("by_token", ["tokenIdentifier"]),
projects: defineTable({
ownerId: v.id("users"),
name: v.string(),
archived: v.boolean(),
createdAt: v.number(),
})
.index("by_owner", ["ownerId"])
.index("by_owner_and_archived", ["ownerId", "archived"]),
tasks: defineTable({
projectId: v.id("projects"),
assigneeId: v.optional(v.id("users")),
title: v.string(),
status: v.union(
v.literal("todo"),
v.literal("doing"),
v.literal("done"),
),
dueAt: v.optional(v.number()),
createdAt: v.number(),
})
.index("by_project", ["projectId"])
.index("by_project_and_status", ["projectId", "status"])
.index("by_assignee", ["assigneeId"]),
});Query from indexes, not scans
Prefer:
return await ctx.db
.query("tasks")
.withIndex("by_project_and_status", (q) =>
q.eq("projectId", args.projectId).eq("status", "todo"),
)
.take(50);Avoid reaching first for:
return await ctx.db
.query("tasks")
.filter((q) => q.eq(q.field("projectId"), args.projectId))
.collect();Modelling guidance
- Prefer flat relational documents over deep nested arrays or maps.
- Use
v.id("otherTable")for document relationships. - Keep large blobs in File Storage and store references in tables.
- Add
searchIndexorvectorIndexonly when the feature actually needs search or embeddings. - Be deliberate about timestamps and status fields. Persist what you need for sorting and filtering instead of recomputing everything ad hoc.
Migration mindset
When adding a field or index: 1. update schema.ts 2. update the write path 3. update the reads to use the new index 4. consider whether existing documents need backfill logic
If the repo is mid-migration, prefer additive changes first and only remove old fields after code has switched over.
Functions and safety
Choose the smallest correct function type
- Query: read-only data access.
- Mutation: transactional writes.
- Action: external API calls, long-running orchestration, or non-transactional work.
- Internal variants: for functions that should not be client-callable.
Public functions: baseline requirements
For any public query, mutation, or action:
- define
args - usually define
returns - validate access
- await all promises
- keep database access index-driven
Query example
import { query } from "./_generated/server";
import { v } from "convex/values";
export const getById = query({
args: { taskId: v.id("tasks") },
returns: v.union(
v.object({
_id: v.id("tasks"),
_creationTime: v.number(),
projectId: v.id("projects"),
assigneeId: v.optional(v.id("users")),
title: v.string(),
status: v.union(
v.literal("todo"),
v.literal("doing"),
v.literal("done"),
),
dueAt: v.optional(v.number()),
createdAt: v.number(),
}),
v.null(),
),
handler: async (ctx, args) => {
return await ctx.db.get("tasks", args.taskId);
},
});Mutation example
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { requireCurrentUser } from "./lib/auth";
export const create = mutation({
args: {
projectId: v.id("projects"),
title: v.string(),
},
returns: v.id("tasks"),
handler: async (ctx, args) => {
const user = await requireCurrentUser(ctx);
// Optional ownership check on the parent resource goes here.
return await ctx.db.insert("tasks", {
projectId: args.projectId,
assigneeId: user._id,
title: args.title,
status: "todo",
createdAt: Date.now(),
});
},
});Action example
Use the default Convex runtime unless you need Node-only APIs or unsupported packages.
import { action } from "./_generated/server";
import { api } from "./_generated/api";
import { v } from "convex/values";
export const enrichTask = action({
args: { taskId: v.id("tasks") },
returns: v.null(),
handler: async (ctx, args) => {
const response = await fetch("https://example.com/enrich", {
method: "POST",
body: JSON.stringify({ taskId: args.taskId }),
headers: { "content-type": "application/json" },
});
const data = await response.json();
await ctx.runMutation(api.tasks.applyEnrichment, {
taskId: args.taskId,
summary: data.summary,
});
return null;
},
});"use node": when it is actually justified
Only add "use node" when the action needs:
- Node-only APIs such as filesystem access, certain crypto APIs, or process-level libraries
- third-party SDKs that do not run in the default Convex runtime
If a file uses "use node", keep only actions in that file.
Scheduler safety
When scheduling work from functions, prefer internal function references rather than public api.* references.
Good shape:
await ctx.scheduler.runAfter(0, internal.jobs.sendDigest, {
userId: args.userId,
});Helper functions and wrappers
Keep registered functions thin. Push reusable logic into plain TypeScript helpers:
getCurrentUserrequireMembershiploadProjectOrThrowassertCanEditTask
If many functions repeat the same access checks, consider custom wrappers or helper constructors so the protection is centralised.
Query-performance rules of thumb
- Prefer
.withIndex(...)over.filter(...)whenever possible. - Prefer
.take(n)or pagination over unbounded.collect(). - Treat
.collect()as a smell unless the result set is truly bounded. - If a list is user-facing and can grow indefinitely, paginate it.
Time-based logic
Do not hide core business logic behind ambient state if you can store or pass what you need explicitly. Prefer:
- persisted timestamps for sorting
- stored status fields for common filters
- explicit arguments when evaluating a date-sensitive view
If you do use current time helpers inside queries, keep the logic intentional and easy to test.
Error shape
Use direct, user-meaningful errors:
"Not authenticated""Task not found""You do not have access to this project"
Do not leak secrets or provider internals through error strings.
Next.js client and server boundaries
Non-negotiable boundary
React hooks from convex/react belong in Client Components:
useQueryuseMutationuseActionusePaginatedQueryusePreloadedQuery
If a file uses those hooks, add "use client".
Reactive-first page with server preloading
Server Component:
import { preloadQuery } from "convex/nextjs";
import { api } from "@/convex/_generated/api";
import { TasksClient } from "./TasksClient";
export default async function Page() {
const tasks = await preloadQuery(api.tasks.listRecent, {});
return <TasksClient preloaded={tasks} />;
}Client Component:
"use client";
import { Preloaded, usePreloadedQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
export function TasksClient({
preloaded,
}: {
preloaded: Preloaded<typeof api.tasks.listRecent>;
}) {
const tasks = usePreloadedQuery(preloaded);
return <div>{tasks.length}</div>;
}Server-only reads
When the page does not need live reactivity after render, use:
import { fetchQuery } from "convex/nextjs";Server Actions and Route Handlers
Use server-side helpers for non-client calls:
fetchQueryfetchMutationfetchAction
Example Server Action:
import { fetchMutation } from "convex/nextjs";
import { api } from "@/convex/_generated/api";
export async function createTask(formData: FormData) {
"use server";
await fetchMutation(api.tasks.create, {
title: String(formData.get("title")),
});
}Authenticated server calls
If a server component, server action, or route handler needs authenticated access:
- obtain the provider token on the server
- pass
{ token }as the third argument topreloadQuery,fetchQuery,fetchMutation, orfetchActionas needed
Consistency note
Multiple independent server-side Convex fetches during one render are not guaranteed to be consistent with one another. If consistency matters, avoid building the page from many unrelated server fetches.
Common mistakes
- calling
useQueryinapp/page.tsxwithout"use client" - creating multiple
ConvexReactClientinstances per render instead of once at module scope - mixing client-only auth helpers into Server Components
- trying to use server helpers without
NEXT_PUBLIC_CONVEX_URLor an explicit URL
Auth and access control
Decide the auth shape
- Client-only auth: easiest if only client UI needs auth-gated data.
- Server + client auth: required when Server Components, Server Actions, or Route Handlers need user-scoped access.
- App-level multi-tenant auth: add explicit tenant or workspace membership checks early.
Users table baseline
users: defineTable({
tokenIdentifier: v.string(),
name: v.optional(v.string()),
email: v.optional(v.string()),
role: v.optional(v.union(v.literal("user"), v.literal("admin"))),
}).index("by_token", ["tokenIdentifier"]),Helper-first pattern
Create helpers before inventing wrappers:
import type { QueryCtx, MutationCtx } from "./_generated/server";
import type { Doc } from "./_generated/dataModel";
type AuthCtx = QueryCtx | MutationCtx;
export async function getCurrentUserOrNull(
ctx: AuthCtx,
): Promise<Doc<"users"> | null> {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return null;
return await ctx.db
.query("users")
.withIndex("by_token", (q) =>
q.eq("tokenIdentifier", identity.tokenIdentifier),
)
.unique();
}
export async function requireCurrentUser(ctx: AuthCtx) {
const user = await getCurrentUserOrNull(ctx);
if (!user) throw new Error("Not authenticated");
return user;
}Access control rules
- Never trust a client-supplied
userIdfor permissions. - Load the current user from auth, then compare against the resource.
- For team or workspace apps, check membership on every protected read and write.
- Use internal functions for system-only work.
Ownership example
const task = await ctx.db.get("tasks", args.taskId);
if (!task) throw new Error("Task not found");
if (task.assigneeId !== user._id) {
throw new Error("You do not have access to this task");
}When wrappers/custom functions help
If many functions repeat the same auth, tenant, or role checks:
- centralise the logic in helpers or wrapper factories
- keep the wrapper thin and visible
- avoid making the wrapper so magical that reviewers cannot see what a function requires
If the repo already uses convex-helpers or an equivalent pattern, extend it rather than fighting it.
User creation and syncing
Common patterns:
- create or upsert a user document on first successful sign-in
- update profile data on a webhook or a dedicated sync mutation
- keep provider-specific identifiers out of business logic except for identity mapping
Server-side auth
For authenticated server rendering or server actions:
- obtain a token via the provider's Next.js SDK
- pass that token into
convex/nextjshelpers - continue enforcing auth again inside Convex functions
Frontend checks are UX only. Convex functions are the real security boundary.
Pagination, performance, and realtime
Default rule
If a list can grow indefinitely, make it paginated before you build the UI.
Typical examples:
- activity feeds
- notifications
- messages
- search results
- audit logs
- comments on popular objects
Backend paginated query
import { paginationOptsValidator } from "convex/server";
import { query } from "./_generated/server";
import { v } from "convex/values";
export const listByProject = query({
args: {
projectId: v.id("projects"),
paginationOpts: paginationOptsValidator,
},
handler: async (ctx, args) => {
return await ctx.db
.query("tasks")
.withIndex("by_project", (q) => q.eq("projectId", args.projectId))
.order("desc")
.paginate(args.paginationOpts);
},
});React client
"use client";
import type { Id } from "@/convex/_generated/dataModel";
import { usePaginatedQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
export function Tasks({ projectId }: { projectId: Id<"projects"> }) {
const { results, status, loadMore } = usePaginatedQuery(
api.tasks.listByProject,
{ projectId },
{ initialNumItems: 20 },
);
return (
<>
<ul>{results.map((task) => <li key={task._id}>{task.title}</li>)}</ul>
{status === "CanLoadMore" && (
<button onClick={() => loadMore(20)}>Load more</button>
)}
</>
);
}Realtime fit check
Convex is especially attractive when the user is building:
- live collaborative features
- chat and messaging
- presence or activity indicators
- notifications
- dashboards that should stay fresh without custom polling
Performance checklist
- every hot read path has an index
- the UI uses paginated queries for unbounded data
- mutations write stored sort and filter fields intentionally
- helpers are extracted so business logic is readable
- no accidental full-table scans in core flows
Smells to fix
.collect()on user-facing feeds.filter(...)where an index should exist- fetching many unrelated server-side queries for one page when a smaller number of coherent calls would do
- a query returning far more fields than the UI needs
Realtime UX note
Use useQuery or usePaginatedQuery when the page should stay live after render. Use fetchQuery only for server-only snapshots.
Components, migrations, and reuse
When a component is worth it
Use a Convex component when a feature:
- owns a coherent subsystem with its own tables and functions
- should be reusable across multiple apps
- benefits from an API boundary around its internal state
- would otherwise clutter the root app with tightly-coupled code
If the feature is small and app-specific, keep it in the main app first.
Component basics
A component lives in its own folder with a convex.config.ts, schema, functions, and generated code. A common local layout is:
convex/
convex.config.ts
components/
onboarding/
convex.config.ts
schema.ts
lib/
_generated/Root app wiring
// convex/convex.config.ts
import { defineApp } from "convex/server";
import onboarding from "./components/onboarding/convex.config.js";
const app = defineApp();
app.use(onboarding);
export default app;Component definition
// convex/components/onboarding/convex.config.ts
import { defineComponent } from "convex/server";
export default defineComponent("onboarding_flow");Important caveat
Components are still a beta/unstable part of Convex. Use them deliberately and avoid forcing a component boundary onto a simple one-off feature.
Migration guidance
When improving an existing repo:
- keep public API paths stable if the frontend already depends on them
- add indexes before moving heavy reads onto them
- prefer additive schema changes first
- use thin adapter functions when moving code to new files or components
- remove deprecated paths only after callers have switched
Reuse rule of thumb
Start simple in the app. Extract to a component when reuse or isolation becomes real, not speculative.
Local dev, Agent Mode, and cloud agents
Normal local development
For work on your own machine, start with:
npx convex devThat is the default path unless the environment cannot log in or you explicitly want a local-only backend.
Local deployments
For a local backend process:
npx convex dev --local --onceNotes:
- the local backend runs as a subprocess of
npx convex dev - if the command stops, the backend stops too
- this is for development, not production
Remote or background coding agents
When the agent cannot log in interactively, use Agent Mode:
CONVEX_AGENT_MODE=anonymous npx convex dev --onceA common setup script in cloud agents is:
npm i
CONVEX_AGENT_MODE=anonymous npx convex dev --once
npm testLocal coding agents on your own machine
If the agent runs locally on your machine, standard npx convex dev is usually enough because it can use your existing local credentials and dev environment.
Choosing between the modes
- Your laptop / local editor agent:
npx convex dev - Remote agent with no login: Agent Mode
- Need a local-only backend:
npx convex dev --local
Deploy, CI, and Vercel
Development vs production
- Use
npx convex devwhile building features. - Use
npx convex deployfor production or CI pushes.
Standard production deploy
npx convex deployVercel pattern
A common build command is:
npx convex deploy --cmd "npm run build"Set:
CONVEX_DEPLOY_KEYin Vercel environment variables- production key for production builds
- preview key for preview builds if you want isolated preview deployments
Useful deploy options
Run a preview setup function:
npx convex deploy --cmd "npm run build" --preview-run "seed.preview"Use a different env var name for the build step:
npx convex deploy --cmd-url-env-var-name CUSTOM_CONVEX_URL --cmd "npm run build"CI checklist
- install dependencies
- run typecheck or lint
- run
npx convex deploy - build the frontend through
--cmdwhen the platform expects it - confirm required runtime env vars exist for auth providers and third-party APIs
Practical reminder
Deploying Convex and building Next.js are linked in production. Treat them as one pipeline, not two unrelated steps.
Troubleshooting and smoke tests
Quick smoke test sequence
1. npx convex dev 2. confirm _generated/api.ts exists 3. confirm the app wraps a Convex provider 4. confirm NEXT_PUBLIC_CONVEX_URL exists 5. render one tiny query in a Client Component 6. mutate one record and confirm the UI updates
Common failures
NEXT_PUBLIC_CONVEX_URL is missing
- rerun
npx convex dev - check
.env.local - check hosting env vars
- pass an explicit URL to server helpers if needed
React hook or client/server errors
- add
"use client"to files using Convex React hooks - keep hooks out of Server Components
- ensure the provider actually wraps the route tree
Generated code missing or stale
- keep
npx convex devrunning - run
npx convex codegen - confirm the functions directory path from
convex.json
Query is slow or returns too much data
- add an index
- replace
.filter(...)with.withIndex(...) - paginate instead of
.collect() - return a smaller, deliberate payload
Auth is always null
- confirm the auth provider is wired to Convex on the client
- confirm server-side calls pass a token when needed
- log the result of
ctx.auth.getUserIdentity()in development
Node runtime issues
- if a library only works in Node, move it to an action file with
"use node" - do not mix queries or mutations into that file
Scheduled jobs are callable from the client
- schedule
internal.*functions, not publicapi.*functions
Useful commands
python {baseDir}/scripts/validate_project.py --root .
python {baseDir}/scripts/validate_project.py --root . --strict
npx convex codegen
npx convex dashboard\
#!/usr/bin/env python3
"""Validate a Next.js + Convex project for common integration mistakes.
The script is intentionally conservative and heuristic-based: warnings mean
"review this" rather than "this is definitely broken".
Exit codes:
0 = no errors (or warnings only when not using --strict)
1 = errors found, or warnings found with --strict
2 = invalid usage / unreadable root
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Iterable
ERROR = "error"
WARN = "warning"
INFO = "info"
DEFAULT_SCAN_EXTS = {".ts", ".tsx", ".js", ".jsx", ".mts", ".cts"}
IGNORE_DIRS = {
"node_modules",
".git",
".next",
"dist",
"build",
"coverage",
".turbo",
".vercel",
".cache",
}
@dataclass
class Issue:
level: str
code: str
message: str
path: str | None = None
def eprint(*parts: object) -> None:
print(*parts, file=sys.stderr)
def read_text(path: Path) -> str | None:
try:
return path.read_text(encoding="utf-8", errors="ignore")
except FileNotFoundError:
return None
except OSError as exc:
eprint(f"[error] Could not read {path}: {exc}")
return None
def read_json(path: Path) -> dict[str, Any] | None:
text = read_text(path)
if text is None:
return None
try:
data = json.loads(text)
except json.JSONDecodeError as exc:
eprint(f"[error] Failed to parse JSON in {path}: {exc}")
return None
if not isinstance(data, dict):
eprint(f"[error] Expected JSON object in {path}")
return None
return data
def file_exists_any(paths: Iterable[Path]) -> Path | None:
for path in paths:
if path.exists():
return path
return None
def find_project_files(root: Path, max_files: int) -> list[Path]:
files: list[Path] = []
for path in root.rglob("*"):
if len(files) >= max_files:
break
if path.is_dir():
if path.name in IGNORE_DIRS:
# pruning via rglob isn't easy; we ignore on file stage too
continue
continue
if any(part in IGNORE_DIRS for part in path.parts):
continue
if path.suffix.lower() in DEFAULT_SCAN_EXTS:
files.append(path)
return files
def functions_dir_from_config(root: Path) -> Path:
convex_json = root / "convex.json"
config = read_json(convex_json) if convex_json.exists() else None
raw = None
if config:
raw = config.get("functions")
if isinstance(raw, str) and raw.strip():
return (root / raw.strip()).resolve()
return (root / "convex").resolve()
def normalise_rel(path: Path, root: Path) -> str:
try:
return str(path.resolve().relative_to(root.resolve()))
except Exception:
return str(path)
def first_nonempty_lines(text: str, limit: int = 8) -> str:
lines: list[str] = []
for line in text.splitlines():
stripped = line.strip()
if stripped:
lines.append(stripped)
if len(lines) >= limit:
break
return "\n".join(lines)
def has_use_client(text: str) -> bool:
top = first_nonempty_lines(text, limit=5)
return '"use client"' in top or "'use client'" in top
def has_use_node(text: str) -> bool:
top = first_nonempty_lines(text, limit=5)
return '"use node"' in top or "'use node'" in top
def package_deps(pkg: dict[str, Any]) -> dict[str, str]:
deps: dict[str, str] = {}
for key in ("dependencies", "devDependencies", "peerDependencies"):
value = pkg.get(key)
if isinstance(value, dict):
for dep, version in value.items():
if isinstance(dep, str) and isinstance(version, str):
deps[dep] = version
return deps
def add_issue(issues: list[Issue], level: str, code: str, message: str, path: Path | None, root: Path) -> None:
issues.append(Issue(level=level, code=code, message=message, path=normalise_rel(path, root) if path else None))
def check_package_json(root: Path, issues: list[Issue]) -> dict[str, Any] | None:
pkg_path = root / "package.json"
pkg = read_json(pkg_path)
if pkg is None:
add_issue(issues, ERROR, "missing-package-json", "package.json not found at the project root.", pkg_path, root)
return None
deps = package_deps(pkg)
if "convex" not in deps:
add_issue(
issues,
ERROR,
"missing-convex-dependency",
"Missing `convex` dependency. Install it with `npm install convex`.",
pkg_path,
root,
)
if "next" not in deps:
add_issue(
issues,
WARN,
"missing-next-dependency",
"Could not find a `next` dependency. Review whether this is actually a Next.js repo.",
pkg_path,
root,
)
if "@convex-dev/eslint-plugin" not in deps:
add_issue(
issues,
WARN,
"missing-convex-eslint-plugin",
"Add `@convex-dev/eslint-plugin` so Convex-specific lint rules catch implicit table IDs, missing validators, and query smells.",
pkg_path,
root,
)
return pkg
def check_typescript(root: Path, issues: list[Issue]) -> None:
tsconfig = file_exists_any([root / "tsconfig.json", root / "tsconfig.base.json"])
if tsconfig is None:
add_issue(
issues,
WARN,
"missing-tsconfig",
"No tsconfig file found. If this repo is TypeScript-based, enable `compilerOptions.strict`.",
None,
root,
)
return
data = read_json(tsconfig)
if not data:
return
strict = (data.get("compilerOptions") or {}).get("strict")
if strict is not True:
add_issue(
issues,
WARN,
"ts-strict-disabled",
"`compilerOptions.strict` is not enabled. Convex code benefits from strict TypeScript.",
tsconfig,
root,
)
def check_functions_dir(root: Path, issues: list[Issue]) -> Path:
functions_dir = functions_dir_from_config(root)
if not functions_dir.exists():
add_issue(
issues,
ERROR,
"missing-functions-dir",
f"Functions directory not found at `{normalise_rel(functions_dir, root)}`. Run `npx convex dev` or review `convex.json`.",
functions_dir,
root,
)
return functions_dir
if not (functions_dir / "schema.ts").exists():
add_issue(
issues,
WARN,
"missing-schema",
f"No `schema.ts` found in `{normalise_rel(functions_dir, root)}`. That may be fine for a prototype, but most apps should define schema and indexes explicitly.",
functions_dir,
root,
)
generated_api = file_exists_any([
functions_dir / "_generated" / "api.ts",
functions_dir / "_generated" / "api.js",
])
generated_server = file_exists_any([
functions_dir / "_generated" / "server.ts",
functions_dir / "_generated" / "server.js",
])
if generated_api is None or generated_server is None:
add_issue(
issues,
ERROR,
"missing-generated-files",
f"Generated Convex files are missing in `{normalise_rel(functions_dir / '_generated', root)}`. Keep `npx convex dev` running or run `npx convex codegen`.",
functions_dir / "_generated",
root,
)
return functions_dir
def env_var_present(root: Path, key: str) -> bool:
if os.environ.get(key):
return True
for candidate in [root / ".env.local", root / ".env", root / ".env.development.local"]:
text = read_text(candidate)
if text and re.search(rf"^\s*{re.escape(key)}\s*=", text, re.MULTILINE):
return True
return False
def check_env(root: Path, issues: list[Issue]) -> None:
if not env_var_present(root, "NEXT_PUBLIC_CONVEX_URL"):
add_issue(
issues,
WARN,
"missing-convex-url",
"`NEXT_PUBLIC_CONVEX_URL` was not found in the environment or common env files. Frontend wiring and `convex/nextjs` helpers may fail.",
None,
root,
)
def check_provider_wiring(root: Path, issues: list[Issue]) -> None:
provider = file_exists_any([
root / "app" / "ConvexClientProvider.tsx",
root / "src" / "app" / "ConvexClientProvider.tsx",
root / "app" / "providers.tsx",
root / "src" / "app" / "providers.tsx",
root / "components" / "ConvexClientProvider.tsx",
root / "src" / "components" / "ConvexClientProvider.tsx",
])
layout = file_exists_any([root / "app" / "layout.tsx", root / "src" / "app" / "layout.tsx"])
pages_app = file_exists_any([root / "pages" / "_app.tsx", root / "src" / "pages" / "_app.tsx"])
if provider:
text = read_text(provider) or ""
if not has_use_client(text):
add_issue(
issues,
WARN,
"provider-missing-use-client",
"The provider file exists but is missing `\"use client\"` at the top.",
provider,
root,
)
if "ConvexProvider" not in text or "ConvexReactClient" not in text:
add_issue(
issues,
WARN,
"provider-incomplete",
"The provider file does not obviously create `ConvexReactClient` and wrap `ConvexProvider`.",
provider,
root,
)
if layout:
layout_text = read_text(layout) or ""
if provider.stem not in layout_text and "ConvexProvider" not in layout_text and "ConvexClientProvider" not in layout_text:
add_issue(
issues,
WARN,
"layout-missing-provider",
"App Router layout does not obviously wrap the tree in the Convex provider.",
layout,
root,
)
elif pages_app:
text = read_text(pages_app) or ""
if "ConvexProvider" not in text and "ConvexProviderWith" not in text:
add_issue(
issues,
ERROR,
"pages-app-missing-provider",
"Pages Router `_app` exists but does not appear to wrap the app in a Convex provider.",
pages_app,
root,
)
else:
add_issue(
issues,
WARN,
"missing-provider-file",
"Could not find an obvious Convex provider file or Pages Router `_app` wiring. Review frontend setup.",
None,
root,
)
def check_hook_boundaries(root: Path, issues: list[Issue], files: list[Path]) -> None:
hook_re = re.compile(r"\buse(Query|Mutation|Action|PaginatedQuery|PreloadedQuery)\s*\(")
for path in files:
text = read_text(path)
if not text:
continue
if not hook_re.search(text):
continue
if has_use_client(text):
continue
rel = normalise_rel(path, root)
if rel.startswith("convex/") or rel.startswith("src/convex/"):
continue
add_issue(
issues,
WARN,
"hook-without-use-client",
"This file appears to use Convex React hooks without `\"use client\"`.",
path,
root,
)
def convex_source_files(functions_dir: Path) -> list[Path]:
files: list[Path] = []
if not functions_dir.exists():
return files
for path in functions_dir.rglob("*"):
if path.is_dir():
continue
if "_generated" in path.parts:
continue
if path.suffix.lower() in DEFAULT_SCAN_EXTS:
files.append(path)
return files
def snippets_around_registered_functions(text: str) -> list[str]:
starts = [m.start() for m in re.finditer(r"\b(?:internalQuery|internalMutation|internalAction|query|mutation|action)\s*\(\s*{", text)]
snippets: list[str] = []
for start in starts:
snippet = text[start:start + 1400]
snippets.append(snippet)
return snippets
def check_convex_code(root: Path, functions_dir: Path, issues: list[Issue]) -> None:
implicit_get = re.compile(r"ctx\.db\.get\(\s*(?!['\"])[^,()]+?\)")
implicit_patch = re.compile(r"ctx\.db\.(?:patch|replace|delete)\(\s*(?!['\"])[^,()]+")
schedule_public = re.compile(r"ctx\.scheduler\.(?:runAfter|runAt)\([^,]+,\s*api\.")
filter_smell = re.compile(r"\.filter\s*\(")
collect_smell = re.compile(r"\.collect\s*\(")
for path in convex_source_files(functions_dir):
text = read_text(path) or ""
if has_use_node(text) and re.search(r"\b(?:query|mutation|internalQuery|internalMutation)\s*\(", text):
add_issue(
issues,
ERROR,
"node-file-mixed-runtimes",
"A file marked with `\"use node\"` appears to define queries or mutations. Keep Node runtime files action-only.",
path,
root,
)
if implicit_get.search(text) or implicit_patch.search(text):
add_issue(
issues,
WARN,
"implicit-table-access",
"Possible implicit table access detected. Prefer explicit table names in `ctx.db.get/patch/replace/delete` calls.",
path,
root,
)
if collect_smell.search(text) and re.search(r"\b(?:query|internalQuery)\s*\(", text):
add_issue(
issues,
WARN,
"collect-in-query",
"A query file uses `.collect()`. Review whether the result set is truly bounded or should be paginated.",
path,
root,
)
if filter_smell.search(text) and "ctx.db.query(" in text:
add_issue(
issues,
WARN,
"filter-on-db-query",
"A Convex database query uses `.filter(...)`. Review whether an index plus `.withIndex(...)` would be better.",
path,
root,
)
if schedule_public.search(text):
add_issue(
issues,
WARN,
"schedule-public-function",
"Scheduled work appears to target a public `api.*` function. Prefer scheduling internal functions.",
path,
root,
)
for snippet in snippets_around_registered_functions(text):
if "args:" not in snippet:
add_issue(
issues,
WARN,
"missing-args-validator",
"A registered Convex function may be missing an `args` validator.",
path,
root,
)
break
for snippet in snippets_around_registered_functions(text):
if "returns:" not in snippet:
add_issue(
issues,
WARN,
"missing-returns-validator",
"A registered Convex function may be missing a `returns` validator.",
path,
root,
)
break
def check_next_lint_config(root: Path, issues: list[Issue], functions_dir: Path, pkg: dict[str, Any] | None) -> None:
if pkg is None:
return
scripts = pkg.get("scripts") if isinstance(pkg.get("scripts"), dict) else {}
lint_cmd = scripts.get("lint") if isinstance(scripts, dict) else None
if not isinstance(lint_cmd, str) or "next lint" not in lint_cmd:
return
next_config = file_exists_any([
root / "next.config.ts",
root / "next.config.mjs",
root / "next.config.js",
])
if not next_config:
return
text = read_text(next_config) or ""
functions_name = functions_dir.name
if "eslint" in text and functions_name not in text and "convex" not in text:
add_issue(
issues,
WARN,
"next-lint-may-skip-convex",
"This repo appears to use `next lint`, but `next.config.*` may not include the Convex functions directory in `eslint.dirs`.",
next_config,
root,
)
def render_text_report(root: Path, functions_dir: Path, issues: list[Issue]) -> str:
errors = [issue for issue in issues if issue.level == ERROR]
warnings = [issue for issue in issues if issue.level == WARN]
lines = [
f"Project root: {root}",
f"Functions dir: {functions_dir}",
"",
]
if not issues:
lines.append("No issues found.")
return "\n".join(lines)
def section(title: str, items: list[Issue]) -> None:
if not items:
return
lines.append(title)
for issue in items:
location = f" ({issue.path})" if issue.path else ""
lines.append(f"- [{issue.code}]{location}: {issue.message}")
lines.append("")
section("Errors", errors)
section("Warnings", warnings)
lines.append(f"Summary: {len(errors)} error(s), {len(warnings)} warning(s)")
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser(
description="Validate a Next.js + Convex project for common wiring, safety, and performance issues."
)
parser.add_argument("--root", default=".", help="Project root to inspect (default: current directory).")
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON to stdout.")
parser.add_argument(
"--strict",
action="store_true",
help="Return a non-zero exit code when warnings are found.",
)
parser.add_argument(
"--max-files",
type=int,
default=800,
help="Maximum number of source files to scan outside the Convex functions directory.",
)
args = parser.parse_args()
root = Path(args.root).resolve()
if not root.exists() or not root.is_dir():
eprint(f"[error] Project root does not exist or is not a directory: {root}")
return 2
issues: list[Issue] = []
pkg = check_package_json(root, issues)
check_typescript(root, issues)
functions_dir = check_functions_dir(root, issues)
check_env(root, issues)
check_provider_wiring(root, issues)
files = find_project_files(root, args.max_files)
check_hook_boundaries(root, issues, files)
check_convex_code(root, functions_dir, issues)
check_next_lint_config(root, issues, functions_dir, pkg)
errors = sum(1 for issue in issues if issue.level == ERROR)
warnings = sum(1 for issue in issues if issue.level == WARN)
payload = {
"ok": errors == 0 and (warnings == 0 or not args.strict),
"root": str(root),
"functions_dir": str(functions_dir),
"summary": {"errors": errors, "warnings": warnings, "issues": len(issues)},
"issues": [asdict(issue) for issue in issues],
}
if args.json:
json.dump(payload, sys.stdout, indent=2)
sys.stdout.write("\n")
else:
print(render_text_report(root, functions_dir, issues))
if errors > 0:
return 1
if warnings > 0 and args.strict:
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())