
Integrating Convex Expo
- 67 installs
- 3 repo stars
- Updated June 29, 2026
- tristanmanchester/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
integrating-convex-expo is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- integrating-convex-expo
- AI & Agent Building
- AI-coding skill
Integrating Convex Expo by the numbers
- 67 all-time installs (skills.sh)
- Ranked #5,935 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tristanmanchester/agent-skills --skill integrating-convex-expoAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 67 |
|---|---|
| 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
Integrating Convex with Expo (React Native)
Use this skill to add, repair, extend, or harden a Convex backend for an Expo app without drifting into web-only patterns or generic React Native advice.
Use this skill for
- Adding Convex to an existing Expo or Expo Router app.
- Fixing broken environment setup, provider wiring, or generated API imports.
- Building an end-to-end feature slice: schema, indexes, backend functions, frontend hooks, and validation.
- Choosing and implementing auth for Expo + Convex.
- Uploading files from Expo URIs into Convex storage.
- Refactoring for pagination, indexes, migrations, or reusable components.
- Hardening a project before preview or production release.
Do not use this skill for
- Pure Expo UI, animation, navigation, or styling work with no Convex involvement.
- Convex projects whose frontend is not Expo or React Native.
- Generic backend architecture discussions that do not need Expo-specific environment or client wiring.
Success criteria
A good outcome should leave the project with:
1. A single, correctly scoped Convex client and provider. 2. Working EXPO_PUBLIC_CONVEX_URL handling in local development and EAS. 3. Backend functions that match Convex best practices for validators, auth, actions, and query performance. 4. Frontend hooks using generated api references with clear loading, empty, and error states. 5. A validation pass using scripts/validate_project.py. 6. A clear path for future growth: pagination, indexes, auth wrappers, migrations, and linting.
Non-negotiables
- Keep
npx convex devrunning while developing or repairing the integration. - Read the deployment URL from
process.env.EXPO_PUBLIC_CONVEX_URL. - Mirror that value into EAS environments for preview and production builds.
- Create exactly one
ConvexReactClientper app, outside render paths. - Mount the provider at the true root:
app/_layout.tsx,src/app/_layout.tsx, orApp.tsx. - In Expo and React Native, set
unsavedChangesWarning: false. - Import generated references from
convex/_generated/apirather than stringly typed names. - Treat all client-callable Convex functions as untrusted entry points.
- Add
argsvalidators to public functions, and preferreturnsvalidators as well. - Keep public wrappers thin; move repeated logic into helpers, internal functions, or custom wrappers.
- Do not use
Date.now()ornew Date()inside query logic. - Do not use Node-only APIs or third-party SDKs inside queries or mutations.
- Put external API calls, heavy compute, or Node-only libraries in
actionorinternalActionfiles; if a file starts with"use node", keep it action-only. - Avoid
.filter()and unbounded.collect()on large tables; prefer indexes plus pagination. - Await every promise.
- Prefer TypeScript strict mode and the official Convex ESLint plugin.
First-pass triage
Before making changes, inspect the project and classify the job.
1. Identify the app entrypoint
Check for:
app/_layout.tsxorsrc/app/_layout.tsxfor Expo Router.App.tsxorApp.jsxfor classic entrypoints.
2. Audit Convex state
Look for:
package.jsondependencies:convex,expo, auth libraries, ESLint tooling.convex/andconvex/_generated/..env.local,.env,.env.development,.env.production.eas.jsonif cloud builds matter.- Existing provider usage:
ConvexProvider,ConvexProviderWithClerk, or custom auth wrappers. - Existing schema and indexes in
convex/schema.ts. - Existing public functions with missing validators, auth checks, or pagination.
3. Run the validator
From the project root:
python scripts/validate_project.py --root <project-root>Use --json for machine-readable output or --fail-on-warning when you want stricter gating.
4. Choose the workflow
- Bootstrap / repair baseline: missing Convex setup, env vars, provider, or generated API.
- Build a feature slice: add backend data and UI together.
- Auth: add or repair sign-in and backend authorization.
- File uploads: move media or documents from device URIs into Convex storage.
- Scale / harden: indexes, pagination, components, linting, production checklist.
- Migration: reshape existing tables or gradually move from another backend.
Workflow A — Bootstrap or repair the baseline integration
Step 1: Install or confirm the client package
npx expo install convexStep 2: Create or reconnect the Convex project
npx convex devExpect this to:
- create or connect a Convex project,
- create
convex/if missing, - generate
convex/_generated/, - write
EXPO_PUBLIC_CONVEX_URLto.env.local, - and keep syncing while the command runs.
Step 3: Ensure the root provider exists
For Expo Router:
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { Stack } from "expo-router";
const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!, {
unsavedChangesWarning: false,
});
export default function RootLayout() {
return (
<ConvexProvider client={convex}>
<Stack />
</ConvexProvider>
);
}For classic App.tsx, wrap the top-level navigation tree the same way.
Step 4: Confirm generated imports
Use:
import { api } from "../convex/_generated/api";or the correct relative path for the project layout. Do not hand-write function names.
Step 5: Verify development and build environments
Read references/eas-env.md and ensure the same deployment URL policy is reflected in EAS.
Step 6: Validate and smoke-test
- Start the Expo app.
- Confirm
useQuery(...)moves fromundefinedto real data. - Run
python scripts/validate_project.py --root <project-root>. - If needed, scaffold the canonical example with
python scripts/scaffold_tasks_example.py --root <project-root>.
Workflow B — Build a feature slice end to end
Build each feature in the order below.
1. Model the access pattern first
Before touching code, answer:
- Is the data public, user-scoped, org-scoped, or admin-only?
- Will the list stay small, or should it paginate?
- Which fields are lookup keys and therefore need indexes?
- Does any step require an external API, AI SDK, Stripe, or Node API?
2. Design the schema
Add or extend convex/schema.ts before the function layer whenever the model is stabilising.
Use references/schema-and-indexes.md for:
- flat relational modelling,
- foreign-key indexing,
- compound indexes around actual query shapes,
- bounded array guidance,
- and pagination thresholds.
3. Implement backend functions
Use references/functions.md for the detailed patterns.
Default rules:
queryfor deterministic reads.mutationfor writes.actionorinternalActionfor external APIs, third-party SDKs, or Node-only code.internalQueryorinternalMutationfor logic that should never be callable from the client.
4. Enforce access on the backend
If the feature is not public, do one of these:
- explicit
ctx.auth.getUserIdentity()checks in each function, or - reusable custom wrappers via
convex-helpers.
See references/auth.md and references/components-and-helpers.md.
5. Wire the frontend
Use references/frontend-patterns.md.
Always handle:
- loading:
useQuery(...) === undefined, - empty state,
- mutation pending state where relevant,
- recoverable errors,
- and pagination for unbounded lists.
6. Validate the slice
Before finishing:
- run the validator with the project root,
- run lint and typecheck if the project has them,
- verify the feature on device or simulator,
- and update or add indexes before shipping.
Workflow C — Authentication and authorization
Pick one auth story and keep the stack coherent.
Option 1: Clerk
Good when the app already uses Clerk or wants a polished auth product.
Option 2: Convex Auth
Good when you want a Convex-native auth stack and are comfortable adopting a beta library.
Option 3: Existing JWT or OIDC provider
Good when the product already depends on Auth0, WorkOS, a company IdP, or another OIDC flow.
Rules regardless of provider
- Backend authorization is mandatory; client-side gating is only for UX.
- If you persist users in Convex, index by a stable identifier such as
tokenIdentifier. - Check ownership, organisation membership, or role on every protected function.
- Prefer helper functions or custom wrappers instead of repeating auth boilerplate.
- Use internal functions for privileged internal-only operations.
Read references/auth.md for a decision matrix and implementation patterns.
Workflow D — File uploads from Expo
Use Convex upload URLs rather than trying to push raw files through a mutation.
Recommended flow:
1. public or protected mutation returns ctx.storage.generateUploadUrl(), 2. client loads the local file:// URI with fetch(uri) and converts it to a Blob, 3. client POSTs the blob to the upload URL, 4. second mutation stores the returned storageId plus app-specific metadata, 5. optional scheduled or action-based post-processing happens afterwards.
Read references/file-uploads.md.
Workflow E — Scale and harden
Once the baseline works, raise the quality bar.
Performance
- Replace
.filter()scans with indexed queries where possible. - Replace unbounded
.collect()withtake,first, or paginated queries. - Use
usePaginatedQueryfor growing feeds, notifications, and infinite-scroll screens. - Push repeated or reusable feature areas into Convex components when boundaries are clear.
Safety
- Add validators consistently.
- Keep privileged logic behind internal functions.
- Use
ConvexErroror clear return shapes for expected failures. - Never trust IDs from the client without checking ownership or membership.
Maintainability
- Turn on
@convex-dev/eslint-plugin. - Use TypeScript strict mode.
- Keep wrappers thin and move shared logic into plain TypeScript helpers.
- Separate
"use node"action files from regular runtime files.
Read references/production-checklist.md.
Workflow F — Migrations
When changing a live schema, prefer additive transitions.
Default migration strategy:
1. add new fields or tables in a backwards-compatible way, 2. dual-read or dual-write if the shape is changing, 3. backfill existing documents in idempotent batches, 4. switch readers and writers, 5. then remove the old shape.
Use internal functions or scheduled jobs for backfills, and keep migrations restart-safe.
Read references/migrations.md.
Fast troubleshooting path
If the app is broken, check these in order:
1. Is npx convex dev running and free of TypeScript errors? 2. Does .env.local contain EXPO_PUBLIC_CONVEX_URL? 3. Was Metro restarted after the env file changed? 4. Is there exactly one ConvexReactClient? 5. Is the provider mounted at the real root? 6. Does convex/_generated/api exist, and are imports pointing at it correctly? 7. Are public functions missing args or returns validators? 8. Is a query using Date.now(), .filter(), or unbounded .collect()? 9. Is a "use node" file incorrectly mixing queries or mutations? 10. Is auth mismatched between the client provider and the backend config?
See references/troubleshooting.md.
Available scripts
scripts/validate_project.py- Validates the project rooted at
--rootor, if copied into a repo, the current working tree. - Validates Expo and Convex dependencies.
- Checks env files, provider wiring, generated code, validator presence,
"use node"misuse, and common query anti-patterns. - Supports
--json,--root, and--fail-on-warning.
scripts/scaffold_tasks_example.py- Dry-run by default.
- Can generate
sampleData.jsonl,convex/schema.ts,convex/tasks.ts, and an optional Expo screen file. - Supports
--write,--overwrite,--ui-file, and--json.
References
Load only the file that matches the current task.
- references/eas-env.md — local envs, EAS envs, and deployment URL handling.
- references/tasks-example.md — canonical minimal example for the stack.
- references/schema-and-indexes.md — schema design, indexes, query shape mapping, and pagination triggers.
- references/functions.md — query, mutation, action, internal function, validators, and runtime boundaries.
- references/frontend-patterns.md — provider placement, hook usage, loading and error states, and paginated lists.
- references/auth.md — Clerk, Convex Auth, JWT or OIDC, user mapping, and server-side authorization.
- references/file-uploads.md — upload URLs, Expo URI handling, metadata storage, and post-processing.
- references/components-and-helpers.md — Convex components and
convex-helperspatterns. - references/migrations.md — additive rollout, backfills, dual reads and writes, and batched migrations.
- references/production-checklist.md — pre-release hardening checklist.
- references/troubleshooting.md — common failures and exact fixes.
- references/evaluation.md — how to use the bundled trigger and output eval files.
- references/sources.md — upstream docs and materials used for this rewrite.
{
"skill_name": "integrating-convex-expo",
"evals": [
{
"id": 1,
"prompt": "I have a fresh create-expo-app project using Expo Router. Add Convex correctly, make sure the provider is in the right place, and tell me what to do for EAS builds.",
"expected_output": "The response or edits should install Convex, run through `npx convex dev`, mount a single Convex provider in the Expo Router root layout, use `process.env.EXPO_PUBLIC_CONVEX_URL`, mention `unsavedChangesWarning: false`, and explain that the deployment URL must also be copied into EAS environment variables.",
"files": []
},
{
"id": 2,
"prompt": "My Expo app already has Convex but `useQuery(api.tasks.list)` never resolves. Please debug the likely causes and repair the setup.",
"expected_output": "The response should diagnose the common Expo-specific causes in a useful order: `npx convex dev` not running, missing or stale `_generated`, missing root provider, wrong generated API import path, missing `EXPO_PUBLIC_CONVEX_URL`, or Metro needing a restart. It should suggest running the bundled validator script.",
"files": []
},
{
"id": 3,
"prompt": "Add a tasks feature to my Expo + Convex app. I need a schema, indexes, secure backend functions, and a screen that handles loading and empty states correctly.",
"expected_output": "The output should design the schema first, add indexes around the intended query shapes, use validated public functions, keep authorization on the backend, import generated API references on the frontend, and handle `useQuery(...) === undefined` cleanly in the screen.",
"files": []
},
{
"id": 4,
"prompt": "I want Clerk auth in my Expo app with Convex, and every user should only see and update their own documents.",
"expected_output": "The output should choose a coherent Clerk + Convex setup, describe or implement the provider pairing, derive identity from Convex auth on the backend, use ownership checks or auth wrappers for protected data, and avoid trusting a client-supplied user ID.",
"files": []
},
{
"id": 5,
"prompt": "Please add image uploads from expo-image-picker to Convex storage and save file metadata in the database.",
"expected_output": "The output should use the upload-URL flow: generate upload URL in a mutation, fetch the local Expo URI, convert it to a Blob, POST it to the upload URL, then persist the returned `storageId` plus metadata in a second mutation. It should not try to upload raw file bytes through a normal mutation.",
"files": []
},
{
"id": 6,
"prompt": "My notifications screen in an Expo app loads every record with `.collect()` and it is starting to feel slow. Can you fix the backend and frontend properly?",
"expected_output": "The output should replace the unbounded query with a paginated query and matching indexes, then wire the screen to `usePaginatedQuery` or an equivalent Expo-friendly paginated pattern rather than keeping a full-table `.collect()`.",
"files": []
},
{
"id": 7,
"prompt": "We need to rename a field and split one table into two in a live Expo + Convex app. Please propose a safe migration plan that accounts for mobile clients updating slowly.",
"expected_output": "The output should recommend an additive migration with optional fields or new tables first, backfills in batches, dual reads or dual writes during the transition, and only then removal of the old shape after clients have caught up.",
"files": []
}
]
}
[
{
"query": "I have an Expo Router app and want to add Convex for a realtime task list. Can you wire it up properly?",
"should_trigger": true
},
{
"query": "why is useQuery(api.tasks.list) always undefined in my Expo app?",
"should_trigger": true
},
{
"query": "Set EXPO_PUBLIC_CONVEX_URL for my Expo app and make sure EAS preview and production builds use the right Convex deployment",
"should_trigger": true
},
{
"query": "Add Clerk auth to my Expo + Convex app and make sure each user only sees their own notes",
"should_trigger": true
},
{
"query": "How do I upload images from expo-image-picker into Convex storage and save metadata?",
"should_trigger": true
},
{
"query": "My Convex backend in an Expo app needs Stripe calls. Which functions should be actions and which should stay mutations?",
"should_trigger": true
},
{
"query": "Can you help me migrate this Expo app from Firebase to Convex without breaking old mobile builds?",
"should_trigger": true
},
{
"query": "I need infinite scroll notifications in my React Native app using Convex. Please add pagination and the right indexes.",
"should_trigger": true
},
{
"query": "We already have Convex in an Expo app but the provider setup and generated imports are messy. Can you clean it up?",
"should_trigger": true
},
{
"query": "Help me design a Convex schema for an Expo app with users, teams, projects and tasks, and make sure the indexes match the queries",
"should_trigger": true
},
{
"query": "Animate a bottom sheet in Expo and make it feel more native on iOS",
"should_trigger": false
},
{
"query": "Build a Next.js app with Convex and Clerk",
"should_trigger": false
},
{
"query": "What's the best React Native chart library for line graphs?",
"should_trigger": false
},
{
"query": "Fix my Expo app icon, splash screen and status bar configuration",
"should_trigger": false
},
{
"query": "Write a Node.js script that uploads a file to Amazon S3",
"should_trigger": false
},
{
"query": "Explain Convex components in general; there is no mobile client involved",
"should_trigger": false
},
{
"query": "Help me configure Clerk in a Next.js App Router project",
"should_trigger": false
},
{
"query": "Set up Firebase in Expo with anonymous auth and Firestore",
"should_trigger": false
},
{
"query": "How do I add Expo Router tabs and deep links?",
"should_trigger": false
},
{
"query": "Optimise my Postgres schema for team memberships and task comments",
"should_trigger": false
}
]
Authentication and authorization for Expo + Convex
This reference is about choosing an auth stack and then enforcing access correctly on the backend.
First principle
Authentication answers who the user is.
Authorization answers what that user may do.
Expo screens can help with auth UX, but authorization must live in Convex functions.
Decision matrix
Choose Clerk when
- the app already uses Clerk,
- you want polished hosted auth flows,
- you want a familiar Expo-friendly product auth layer,
- or the team already knows Clerk.
Choose Convex Auth when
- you want a Convex-native auth stack,
- you do not want a separate auth SaaS for this project,
- and you are comfortable adopting a beta library.
Choose an existing JWT or OIDC provider when
- the company already uses Auth0, WorkOS, an enterprise IdP, or a custom OIDC flow,
- the app needs to plug into an existing identity estate,
- or migrations would be more expensive than keeping the current provider.
Backend rules that never change
- Never rely on a client-provided
userIdwithout verifying it. - Every protected function should derive the current user from
ctx.auth.getUserIdentity(), directly or via a wrapper. - Check ownership, membership, or role before returning data or performing writes.
- Use internal functions for privileged operations that should not be directly public.
Users table pattern
If the product needs user profiles in Convex, create a users table keyed by a stable identity field.
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
users: defineTable({
tokenIdentifier: v.string(),
email: v.string(),
name: v.string(),
imageUrl: v.optional(v.string()),
role: v.union(v.literal("user"), v.literal("admin")),
})
.index("by_token", ["tokenIdentifier"])
.index("by_email", ["email"]),
});Identity lookup helper
import type { MutationCtx, QueryCtx } from "./_generated/server";
export async function getCurrentUser(ctx: QueryCtx | MutationCtx) {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Not authenticated");
}
const user = await ctx.db
.query("users")
.withIndex("by_token", (q) =>
q.eq("tokenIdentifier", identity.tokenIdentifier),
)
.unique();
if (!user) {
throw new Error("User not found");
}
return user;
}Clerk notes
Typical stack:
@clerk/clerk-expoon the client,- Convex configured with Clerk as an auth provider,
- a root provider that combines Clerk and Convex,
- backend functions that still perform access checks.
Patterns to remember:
- UI can be gated with Clerk state and Convex auth helpers.
- Convex should still validate the token and enforce access on reads and writes.
- If the app stores user documents, sync them from a protected mutation rather than trusting client state.
Convex Auth notes
Use this when you want auth directly in the Convex backend.
What to watch:
- it supports React Native,
- but it is still beta,
- so check current docs before making it the default for a production-critical app.
For teams already committed to a separate identity provider, Convex Auth may not be the cheapest migration.
Generic JWT or OIDC notes
When keeping an existing provider:
- configure Convex to validate the provider’s JWTs,
- ensure the token contains a stable subject or identifier,
- map that identifier to the
userstable when needed, - keep the backend checks exactly as strict as with any other provider.
Custom function wrappers with convex-helpers
If many functions repeat the same auth and tenant checks, use wrappers.
Example: authenticated query wrapper
import { customQuery, customMutation } from "convex-helpers/server/customFunctions";
import { query, mutation } from "../_generated/server";
import { getCurrentUser } from "./auth";
export const authedQuery = customQuery(query, {
args: {},
input: async (ctx, args) => {
const user = await getCurrentUser(ctx);
return { ctx: { ...ctx, user }, args };
},
});
export const authedMutation = customMutation(mutation, {
args: {},
input: async (ctx, args) => {
const user = await getCurrentUser(ctx);
return { ctx: { ...ctx, user }, args };
},
});Example: org-scoped wrapper
import { customQuery } from "convex-helpers/server/customFunctions";
import { query } from "../_generated/server";
import { v } from "convex/values";
import { getCurrentUser } from "./auth";
export const orgQuery = customQuery(query, {
args: {
organizationId: v.id("organizations"),
},
input: async (ctx, args) => {
const user = await getCurrentUser(ctx);
const membership = await ctx.db
.query("organizationMembers")
.withIndex("by_org_and_user", (q) =>
q.eq("organizationId", args.organizationId).eq("userId", user._id),
)
.unique();
if (!membership) {
throw new Error("Not authorized for this organization");
}
return {
ctx: {
...ctx,
user,
organizationId: args.organizationId,
role: membership.role,
},
args,
};
},
});Backend authorization patterns
Owner-only resource
- verify the current user,
- fetch the resource,
- compare
resource.userIdwithuser._id, - reject mismatches.
Team or org membership
- store membership rows in a join table,
- index by
(organizationId, userId)or(teamId, userId), - check membership before loading or mutating resources.
Role-based admin access
- keep a
rolefield on the user or membership row, - centralise the check in a helper or wrapper,
- and avoid copying role logic across dozens of files.
Common mistakes
UI says signed in but Convex behaves unauthenticated
Usually caused by one of these:
- the root provider pairing is incomplete,
- the backend provider config does not match the client provider,
- or the client is rendering too early before Convex auth has finished initialising.
Resource access keyed by email alone
Do not use mutable or user-controlled fields like email as the only access key. Resolve the verified identity first.
Repeating auth logic everywhere
If the same auth and tenant boilerplate appears in three or more places, wrap it.
Review checklist
- [ ] Auth provider choice matches the project’s existing stack.
- [ ] Protected functions derive identity from Convex auth, not from the client alone.
- [ ] A stable identity field such as
tokenIdentifieris indexed. - [ ] Ownership or membership checks are implemented on the backend.
- [ ] Repeated auth boilerplate is centralised in helpers or wrappers.
- [ ] Privileged workflows are moved behind internal functions when appropriate.
Convex components and convex-helpers
Use this reference when the backend is starting to sprawl or when repeated patterns deserve their own abstraction.
When to use convex-helpers
Install convex-helpers when you want proven patterns for:
- auth wrappers,
- tenant wrappers,
- relationship helpers,
- sessions,
- and other common building blocks.
npm install convex-helpersHighest-value helper: custom function wrappers
This is the most useful pattern for Expo apps with authenticated user data.
Why wrap functions?
Without wrappers, every query and mutation repeats:
- identity lookup,
- user lookup,
- membership checks,
- and role checks.
That repetition is noisy and easy to get wrong.
Authenticated wrapper example
convex/lib/authFunctions.ts
import { customQuery, customMutation } from "convex-helpers/server/customFunctions";
import { query, mutation } from "../_generated/server";
import { getCurrentUser } from "./auth";
export const authedQuery = customQuery(query, {
args: {},
input: async (ctx, args) => {
const user = await getCurrentUser(ctx);
return { ctx: { ...ctx, user }, args };
},
});
export const authedMutation = customMutation(mutation, {
args: {},
input: async (ctx, args) => {
const user = await getCurrentUser(ctx);
return { ctx: { ...ctx, user }, args };
},
});Use it like this:
import { authedQuery, authedMutation } from "./lib/authFunctions";
import { v } from "convex/values";
export const listMine = authedQuery({
args: {},
handler: async (ctx) => {
return await ctx.db
.query("tasks")
.withIndex("by_user", (q) => q.eq("userId", ctx.user._id))
.order("desc")
.take(50);
},
});
export const create = authedMutation({
args: { title: v.string() },
returns: v.id("tasks"),
handler: async (ctx, args) => {
return await ctx.db.insert("tasks", {
userId: ctx.user._id,
title: args.title.trim(),
createdAt: Date.now(),
});
},
});Tenant-scoped wrapper
When the product has organisations or teams, wrap membership checks too.
Benefits:
- less boilerplate,
- fewer authorization mistakes,
- cleaner function bodies,
- and better consistency across the backend.
Relationship helpers
If the backend frequently loads related entities, relationship helpers can keep code readable.
Typical examples:
- load task + assignee,
- load project + members,
- load channel + unread counts,
- load file + owner.
Use them when they make the code clearer, but still design indexes properly first.
When to use Convex components
Convex components are best when a feature area wants a strong internal boundary.
Good candidates:
- rate limiting,
- notifications,
- file handling,
- billing,
- analytics,
- reusable auth or organisation infrastructure.
Use a component when the feature has:
- its own tables,
- its own functions,
- a reusable or swappable boundary,
- or enough complexity that it muddies the rest of the app.
Do not reach for components too early
For a small Expo app, plain convex/*.ts modules are simpler. Introduce components when they actually reduce complexity.
Practical decision rule
Stay with plain modules when
- the feature is local to one app,
- it has only a few functions,
- and the boundary is still evolving quickly.
Move toward components when
- the feature is reused across domains,
- multiple modules depend on it,
- you want clearer ownership and isolation,
- or a third-party official component already solves the problem well.
Example: file handling boundary
A growing file feature often wants:
- upload metadata tables,
- processing actions,
- thumbnail logic,
- permissions,
- and serving helpers.
That is a good place to consider a component or at least a dedicated domain subtree.
Review checklist
- [ ] Repeated auth logic has been centralised if it appears often.
- [ ] Wrappers still keep authorization decisions explicit and understandable.
- [ ] Relationships are backed by indexes before helper abstractions are added.
- [ ] Components are introduced to reduce complexity, not for novelty.
- [ ] The backend remains easy to navigate for someone new to the codebase.
Expo environment handling for Convex
Core rule
The Expo client should read the Convex deployment URL from:
process.env.EXPO_PUBLIC_CONVEX_URLAnything else causes avoidable breakage.
What npx convex dev is expected to do
Running:
npx convex devshould:
- create or connect a Convex project,
- create the
convex/backend directory if it does not exist, - keep a sync process running,
- and write the current deployment URL into
.env.localasEXPO_PUBLIC_CONVEX_URL=....
Treat .env.local as the local-development source of truth.
Recommended deployment URL policy
Use separate Convex deployments for each environment whenever the product has meaningful preview and production stages.
- development: the local or shared dev deployment used by
npx convex dev - preview: a preview deployment used by EAS preview builds
- production: the production Convex deployment used by store releases
Do not casually point preview or production builds at a development deployment.
Local development checklist
1. Run npx convex dev. 2. Confirm .env.local exists. 3. Confirm it contains:
EXPO_PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud4. Restart Metro if the value was added or changed after the dev server started. 5. If using multiple local .env* files, make sure they do not override or shadow the value unintentionally.
EAS builds
EAS cloud builds do not read your local .env.local file. Copy the value into EAS environment variables.
Example:
eas env:create --name EXPO_PUBLIC_CONVEX_URL --value https://YOUR_DEPLOYMENT_URL.convex.cloud --visibility plaintext --environment development --environment preview --environment productionReplace the value with the deployment URL from the correct Convex environment.
Safer operational pattern
When you create or rotate a deployment URL:
1. update the local .env.local, 2. update the EAS environment value for the matching build environments, 3. rebuild any preview or production apps that should pick up the new value, 4. verify the app points to the intended Convex deployment.
Provider snippet
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { Stack } from "expo-router";
const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!, {
unsavedChangesWarning: false,
});
export default function RootLayout() {
return (
<ConvexProvider client={convex}>
<Stack />
</ConvexProvider>
);
}Common failure modes
process.env.EXPO_PUBLIC_CONVEX_URL is undefined
Likely causes:
npx convex devhas never been run in this project..env.localwas not created where Expo expects it.- Metro was not restarted after the env file changed.
- The code uses
CONVEX_URLor another variable name without theEXPO_PUBLIC_prefix.
Local development works but EAS build fails
Likely causes:
- the value exists only in
.env.local, - preview or production EAS environments were not updated,
- or the wrong deployment URL was copied into EAS.
Preview build is hitting production data
Likely causes:
- preview and production share the same
EXPO_PUBLIC_CONVEX_URL, - or the build profile uses the wrong EAS environment.
Validation tips
- Run
python scripts/validate_project.py. - Grep the repo for
EXPO_PUBLIC_CONVEX_URL. - Keep a short note in the repository or internal docs describing which Convex deployment maps to each build profile.
Evaluating this skill
This skill bundles two evaluation aids:
evals/trigger-queries.jsonevals/evals.json
Use them to test both when the skill triggers and whether it produces good outputs once loaded.
1. Trigger evaluation
evals/trigger-queries.json contains realistic prompts labelled with should_trigger: true or false.
How to use it
- Run each query several times rather than only once.
- Record whether the skill loads.
- Calculate a trigger rate for each query.
- Tighten the description if true cases fail to trigger.
- Add scope or negative guidance if false cases trigger too often.
What good trigger tests look like
A useful set includes:
- obvious should-trigger requests,
- paraphrases and typo-heavy variants,
- and near-miss should-not-trigger requests such as generic Expo UI work or non-Expo Convex tasks.
2. Functional evaluation
evals/evals.json contains higher-level tasks with an expected outcome description.
Each case is designed to answer:
- does the skill pick the right workflow,
- does it produce the right edits or guidance,
- does it avoid common Convex mistakes,
- and does it improve results compared with no skill?
Recommended evaluation loop
With the skill
Run each eval with this skill available.
Without the skill
Run the same eval in a clean session without the skill, or against the previous skill version.
Compare
Look at:
- correctness,
- completeness,
- tool choice,
- number of avoidable follow-up questions,
- and whether the output follows Convex best practices.
What to grade in this skill specifically
- correct Expo entrypoint detection,
- correct use of
EXPO_PUBLIC_CONVEX_URL, - correct root provider wiring,
- generated API usage,
- validators on public Convex functions,
- auth enforced on the backend,
- correct action vs mutation boundaries,
- pagination for growing lists,
- and migration safety when schema changes are involved.
Suggested lightweight rubric
For each functional eval, score:
- Pass: the output is correct and production-safe.
- Borderline: the main idea is right but it misses one important hardening step.
- Fail: it uses the wrong workflow or introduces unsafe patterns.
Iteration advice
If the skill under-triggers:
- add clearer user-intent phrases to the frontmatter description,
- include more Expo-specific language,
- and add near-obvious trigger phrases such as
expo-router,EXPO_PUBLIC_CONVEX_URL,useQuery undefined,Clerk, orfile://uploads.
If it over-triggers:
- narrow the description,
- add “do not use” scope language,
- and add more near-miss negatives to the trigger file.
If it loads correctly but outputs weak advice:
- strengthen the body instructions,
- move specifics into references,
- and expand the functional eval set around the weak scenario.
File uploads from Expo to Convex storage
Expo gives you local file:// URIs from packages such as:
expo-image-pickerexpo-document-pickerexpo-av- custom camera or recorder flows
The most reliable Convex pattern is:
1. request an upload URL from a mutation, 2. load the local URI with fetch(uri), 3. convert it to a Blob, 4. POST the blob to the upload URL, 5. store the returned storageId plus your own metadata in a second mutation.
Why use upload URLs?
Benefits:
- avoids squeezing file bytes through normal function arguments,
- works well for mobile file sources,
- separates transport from your domain metadata,
- and lets you attach later processing steps cleanly.
Schema example
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
files: defineTable({
ownerId: v.id("users"),
storageId: v.id("_storage"),
originalName: v.string(),
mimeType: v.string(),
sizeBytes: v.optional(v.number()),
kind: v.union(
v.literal("image"),
v.literal("document"),
v.literal("audio"),
v.literal("other"),
),
createdAt: v.number(),
})
.index("by_owner", ["ownerId"])
.index("by_storage", ["storageId"]),
});Backend functions
convex/files.ts
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
export const generateUploadUrl = mutation({
args: {},
returns: v.string(),
handler: async (ctx) => {
// Add auth checks here if uploads are protected.
return await ctx.storage.generateUploadUrl();
},
});
export const saveUploadedFile = mutation({
args: {
ownerId: v.id("users"),
storageId: v.id("_storage"),
originalName: v.string(),
mimeType: v.string(),
sizeBytes: v.optional(v.number()),
kind: v.union(
v.literal("image"),
v.literal("document"),
v.literal("audio"),
v.literal("other"),
),
},
returns: v.id("files"),
handler: async (ctx, args) => {
return await ctx.db.insert("files", {
ownerId: args.ownerId,
storageId: args.storageId,
originalName: args.originalName,
mimeType: args.mimeType,
sizeBytes: args.sizeBytes,
kind: args.kind,
createdAt: Date.now(),
});
},
});
export const getFileMetadata = query({
args: {
fileId: v.id("files"),
},
returns: v.union(
v.object({
_id: v.id("files"),
_creationTime: v.number(),
ownerId: v.id("users"),
storageId: v.id("_storage"),
originalName: v.string(),
mimeType: v.string(),
sizeBytes: v.optional(v.number()),
kind: v.union(
v.literal("image"),
v.literal("document"),
v.literal("audio"),
v.literal("other"),
),
createdAt: v.number(),
}),
v.null(),
),
handler: async (ctx, args) => {
return await ctx.db.get(args.fileId);
},
});If uploads are protected
Do not accept ownerId blindly from the client in a real app. Resolve the current user from auth and write that verified owner ID instead.
Expo client example
import { api } from "../convex/_generated/api";
import { useMutation } from "convex/react";
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
const saveUploadedFile = useMutation(api.files.saveUploadedFile);
export async function uploadFromUri(params: {
uri: string;
ownerId: string;
originalName: string;
mimeType: string;
sizeBytes?: number;
kind: "image" | "document" | "audio" | "other";
}) {
const postUrl = await generateUploadUrl();
const fileResponse = await fetch(params.uri);
if (!fileResponse.ok) {
throw new Error("Failed to read the local file URI");
}
const blob = await fileResponse.blob();
const uploadResponse = await fetch(postUrl, {
method: "POST",
headers: {
"Content-Type": params.mimeType,
},
body: blob,
});
if (!uploadResponse.ok) {
throw new Error("Failed to upload file bytes to Convex");
}
const { storageId } = await uploadResponse.json();
return await saveUploadedFile({
ownerId: params.ownerId,
storageId,
originalName: params.originalName,
mimeType: params.mimeType,
sizeBytes: params.sizeBytes,
kind: params.kind,
});
}Optional post-processing
Common follow-up jobs:
- image resizing,
- audio transcription,
- virus scanning,
- metadata extraction,
- PDF text extraction,
- thumbnail creation.
Good pattern:
1. the metadata mutation inserts the file record, 2. it schedules or triggers an internal action, 3. the action calls the external service or heavy processor, 4. an internal mutation stores the result.
Serving files back to the app
Typical options:
- store the
storageIdand request a serving URL when needed, - or return URLs from a query alongside your domain metadata.
Keep privacy in mind. If the file is protected, do not hand out URLs in a query that lacks authorization checks.
Gotchas
Wrong MIME type
Mobile uploads often fail quietly or behave strangely when the Content-Type header is missing or incorrect.
Upload succeeds but metadata is missing
This usually means the second mutation failed or was never awaited.
Metadata saved but post-processing never ran
Check:
- scheduler calls are awaited,
- the scheduled function is internal and callable,
- and the action file is in the correct runtime if it uses Node-only packages.
Huge feeds of uploaded assets
Do not .collect() every file document. Paginate user libraries and media lists.
Review checklist
- [ ] Upload URL comes from a mutation.
- [ ] Local URI is converted to a
Blobbefore upload. - [ ] The upload
fetchcall sets the correctContent-Type. - [ ] The returned
storageIdis persisted with domain metadata. - [ ] Protected uploads derive ownership from auth on the backend.
- [ ] Follow-up processing happens in actions or internal functions.
- [ ] Large media libraries use pagination.
Frontend patterns for Expo + Convex
Use this reference when wiring the Convex client into an Expo app and when connecting screens to backend functions.
Root provider placement
Expo Router
Mount the provider in app/_layout.tsx or src/app/_layout.tsx.
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { Stack } from "expo-router";
const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!, {
unsavedChangesWarning: false,
});
export default function RootLayout() {
return (
<ConvexProvider client={convex}>
<Stack />
</ConvexProvider>
);
}Classic App.tsx
Wrap the navigation container or root screen tree in the same provider.
One client only
Create the client once at module scope.
Bad:
export default function App() {
const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!);
return <ConvexProvider client={convex}>{/* ... */}</ConvexProvider>;
}Why it is bad:
- recreates the client on render,
- breaks subscriptions,
- makes debugging confusing.
useQuery lifecycle
useQuery(...) behaves like this:
undefinedduring the initial load,- a real value once the query returns,
- reactive updates afterwards.
Always handle the loading phase explicitly.
const project = useQuery(api.projects.byId, { projectId });
if (project === undefined) {
return <ActivityIndicator />;
}useMutation
Mutations return a callable function.
const createTask = useMutation(api.tasks.create);
await createTask({
projectId,
title: "Draft landing page",
});Tips:
- trim user input before sending,
- disable repeated taps while a mutation is in flight when needed,
- rely on reactivity instead of manually refetching whenever possible.
useAction
Use when the backend function is an action.
Typical cases:
- AI or summarisation,
- export or import jobs,
- external service calls,
- Stripe, email, or webhook tooling.
const runSummary = useAction(api.aiActions.generateAndStoreSummary);
await runSummary({ noteId });Paginated lists
For feeds, inboxes, chat history, or large tables, pair a paginated query with usePaginatedQuery.
import { usePaginatedQuery } from "convex/react";
const { results, status, loadMore } = usePaginatedQuery(
api.notifications.listMine,
{},
{ initialNumItems: 20 },
);Expo / React Native list pattern
import { ActivityIndicator, FlatList, Pressable, Text, View } from "react-native";
export function NotificationsScreen() {
const { results, status, loadMore } = usePaginatedQuery(
api.notifications.listMine,
{},
{ initialNumItems: 20 },
);
const isInitialLoading = results.length === 0 && status === "LoadingFirstPage";
if (isInitialLoading) {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<ActivityIndicator />
</View>
);
}
return (
<FlatList
data={results}
keyExtractor={(item) => item._id}
renderItem={({ item }) => <Text>{item.title}</Text>}
onEndReached={() => {
if (status === "CanLoadMore") {
loadMore(20);
}
}}
onEndReachedThreshold={0.6}
ListFooterComponent={
status === "LoadingMore" ? <ActivityIndicator /> : null
}
/>
);
}Auth-aware rendering
If the auth integration provides Convex auth helpers, use them for UI gating:
AuthenticatedUnauthenticatedAuthLoading
These improve UX, but they are not security controls. The backend still decides access.
Error handling in screens
Prefer three explicit states:
1. loading, 2. empty, 3. loaded with data.
Then add an error strategy appropriate for the app:
- inline message for expected issues,
- toast or banner for mutation failures,
- error boundary for render-time query failures.
A useful mental model is that Convex gives you the data transport and reactivity, but you still own product-grade UX.
Path alias and import pitfalls
Common generated API imports:
../convex/_generated/api../../convex/_generated/api@/convex/_generated/apiif the project already defines that alias
Do not assume @/ exists. Verify the project config first.
Screen checklist
Before finishing a screen that talks to Convex:
- [ ]
useQueryhandlesundefined. - [ ] Mutations are awaited.
- [ ] The screen imports from generated API references.
- [ ] Empty states exist for zero-data cases.
- [ ] The list is paginated if it can grow without bound.
- [ ] Protected data is backed by server-side authorization.
- [ ] No extra Convex client is created inside the component tree.
Convex function patterns for Expo projects
Use this reference when deciding how to implement backend logic and where that logic should live.
Choose the right function type
query
Use for deterministic reads.
Good for:
- loading lists,
- loading detail views,
- computing derived read-only values from Convex data.
Rules:
- no writes,
- no external network access,
- no
Date.now()-driven behaviour inside the query, - and keep it fast.
mutation
Use for writes.
Good for:
- create, update, delete,
- toggles,
- transactional state changes,
- bookkeeping fields such as
createdAtandupdatedAt.
Rules:
- do not call third-party APIs from mutations,
- keep business rules server-side,
- and validate all client inputs.
action
Use when you need things outside the normal deterministic runtime.
Good for:
- calling external APIs,
- using Node-only packages,
- AI SDKs,
- Stripe,
- sending email,
- filesystem or crypto APIs that require the Node runtime,
- post-processing uploads.
Rules:
- if the file starts with
"use node", keep that file action-only, - call writes via
ctx.runMutation(...), - call reads via
ctx.runQuery(...), - and avoid mixing runtime modes in the same file.
Internal functions
Use internalQuery, internalMutation, and internalAction when logic should never be callable from a client.
Good for:
- backfills,
- privileged state transitions,
- action helpers,
- scheduled jobs,
- expensive maintenance logic,
- and refactoring public wrappers into smaller internal operations.
Minimum standard for public functions
Every public query, mutation, and action should have:
args,- clear backend authorization if the data is not public,
- and preferably
returns.
For mutations that do not return useful data, explicitly return null.
Starter template
import { mutation, query, internalMutation } from "./_generated/server";
import { v } from "convex/values";
const taskDoc = v.object({
_id: v.id("tasks"),
_creationTime: v.number(),
userId: v.id("users"),
title: v.string(),
status: v.union(
v.literal("todo"),
v.literal("doing"),
v.literal("done"),
),
createdAt: v.number(),
});
export const listMine = query({
args: {
userId: v.id("users"),
},
returns: v.array(taskDoc),
handler: async (ctx, args) => {
return await ctx.db
.query("tasks")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.order("desc")
.take(50);
},
});
export const create = mutation({
args: {
userId: v.id("users"),
title: v.string(),
},
returns: v.id("tasks"),
handler: async (ctx, args) => {
const title = args.title.trim();
if (!title) throw new Error("Task title cannot be empty");
return await ctx.db.insert("tasks", {
userId: args.userId,
title,
status: "todo",
createdAt: Date.now(),
});
},
});
export const markDone = internalMutation({
args: {
taskId: v.id("tasks"),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.taskId, { status: "done" });
return null;
},
});Thin wrappers, shared helpers
Keep wrappers small. Put repeated logic in plain TypeScript helpers.
Good structure
convex/tasks.tsfor public wrappersconvex/lib/auth.tsfor identity lookup helpersconvex/lib/tasks.tsfor business rules or validation helpersconvex/tasksActions.tsfor external API or Node-runtime work
Why this matters
- easier to test,
- less duplicated auth and ownership logic,
- easier to reuse from actions or background jobs,
- safer refactors.
Auth pattern
If data is user-scoped, do not trust a client-supplied userId unless the function verifies it.
Safer patterns:
- read the current identity with
ctx.auth.getUserIdentity(), - resolve the corresponding user document,
- and then scope queries or writes with the verified user.
When many functions need the same protection, create custom wrappers with convex-helpers. See references/auth.md and references/components-and-helpers.md.
Expected failures vs exceptional failures
For expected product-level failures:
- return
nullor a discriminated result shape when that fits the API, - or throw a
ConvexErrorwith a structured payload when the caller should handle it as an expected failure.
Examples:
- unauthenticated access,
- trying to edit a resource the user does not own,
- validation that depends on current database state,
- or “not found” for a user-selected record.
Use plain Error for unexpected failures when structure is not needed.
Avoid these anti-patterns
1. Unvalidated public functions
Bad:
export const create = mutation({
handler: async (ctx, args) => {
return await ctx.db.insert("tasks", args);
},
});Why it is bad:
- no input validation,
- no output contract,
- easy to accidentally expose fields you did not intend.
2. Query logic that depends on wall-clock time
Bad:
export const listReleased = query({
args: {},
handler: async (ctx) => {
return await ctx.db
.query("posts")
.withIndex("by_released_at", (q) => q.lte("releasedAt", Date.now()))
.take(50);
},
});Safer options:
- pass
nowfrom the client as an argument when needed, - or precompute a field such as
isReleasedwith scheduled updates.
3. Full-table scans with .filter()
Bad:
const project = await ctx.db
.query("projects")
.filter((q) => q.eq(q.field("slug"), args.slug))
.unique();Better:
- add
.index("by_slug", ["slug"]), - use
.withIndex("by_slug", ...).
4. Unbounded .collect() on feeds
Bad:
return await ctx.db.query("notifications").collect();Better:
- use
.take(50)for small bounded lists, - or paginated queries for inboxes, feeds, and chat.
5. Mixing "use node" with queries or mutations
Bad:
"use node";
import { mutation } from "./_generated/server";
export const createTask = mutation({
// invalid in a Node-runtime-only file
});Correct pattern:
- keep regular
queryandmutationfunctions in normal runtime files, - create a sibling
somethingActions.tsfile for"use node"actions.
Action pattern for external services
convex/aiActions.ts
"use node";
import { action } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
export const generateAndStoreSummary = action({
args: {
noteId: v.id("notes"),
},
returns: v.null(),
handler: async (ctx, args) => {
const note = await ctx.runQuery(internal.notes.getForSummariser, {
noteId: args.noteId,
});
if (!note) {
throw new Error("Note not found");
}
const summary = `Summary for ${note.title}`;
await ctx.runMutation(internal.notes.storeSummary, {
noteId: args.noteId,
summary,
});
return null;
},
});Pagination backend pattern
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);
},
});Review checklist
- [ ] Correct function type chosen.
- [ ] Public functions validate
args. - [ ] Public functions expose deliberate
returns. - [ ] Protected data is authorised on the backend.
- [ ] Shared logic lives in helpers or wrappers.
- [ ] External APIs and Node-only SDKs live in actions.
- [ ] No unbounded
.collect()on growing data. - [ ] No
Date.now()inside query logic. - [ ] All promises are awaited.
- [ ] Internal functions are used to reduce public surface area where appropriate.
Migration patterns for Convex in Expo apps
Use this reference when changing schemas or gradually moving from another backend without breaking live clients.
Migration priorities
1. keep the app working during the transition, 2. make each migration step restart-safe, 3. avoid one-shot breaking changes unless the app is still throwaway, 4. reduce public blast radius by using internal functions for migration logic.
Preferred migration strategy: additive first
Safe changes
These are usually straightforward:
- add a new optional field,
- add a new table,
- add a new index,
- add a new internal function,
- add a new public read path while old ones still work.
Risky changes
These usually need a staged rollout:
- making an optional field required,
- changing field types,
- renaming a field,
- splitting one table into multiple related tables,
- merging multiple shapes into one,
- removing fields that old clients still read.
Pattern 1: optional field -> backfill -> required field
Step 1
Add the field as optional.
users: defineTable({
name: v.string(),
bio: v.optional(v.string()),
})Step 2
Backfill missing rows with an internal mutation or internal action.
import { internalMutation } from "./_generated/server";
import { v } from "convex/values";
export const backfillUserBio = internalMutation({
args: {
batchSize: v.optional(v.number()),
},
returns: v.object({
processed: v.number(),
}),
handler: async (ctx, args) => {
const batchSize = args.batchSize ?? 100;
const users = await ctx.db.query("users").take(batchSize);
let processed = 0;
for (const user of users) {
if (user.bio !== undefined) continue;
await ctx.db.patch(user._id, { bio: "" });
processed += 1;
}
return { processed };
},
});Step 3
Run the backfill repeatedly until no rows remain.
Step 4
Make the field required only after all data is compliant and old clients are updated.
Pattern 2: rename a field
Suppose name becomes displayName.
Safer rollout
1. add displayName as optional, 2. backfill from name, 3. update readers to prefer displayName ?? name, 4. update writers to write both fields for a period, 5. remove reads of name, 6. finally remove name from the schema.
This dual-read and dual-write period is what keeps mobile clients from breaking during staggered releases.
Pattern 3: split nested or overloaded data into proper tables
Example:
- old
posts.tags: string[] - new
tagstable pluspostTagsjoin table
Safer rollout
1. add the new tables and indexes, 2. keep the old field temporarily, 3. backfill relationships in batches, 4. update readers to use the new tables, 5. update writers to populate only the new shape, 6. remove the old field once all readers are migrated.
Pattern 4: move from another backend to Convex gradually
Good strategy:
1. keep the old system for legacy features, 2. add new features in Convex first, 3. migrate one domain at a time, 4. dual-read where needed, 5. cut traffic over feature by feature.
This works well when moving from Firebase, Supabase, a REST backend, or a custom database.
Batch processing guidance
For large tables:
- process in batches,
- make the batch idempotent,
- log enough state to know what has already been migrated,
- avoid reading the entire table into memory,
- and prefer resumable jobs over one huge mutation.
Scheduled migration pattern
When a migration is expensive, schedule or repeatedly invoke a small internal unit of work instead of trying to finish everything in one request.
Good for:
- media metadata backfills,
- adding derived state fields,
- rebuilding search or denormalised views,
- moving historical records.
Validation rules during migrations
While migrating:
- keep validators accurate for each active shape,
- do not expose a public function that accepts both legacy and new shapes unless you truly need it,
- and make deprecation windows deliberate rather than accidental.
Mobile-client-specific note
Expo clients can lag behind the backend because users do not update instantly.
That means:
- avoid removing fields immediately after changing the app,
- keep old read paths alive long enough,
- and assume some users will run an older build against the new backend for a while.
Review checklist
- [ ] The change has been decomposed into additive steps.
- [ ] Backfills are idempotent and can be resumed.
- [ ] Public clients are not forced onto a breaking schema in one step.
- [ ] Mobile release lag has been accounted for.
- [ ] Old reads and writes are removed only after the rollout is complete.
- [ ] Internal functions are used for migration mechanics where appropriate.
Production hardening checklist for Expo + Convex
Use this before preview or production release.
Environment and deployment
- [ ]
EXPO_PUBLIC_CONVEX_URLexists locally and in the correct EAS environments. - [ ] Preview and production builds point at the intended Convex deployments.
- [ ] Team members know which deployment maps to each build profile.
- [ ]
npx convex devis not the only place the deployment URL is documented.
Provider and generated code
- [ ] Exactly one
ConvexReactClientexists. - [ ] The provider is mounted at the real app root.
- [ ]
convex/_generated/is current and committed or ignored according to the team’s workflow. - [ ] All frontend calls import from generated API references.
Backend safety
- [ ] Public functions define
args. - [ ] Public functions expose deliberate
returnswhere practical. - [ ] Protected resources enforce authorization on the backend.
- [ ] Internal-only workflows use internal functions.
- [ ] All promises are awaited.
- [ ] External APIs or Node-only packages live in actions, not mutations or queries.
Performance
- [ ] Large or growing lists use pagination.
- [ ] Heavy lookups use indexes rather than full scans.
- [ ] Queries do not rely on
Date.now()or other nondeterministic time checks. - [ ] Unbounded
.collect()calls have been replaced where needed. - [ ] Common ownership or tenant filters have matching indexes.
Developer experience and maintainability
- [ ] TypeScript strict mode is enabled or there is a documented reason it is not.
- [ ]
@convex-dev/eslint-pluginis installed and configured. - [ ] The project runs lint and typecheck successfully.
- [ ] Repeated auth or tenancy logic has been centralised.
- [ ] Runtime boundaries are obvious: normal files vs
"use node"action files.
Expo UX
- [ ] Loading, empty, and error states exist for Convex-backed screens.
- [ ] Mutation pending states are acceptable on slow mobile networks.
- [ ] Offline or reconnect behaviour has been tested on device where relevant.
- [ ] Screens do not assume
useQueryis immediately loaded.
Files and media
- [ ] Uploads use upload URLs instead of forcing bytes through standard mutations.
- [ ] Metadata writes are awaited after uploads.
- [ ] Protected media queries and serving URLs are authorized.
- [ ] Large asset libraries paginate.
Migration readiness
- [ ] Pending schema changes are additive or staged.
- [ ] Old clients will not break if they run against the new backend.
- [ ] Backfills are resumable and safe to re-run.
Suggested commands
python scripts/validate_project.py --root <project-root> --fail-on-warning
cd <project-root> && npm run lint
cd <project-root> && npm run typecheckAdjust the npm scripts to match the project.
Final gut check
If someone new cloned the app and followed the documented setup, would they end up with:
- a working provider,
- a working deployment URL,
- a generated API folder,
- secure backend access patterns,
- and no obvious scaling traps?
If not, tighten the docs or the code before release.
Schema and index design for Expo + Convex
Convex works schemaless, but an Expo app benefits from adding convex/schema.ts early once the data model stops changing every few minutes.
Design principles
1. Model documents as flat, relational records
Prefer:
tasks.userIdcomments.taskIdmemberships.organizationId
over deeply nested arrays of objects inside a single document.
2. Add indexes for real query shapes
Index the fields you actually query on:
- ownership:
userId - membership:
organizationId,teamId - status tabs:
userId + status - time or ordering:
userId + createdAt,channelId + createdAt - lookup keys:
tokenIdentifier,email,slug
3. Keep arrays bounded
Arrays are fine for small, naturally limited collections such as:
- user roles,
- a few tags,
- feature flags,
- recent device IDs.
Do not store large, growing sets such as comments, messages, or child records inside arrays.
4. Design around access rules
If every query is user-scoped, put userId directly on the table and index it.
If data is multi-tenant, include the org or team ID directly on every tenant-owned table rather than inferring tenancy indirectly.
Schema template
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
users: defineTable({
tokenIdentifier: v.string(),
email: v.string(),
name: v.string(),
imageUrl: v.optional(v.string()),
role: v.union(v.literal("user"), v.literal("admin")),
})
.index("by_token", ["tokenIdentifier"])
.index("by_email", ["email"]),
projects: defineTable({
ownerId: v.id("users"),
organizationId: v.optional(v.id("organizations")),
name: v.string(),
status: v.union(
v.literal("active"),
v.literal("archived"),
v.literal("draft"),
),
createdAt: v.number(),
})
.index("by_owner", ["ownerId"])
.index("by_org", ["organizationId"])
.index("by_owner_and_status", ["ownerId", "status"]),
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"),
),
createdAt: v.number(),
})
.index("by_project", ["projectId"])
.index("by_assignee", ["assigneeId"])
.index("by_project_and_status", ["projectId", "status"]),
});Query shape mapping
Before adding an index, write the query in plain English.
- “List my active projects” →
by_owner_and_status - “Find a user by token identifier” →
by_token - “Show tasks for a project in todo state” →
by_project_and_status - “Load messages in a channel newest first” →
by_channelplus.order("desc")
If you cannot describe the query shape clearly, the schema is usually not ready yet.
Prefer indexes over .filter()
For large or growing tables:
- use
.withIndex(...), - then use
.order(...),.take(...),.first(), or.paginate(...).
Use .filter() only when:
- the result set is already small and bounded, or
- the logic truly cannot be expressed via indexes.
If you do filter in TypeScript, start from a deliberately small result set rather than the whole table.
Pagination rule of thumb
Switch from take(...) to pagination when any of the following are true:
- the list is user-generated and unbounded,
- the screen is a feed, inbox, notification list, or message list,
- the product expects hundreds of rows,
- or the UI naturally wants infinite scroll or “load more”.
See references/frontend-patterns.md for the frontend hook and references/functions.md for the backend query pattern.
Time-based data
Safe pattern
Store timestamps on writes:
createdAtupdatedAtscheduledForreleasedAt
and query them deterministically.
Unsafe pattern
Do not call Date.now() inside a query to decide what should be returned right now.
Instead:
- pass time as an argument from the client, or
- maintain a coarser state field such as
isReleased,isExpired, ordueToday, updated by a scheduled function.
Example: messages with pagination
import { paginationOptsValidator } from "convex/server";
import { query } from "./_generated/server";
import { v } from "convex/values";
export const listByChannel = query({
args: {
channelId: v.id("channels"),
paginationOpts: paginationOptsValidator,
},
handler: async (ctx, args) => {
return await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.order("desc")
.paginate(args.paginationOpts);
},
});Common mistakes
Missing index on a foreign key
If a feature constantly looks up projectId, userId, or organizationId, index it from the start.
Nested objects growing without bound
If comments or checklist items can grow, they deserve their own table.
Too many redundant indexes
Do not create every possible index “just in case”. Add indexes around observed access patterns.
Migrating directly to a breaking schema
For live data, add optional fields first, backfill, and only then make them required. See references/migrations.md.
Review checklist
- [ ] Each protected resource stores the ownership or tenancy field it needs.
- [ ] Each common lookup has a matching index.
- [ ] Bounded arrays stay bounded.
- [ ] Large lists use pagination rather than unbounded collection.
- [ ] Query code does not rely on
Date.now()for live filtering. - [ ] The schema is easy to explain in terms of product features rather than implementation accidents.
Sources used to rewrite this skill
Checked or reviewed on 2026-03-12.
Agent Skills guidance
- Agent Skills specification
- Optimizing skill descriptions
- Evaluating skills
- Using scripts
- The complete skill-building guide supplied with this task
Convex and Expo guidance
- Expo guide for using Convex
- Convex React Native and deployment URL docs
- Convex validation, actions, internal functions, error handling, and pagination docs
- Convex best practices and ESLint docs
- Convex Clerk integration docs
- Convex Auth docs
- Convex file upload guidance
- Convex blog post on uploading files from React Native or Expo
Local plugin inputs folded into this rewrite
From the attached convex-agent-plugins repository, the rewrite incorporates guidance inspired by:
argument-validation.mdcasync-handling.mdcauthentication-checks.mdccustom-functions-for-auth.mdcfunction-organization.mdcno-date-now-in-queries.mdcquery-optimization.mdcschema-design.mdcuse-components-for-encapsulation.mdcuse-eslint-always.mdcuse-node-for-actions.mdcuse-pagination-for-large-datasets.mdc
and the bundled skills:
auth-setupcomponents-guideconvex-helpers-guideconvex-quickstartfunction-creatormigration-helperschema-builder
Intent of the rewrite
This skill deliberately tightens:
- frontmatter quality and trigger specificity,
- progressive disclosure through references,
- script interfaces for agentic use,
- Convex safety defaults,
- Expo-specific environment handling,
- and evaluation scaffolding.
Tasks example for Expo + Convex
Use this as the smallest end-to-end proof that the stack is wired correctly.
What this proves
A successful run confirms all of the following:
npx convex devis running,convex/_generated/exists,- the Expo app can reach the deployment URL,
- the root provider is mounted correctly,
- and frontend imports are pointing at generated API references.
1. Seed data
Create sampleData.jsonl in the project root:
{"text": "Buy groceries", "isCompleted": true}
{"text": "Go for a swim", "isCompleted": true}
{"text": "Integrate Convex", "isCompleted": false}Import it:
npx convex import --table tasks sampleData.jsonl2. Add a schema
convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
tasks: defineTable({
text: v.string(),
isCompleted: v.boolean(),
}),
});This table is small enough for a starter example. Add indexes later if you query by owner, status, or team.
3. Add functions
convex/tasks.ts
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
const taskDoc = v.object({
_id: v.id("tasks"),
_creationTime: v.number(),
text: v.string(),
isCompleted: v.boolean(),
});
export const list = query({
args: {},
returns: v.array(taskDoc),
handler: async (ctx) => {
return await ctx.db.query("tasks").order("desc").take(50);
},
});
export const add = mutation({
args: { text: v.string() },
returns: v.id("tasks"),
handler: async (ctx, args) => {
const text = args.text.trim();
if (!text) {
throw new Error("Task text cannot be empty");
}
return await ctx.db.insert("tasks", {
text,
isCompleted: false,
});
},
});
export const toggle = mutation({
args: {
taskId: v.id("tasks"),
isCompleted: v.boolean(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.taskId, {
isCompleted: args.isCompleted,
});
return null;
},
});Why take(50) instead of .collect()?
For a starter example, take(50) keeps the query bounded. If the list can grow without limit, switch to a paginated query and usePaginatedQuery.
4. Add a screen
Use a relative import that matches the screen location.
If the screen file is app/index.tsx
import { api } from "../convex/_generated/api";
import { useMutation, useQuery } from "convex/react";
import { useState } from "react";
import {
ActivityIndicator,
FlatList,
Pressable,
Text,
TextInput,
View,
} from "react-native";
export default function IndexScreen() {
const tasks = useQuery(api.tasks.list);
const addTask = useMutation(api.tasks.add);
const toggleTask = useMutation(api.tasks.toggle);
const [text, setText] = useState("");
if (tasks === undefined) {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<ActivityIndicator />
</View>
);
}
return (
<View style={{ flex: 1, padding: 24, gap: 16 }}>
<View style={{ gap: 8 }}>
<Text style={{ fontSize: 28, fontWeight: "700" }}>Tasks</Text>
<Text style={{ color: "#666" }}>
If this renders, the Expo and Convex wiring is working.
</Text>
</View>
<View style={{ flexDirection: "row", gap: 8 }}>
<TextInput
value={text}
onChangeText={setText}
placeholder="Add a task"
style={{
flex: 1,
borderWidth: 1,
borderColor: "#ddd",
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
}}
/>
<Pressable
onPress={async () => {
const trimmed = text.trim();
if (!trimmed) return;
await addTask({ text: trimmed });
setText("");
}}
style={{
justifyContent: "center",
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 10,
backgroundColor: "#111",
}}
>
<Text style={{ color: "white", fontWeight: "600" }}>Add</Text>
</Pressable>
</View>
<FlatList
data={tasks}
keyExtractor={(task) => task._id}
renderItem={({ item }) => (
<Pressable
onPress={() =>
toggleTask({
taskId: item._id,
isCompleted: !item.isCompleted,
})
}
style={{
borderWidth: 1,
borderColor: "#eee",
borderRadius: 12,
padding: 14,
marginBottom: 10,
}}
>
<Text
style={{
fontSize: 16,
textDecorationLine: item.isCompleted ? "line-through" : "none",
}}
>
{item.text}
</Text>
</Pressable>
)}
/>
</View>
);
}If the screen lives under src/app, change the generated API import accordingly.
5. Expected behaviour
tasksisundefinedon the first render.- It then resolves to an array.
- Pressing Add creates a task and the list updates reactively.
- Pressing a task toggles completion and the UI updates without manual refetching.
6. Quick debug checklist
If it fails:
1. confirm npx convex dev is running, 2. confirm convex/_generated/api exists, 3. confirm the provider is mounted at the app root, 4. confirm the screen imports from the generated API, 5. confirm the client sees EXPO_PUBLIC_CONVEX_URL, 6. run python scripts/validate_project.py --root <project-root>.
Optional helper script
You can scaffold the files above with:
python scripts/scaffold_tasks_example.py --root <project-root>
python scripts/scaffold_tasks_example.py --root <project-root> --writeAdd --ui-file app/index.tsx or another target path if you also want a screen scaffolded.
Troubleshooting Expo + Convex
Use this file when the integration mostly exists but something is clearly off.
useQuery(...) stays undefined
Remember: undefined is normal during the initial load.
If it never resolves, check these in order:
1. npx convex dev is running. 2. convex/_generated/api exists. 3. the provider is mounted at the app root. 4. the app can read process.env.EXPO_PUBLIC_CONVEX_URL. 5. the query reference comes from generated api. 6. the backend function is compiling successfully.
EXPO_PUBLIC_CONVEX_URL is undefined
Most common causes:
.env.localwas never created becausenpx convex devhas not run,- Metro was not restarted after the env file changed,
- the code uses the wrong variable name,
- a different
.env*file overrides the expected value.
convex/_generated is missing or stale
Causes:
npx convex devis not running,- TypeScript errors in
convex/stopped generation, - or the working directory for
npx convex devwas wrong.
Fix:
1. run npx convex dev from the Expo project root, 2. fix any backend compile errors, 3. wait for generation to complete.
“Cannot find module '../convex/_generated/api'”
Usually one of these:
- the relative path is wrong for the screen’s location,
- the project uses
src/appbut the import assumesapp, - or
convex/_generatedhas not been generated yet.
The app crashes after adding Convex
Common causes:
- provider is mounted too low in the tree,
- multiple
ConvexReactClientinstances exist, - or a screen renders before the root provider is available.
Query is stale or behaves strangely around time
If a query uses Date.now() or new Date() internally, the reactivity model will not behave the way you expect.
Fix:
- pass time as an argument,
- or maintain a stored status field updated elsewhere.
Slow screens or expensive queries
Look for:
.filter()on large tables,- unbounded
.collect(), - missing indexes on foreign keys,
- or screens trying to load entire feeds at once.
Fix:
- add indexes,
- switch to
.withIndex(...), - use
take(...)or paginated queries, - and update the screen to
usePaginatedQuerywhere appropriate.
Node or third-party SDK errors in backend files
If a query or mutation tries to use:
fetchto third-party APIs,Buffer,crypto,- Stripe,
- OpenAI,
- filesystem access,
move that logic into an action. If the file needs Node APIs, start it with "use node" and keep it action-only.
Auth says signed in but backend is unauthenticated
Check all three layers:
1. client auth provider is mounted correctly, 2. Convex auth provider configuration matches the client provider, 3. backend functions actually read and enforce ctx.auth.getUserIdentity().
Do not trust only the UI provider’s “signed in” flag.
File uploads succeed but the app cannot find the file later
Check:
- the second metadata mutation was awaited,
- the correct
storageIdwas persisted, - the query returning file data is authorized,
- the UI is asking for the correct file record.
ESLint or validation warnings about public functions
Typical fixes:
- add
args: { ... }, - add
returns: ..., - move internal-only logic to internal functions,
- replace scans with indexes,
- or split
"use node"code into a separate action file.
Remote agent or CI environment cannot run npx convex dev
Only cloud agents or CI-like environments that cannot log in should consider:
CONVEX_AGENT_MODE=anonymous npx convex devDo not add this by default for local development. Local work should usually use your normal authenticated Convex setup.
Quick recovery sequence
When the project is messy and you need to stabilise it fast:
1. stop Metro, 2. start npx convex dev, 3. run python scripts/validate_project.py --root <project-root>, 4. fix env issues, 5. fix provider placement, 6. fix generated API imports, 7. restart Metro, 8. re-test with a small known-good query such as the tasks example.
Useful commands
python scripts/validate_project.py --root <project-root>
python scripts/validate_project.py --root <project-root> --json
python scripts/scaffold_tasks_example.py --root <project-root>\
#!/usr/bin/env python3
"""Scaffold a small Expo + Convex tasks example.
Default mode is dry-run. Use --write to create files.
Examples:
python scripts/scaffold_tasks_example.py
python scripts/scaffold_tasks_example.py --write
python scripts/scaffold_tasks_example.py --write --ui-file app/index.tsx
python scripts/scaffold_tasks_example.py --json
Exit codes:
0 = success
2 = project root not found
3 = internal error
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
from textwrap import dedent
SAMPLE_DATA = dedent("""\
{"text": "Buy groceries", "isCompleted": true}
{"text": "Go for a swim", "isCompleted": true}
{"text": "Integrate Convex", "isCompleted": false}
""")
SCHEMA_TS = dedent("""\
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
tasks: defineTable({
text: v.string(),
isCompleted: v.boolean(),
}),
});
""")
TASKS_TS = dedent("""\
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
const taskDoc = v.object({
_id: v.id("tasks"),
_creationTime: v.number(),
text: v.string(),
isCompleted: v.boolean(),
});
export const list = query({
args: {},
returns: v.array(taskDoc),
handler: async (ctx) => {
return await ctx.db.query("tasks").order("desc").take(50);
},
});
export const add = mutation({
args: { text: v.string() },
returns: v.id("tasks"),
handler: async (ctx, args) => {
const text = args.text.trim();
if (!text) {
throw new Error("Task text cannot be empty");
}
return await ctx.db.insert("tasks", {
text,
isCompleted: false,
});
},
});
export const toggle = mutation({
args: {
taskId: v.id("tasks"),
isCompleted: v.boolean(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.taskId, {
isCompleted: args.isCompleted,
});
return null;
},
});
""")
@dataclass
class PlannedFile:
path: str
action: str # create | overwrite | skip
reason: str | None = None
def find_project_root(start: Path) -> Path | None:
current = start.resolve()
for _ in range(12):
if (current / "package.json").exists():
return current
if current.parent == current:
return None
current = current.parent
return None
def ensure_relative_import(from_file: Path, target_without_ext: Path) -> str:
rel = os.path.relpath(target_without_ext, start=from_file.parent)
rel = rel.replace(os.sep, "/")
if not rel.startswith("."):
rel = f"./{rel}"
return rel
def build_ui_tsx(ui_file: Path, root: Path) -> str:
api_target = root / "convex" / "_generated" / "api"
api_import = ensure_relative_import(ui_file, api_target)
return dedent(f"""\
import {{ api }} from "{api_import}";
import {{ useMutation, useQuery }} from "convex/react";
import {{ useState }} from "react";
import {{
ActivityIndicator,
FlatList,
Pressable,
Text,
TextInput,
View,
}} from "react-native";
export default function TasksScreen() {{
const tasks = useQuery(api.tasks.list);
const addTask = useMutation(api.tasks.add);
const toggleTask = useMutation(api.tasks.toggle);
const [text, setText] = useState("");
if (tasks === undefined) {{
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<ActivityIndicator />
</View>
);
}}
return (
<View style={{ flex: 1, padding: 24, gap: 16 }}>
<View style={{ gap: 8 }}>
<Text style={{ fontSize: 28, fontWeight: "700" }}>Tasks</Text>
<Text style={{ color: "#666" }}>
If this renders, the Expo and Convex wiring is working.
</Text>
</View>
<View style={{ flexDirection: "row", gap: 8 }}>
<TextInput
value={{text}}
onChangeText={{setText}}
placeholder="Add a task"
style={{
flex: 1,
borderWidth: 1,
borderColor: "#ddd",
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
}}
/>
<Pressable
onPress={{async () => {{
const trimmed = text.trim();
if (!trimmed) return;
await addTask({{ text: trimmed }});
setText("");
}}}}
style={{
justifyContent: "center",
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 10,
backgroundColor: "#111",
}}
>
<Text style={{ color: "white", fontWeight: "600" }}>Add</Text>
</Pressable>
</View>
<FlatList
data={{tasks}}
keyExtractor={{(task) => task._id}}
renderItem={{({{ item }}) => (
<Pressable
onPress={{() =>
toggleTask({{
taskId: item._id,
isCompleted: !item.isCompleted,
}})
}}
style={{
borderWidth: 1,
borderColor: "#eee",
borderRadius: 12,
padding: 14,
marginBottom: 10,
}}
>
<Text
style={{
fontSize: 16,
textDecorationLine: item.isCompleted ? "line-through" : "none",
}}
>
{{item.text}}
</Text>
</Pressable>
)}}
/>
</View>
);
}}
""")
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Scaffold a minimal Expo + Convex tasks example. Dry-run by default."
)
parser.add_argument(
"--root",
type=Path,
help="Project root. Defaults to the nearest parent containing package.json.",
)
parser.add_argument(
"--write",
action="store_true",
help="Actually write files instead of printing the plan.",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="Overwrite existing files. Without this flag, existing files are left untouched.",
)
parser.add_argument(
"--ui-file",
type=Path,
help="Optional screen file to scaffold, e.g. app/index.tsx or src/app/index.tsx.",
)
parser.add_argument(
"--json",
action="store_true",
help="Emit a JSON plan/result instead of human-readable text.",
)
return parser.parse_args(argv)
def decide_action(path: Path, overwrite: bool) -> str:
if not path.exists():
return "create"
if overwrite:
return "overwrite"
return "skip"
def plan_files(root: Path, overwrite: bool, ui_file: Path | None) -> tuple[list[PlannedFile], dict[Path, str]]:
file_map: dict[Path, str] = {
root / "sampleData.jsonl": SAMPLE_DATA,
root / "convex" / "schema.ts": SCHEMA_TS,
root / "convex" / "tasks.ts": TASKS_TS,
}
if ui_file is not None:
file_map[ui_file] = build_ui_tsx(ui_file, root)
plan: list[PlannedFile] = []
for path in sorted(file_map.keys()):
action = decide_action(path, overwrite)
reason = None
if action == "skip":
reason = "File already exists; rerun with --overwrite to replace it."
plan.append(PlannedFile(path=str(path), action=action, reason=reason))
return plan, file_map
def write_files(plan: list[PlannedFile], file_map: dict[Path, str]) -> list[str]:
written: list[str] = []
for item in plan:
path = Path(item.path)
if item.action == "skip":
continue
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(file_map[path], encoding="utf-8")
written.append(str(path))
return written
def render_human(root: Path, plan: list[PlannedFile], write: bool) -> str:
lines: list[str] = []
lines.append(f"Project root: {root}")
lines.append("")
lines.append("Plan:")
for item in plan:
rel = Path(item.path).relative_to(root)
label = item.action.upper()
lines.append(f"- [{label}] {rel}")
if item.reason:
lines.append(f" {item.reason}")
lines.append("")
if write:
wrote_any = any(item.action in {"create", "overwrite"} for item in plan)
if wrote_any:
lines.append("Files were written.")
else:
lines.append("Nothing was written because every target file already exists.")
else:
lines.append("Dry-run only. Re-run with --write to create files.")
lines.append("")
lines.append("Next steps:")
lines.append("1. Run `npx convex dev` if it is not already running.")
lines.append("2. Run `npx convex import --table tasks sampleData.jsonl`.")
lines.append("3. Make sure your root provider is mounted with `ConvexProvider` and `ConvexReactClient`.")
lines.append("4. Run `python scripts/validate_project.py`.")
return "\n".join(lines) + "\n"
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv or sys.argv[1:])
try:
if args.root:
root = args.root.resolve()
if not (root / "package.json").exists():
if args.json:
print(json.dumps({
"ok": False,
"fatal": "Provided --root does not contain package.json.",
"root": str(root),
}, indent=2))
else:
print("Provided --root does not contain package.json.", file=sys.stderr)
return 2
else:
found = find_project_root(Path.cwd())
if found is None:
if args.json:
print(json.dumps({
"ok": False,
"fatal": "Could not find package.json by walking up from the current directory.",
"cwd": str(Path.cwd()),
}, indent=2))
else:
print("Could not find package.json by walking up from the current directory.", file=sys.stderr)
return 2
root = found
ui_file = None
if args.ui_file is not None:
ui_file = args.ui_file if args.ui_file.is_absolute() else (root / args.ui_file)
ui_file = ui_file.resolve()
plan, file_map = plan_files(root, args.overwrite, ui_file)
written: list[str] = []
if args.write:
written = write_files(plan, file_map)
payload = {
"ok": True,
"root": str(root),
"write": args.write,
"overwrite": args.overwrite,
"planned_files": [asdict(item) for item in plan],
"written_files": written,
"next_steps": [
"Run `npx convex dev` if it is not already running.",
"Run `npx convex import --table tasks sampleData.jsonl`.",
"Ensure the root provider uses ConvexProvider + ConvexReactClient.",
"Run `python scripts/validate_project.py`.",
],
}
if args.json:
print(json.dumps(payload, indent=2))
else:
print(render_human(root, plan, args.write), end="")
return 0
except KeyboardInterrupt:
if args.json:
print(json.dumps({"ok": False, "fatal": "Interrupted."}, indent=2))
else:
print("Interrupted.", file=sys.stderr)
return 3
except Exception as exc:
if args.json:
print(json.dumps({"ok": False, "fatal": f"Internal scaffold error: {exc}"}, indent=2))
else:
print(f"Internal scaffold error: {exc}", file=sys.stderr)
return 3
if __name__ == "__main__":
raise SystemExit(main())
\
#!/usr/bin/env python3
"""Validate an Expo + Convex project.
This script is designed for agentic use:
- non-interactive
- helpful text output by default
- optional JSON output for automation
- conservative heuristics with clear warnings when a check is approximate
Examples:
python scripts/validate_project.py
python scripts/validate_project.py --json
python scripts/validate_project.py --root /path/to/project
python scripts/validate_project.py --fail-on-warning
Exit codes:
0 = no errors (warnings allowed unless --fail-on-warning is set)
1 = validation issues found
2 = project root not found or not a Node/Expo project
3 = internal error while validating
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Iterable, Iterator
ENTRY_CANDIDATES = (
"app/_layout.tsx",
"app/_layout.jsx",
"app/_layout.js",
"src/app/_layout.tsx",
"src/app/_layout.jsx",
"src/app/_layout.js",
"App.tsx",
"App.jsx",
"App.js",
)
ENV_CANDIDATES = (
".env.local",
".env",
".env.development",
".env.production",
".env.development.local",
".env.production.local",
)
PROVIDER_MARKERS = (
"ConvexProvider",
"ConvexProviderWithClerk",
"ConvexProviderWithAuth",
)
BACKEND_EXTENSIONS = {".ts", ".tsx", ".js", ".jsx"}
IGNORED_DIRS = {
"node_modules",
".git",
".next",
".expo",
"dist",
"build",
"coverage",
"ios",
"android",
}
FUNC_START_RE = re.compile(
r"export\s+const\s+(?P<name>[A-Za-z0-9_]+)\s*=\s*"
r"(?P<kind>query|mutation|action|internalQuery|internalMutation|internalAction)\s*\(",
re.M,
)
OLD_SYNTAX_RE = re.compile(
r"export\s+const\s+(?P<name>[A-Za-z0-9_]+)\s*=\s*"
r"(?P<kind>query|mutation|action|internalQuery|internalMutation|internalAction)\s*\(\s*async\b",
re.M,
)
PACKAGE_MANAGER_HINT = "Install missing packages from the Expo project root and rerun the validator."
@dataclass
class Issue:
severity: str # "error" | "warning" | "info"
code: str
message: str
path: str | None = None
hint: str | None = None
def read_text(path: Path) -> str:
try:
return path.read_text(encoding="utf-8")
except UnicodeDecodeError:
return path.read_text(encoding="utf-8", errors="replace")
def iter_code_files(root: Path) -> Iterator[Path]:
for path in root.rglob("*"):
if not path.is_file():
continue
if path.suffix not in BACKEND_EXTENSIONS:
continue
if any(part in IGNORED_DIRS for part in path.parts):
continue
yield path
def find_project_root(start: Path) -> Path | None:
current = start.resolve()
for _ in range(12):
if (current / "package.json").exists():
return current
if current.parent == current:
return None
current = current.parent
return None
def load_package_json(root: Path) -> dict:
return json.loads(read_text(root / "package.json"))
def dep_exists(pkg: dict, name: str) -> bool:
for key in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
value = pkg.get(key)
if isinstance(value, dict) and name in value:
return True
return False
def relative_to_root(path: Path, root: Path) -> str:
try:
return str(path.relative_to(root))
except ValueError:
return str(path)
def env_files(root: Path) -> list[Path]:
return [root / candidate for candidate in ENV_CANDIDATES if (root / candidate).exists()]
def has_convex_url(root: Path) -> tuple[bool, list[Path]]:
files = env_files(root)
pattern = re.compile(r"^\s*EXPO_PUBLIC_CONVEX_URL\s*=\s*.+\S\s*$", re.M)
for file in files:
if pattern.search(read_text(file)):
return True, files
return False, files
def detect_entry_files(root: Path) -> list[Path]:
files: list[Path] = []
for candidate in ENTRY_CANDIDATES:
path = root / candidate
if path.exists():
files.append(path)
return files
def project_mentions_provider(root: Path, entry_files: list[Path]) -> tuple[bool, list[str]]:
checked: list[str] = []
for file in entry_files:
text = read_text(file)
checked.append(relative_to_root(file, root))
if any(marker in text for marker in PROVIDER_MARKERS):
return True, checked
return False, checked
def count_convex_clients(root: Path) -> tuple[int, list[str]]:
matches: list[str] = []
for file in iter_code_files(root):
if "ConvexReactClient" not in read_text(file):
continue
count = read_text(file).count("new ConvexReactClient(")
if count:
for _ in range(count):
matches.append(relative_to_root(file, root))
return len(matches), matches
def find_backend_files(root: Path) -> list[Path]:
convex_dir = root / "convex"
if not convex_dir.exists():
return []
files: list[Path] = []
for path in convex_dir.rglob("*"):
if not path.is_file():
continue
if path.suffix not in BACKEND_EXTENSIONS:
continue
if "_generated" in path.parts:
continue
files.append(path)
return files
def first_directive(text: str) -> str | None:
# Return the first non-empty, non-comment string directive if present.
in_block_comment = False
for raw_line in text.splitlines():
line = raw_line.strip()
if not line:
continue
if in_block_comment:
if "*/" in line:
in_block_comment = False
continue
if line.startswith("/*"):
in_block_comment = "*/" not in line
continue
if line.startswith("//"):
continue
if line in ('"use node";', "'use node';", '"use node"', "'use node'"):
return "use node"
return None
return None
def extract_object_literal(source: str, brace_index: int) -> str | None:
"""Extract a JS/TS object literal beginning at source[brace_index] == '{'."""
if brace_index >= len(source) or source[brace_index] != "{":
return None
i = brace_index
depth = 0
in_string: str | None = None
escape = False
in_line_comment = False
in_block_comment = False
while i < len(source):
ch = source[i]
nxt = source[i + 1] if i + 1 < len(source) else ""
if in_line_comment:
if ch == "\n":
in_line_comment = False
i += 1
continue
if in_block_comment:
if ch == "*" and nxt == "/":
in_block_comment = False
i += 2
continue
i += 1
continue
if in_string is not None:
if escape:
escape = False
i += 1
continue
if ch == "\\":
escape = True
i += 1
continue
if ch == in_string:
in_string = None
i += 1
continue
i += 1
continue
if ch == "/" and nxt == "/":
in_line_comment = True
i += 2
continue
if ch == "/" and nxt == "*":
in_block_comment = True
i += 2
continue
if ch in ("'", '"', "`"):
in_string = ch
i += 1
continue
if ch == "{":
depth += 1
i += 1
continue
if ch == "}":
depth -= 1
i += 1
if depth == 0:
return source[brace_index:i]
continue
i += 1
return None
def iter_registered_functions(text: str) -> Iterator[dict]:
for match in FUNC_START_RE.finditer(text):
name = match.group("name")
kind = match.group("kind")
open_paren_index = match.end() - 1 # points to '('
index = open_paren_index + 1
while index < len(text) and text[index].isspace():
index += 1
if index >= len(text):
continue
if text[index] != "{":
yield {
"name": name,
"kind": kind,
"object_text": None,
"start": match.start(),
"end": match.end(),
}
continue
object_text = extract_object_literal(text, index)
yield {
"name": name,
"kind": kind,
"object_text": object_text,
"start": match.start(),
"end": match.end(),
}
def header_before_handler(object_text: str) -> str:
handler_index = object_text.find("handler")
if handler_index == -1:
return object_text
return object_text[:handler_index]
def add_issue(
issues: list[Issue],
severity: str,
code: str,
message: str,
path: Path | None,
root: Path,
hint: str | None = None,
) -> None:
issues.append(
Issue(
severity=severity,
code=code,
message=message,
path=relative_to_root(path, root) if path else None,
hint=hint,
)
)
def scan_backend_file(path: Path, root: Path, issues: list[Issue]) -> None:
text = read_text(path)
directive = first_directive(text)
use_node = directive == "use node"
if OLD_SYNTAX_RE.search(text):
add_issue(
issues,
"warning",
"old-registered-syntax",
"Uses the older registered function syntax. Prefer object syntax with explicit args and returns.",
path,
root,
"Rewrite functions as query({ args, returns, handler }) or mutation({ args, returns, handler }).",
)
registered_any = False
for item in iter_registered_functions(text):
registered_any = True
name = item["name"]
kind = item["kind"]
object_text = item["object_text"]
if use_node and kind not in {"action", "internalAction"}:
add_issue(
issues,
"error",
"use-node-mixed-runtime",
f'`{name}` is a `{kind}` inside a `"use node"` file. Keep `"use node"` files action-only.',
path,
root,
"Move queries and mutations into a normal runtime file, and keep only actions/internalAction in the Node runtime file.",
)
if object_text is None:
# Probably old shorthand syntax; the old-syntax warning above will explain.
continue
header = header_before_handler(object_text)
if kind in {"query", "mutation", "action"} and "args:" not in header:
add_issue(
issues,
"error",
"missing-args-validator",
f"Public function `{name}` is missing an `args` validator.",
path,
root,
"Add `args: { ... }` even when the function takes no arguments (`args: {}`).",
)
if kind in {"query", "mutation", "action"} and "returns:" not in header:
add_issue(
issues,
"warning",
"missing-returns-validator",
f"Public function `{name}` does not declare a `returns` validator.",
path,
root,
"Prefer explicit `returns: ...`; for no value use `returns: v.null()` and `return null`.",
)
if kind in {"query", "internalQuery"}:
if "Date.now(" in object_text or "new Date(" in object_text:
add_issue(
issues,
"warning",
"query-uses-time",
f"Query `{name}` appears to depend on wall-clock time.",
path,
root,
"Pass time as an argument or store a derived status field instead of using Date.now() in queries.",
)
if ".filter(" in object_text:
add_issue(
issues,
"warning",
"query-uses-filter",
f"Query `{name}` uses `.filter()`. This is often a full scan.",
path,
root,
"Prefer `.withIndex(...)` or filter only after narrowing to a small bounded result set.",
)
if ".collect(" in object_text:
add_issue(
issues,
"warning",
"query-uses-collect",
f"Query `{name}` uses `.collect()`. This may be unsafe for growing result sets.",
path,
root,
"Use `.take(...)`, `.first()`, or paginated queries for unbounded lists.",
)
if kind in {"query", "mutation", "internalQuery", "internalMutation"} and "fetch(" in object_text:
add_issue(
issues,
"warning",
"fetch-outside-action",
f"`{name}` appears to call `fetch()` outside an action.",
path,
root,
"Move external API calls into an action or internalAction; keep queries and mutations deterministic.",
)
if use_node and not registered_any:
add_issue(
issues,
"info",
"use-node-no-registered-functions",
'This file uses `"use node"` but no registered functions were detected. That is fine if it only exports helpers.',
path,
root,
)
def scan_project(root: Path) -> dict:
issues: list[Issue] = []
package_json_path = root / "package.json"
if not package_json_path.exists():
return {"ok": False, "fatal": "Missing package.json", "issues": issues}
try:
pkg = load_package_json(root)
except json.JSONDecodeError as exc:
add_issue(
issues,
"error",
"package-json-invalid",
f"Could not parse package.json: {exc}",
package_json_path,
root,
)
return {"ok": False, "issues": issues}
if not dep_exists(pkg, "expo"):
add_issue(
issues,
"error",
"missing-expo-dependency",
"package.json does not include `expo`.",
package_json_path,
root,
PACKAGE_MANAGER_HINT,
)
if not dep_exists(pkg, "convex"):
add_issue(
issues,
"error",
"missing-convex-dependency",
"package.json does not include `convex`.",
package_json_path,
root,
"Run `npx expo install convex` from the project root.",
)
convex_dir = root / "convex"
if not convex_dir.exists():
add_issue(
issues,
"error",
"missing-convex-directory",
"Missing `convex/` directory.",
None,
root,
"Run `npx convex dev` from the project root to create the backend directory and generated code.",
)
generated_dir = root / "convex" / "_generated"
if convex_dir.exists() and not generated_dir.exists():
add_issue(
issues,
"warning",
"missing-generated-code",
"Missing `convex/_generated/` directory.",
generated_dir,
root,
"Keep `npx convex dev` running and fix any backend compile errors.",
)
url_found, env_paths = has_convex_url(root)
if not url_found:
if env_paths:
env_list = ", ".join(relative_to_root(path, root) for path in env_paths)
add_issue(
issues,
"error",
"missing-convex-url",
f"Found env files but none define `EXPO_PUBLIC_CONVEX_URL`: {env_list}",
None,
root,
"Run `npx convex dev` or add the variable manually, then restart Metro.",
)
else:
add_issue(
issues,
"error",
"missing-env-files",
"No env file was found with `EXPO_PUBLIC_CONVEX_URL`.",
None,
root,
"Run `npx convex dev` to generate `.env.local`, then copy the value to EAS environments as needed.",
)
entry_files = detect_entry_files(root)
if not entry_files:
add_issue(
issues,
"warning",
"entrypoint-not-found",
"Could not find `app/_layout.*`, `src/app/_layout.*`, or `App.*` to verify provider wiring.",
None,
root,
"If this project uses a custom entry structure, verify the Convex provider is mounted at the real root.",
)
else:
provider_found, checked_files = project_mentions_provider(root, entry_files)
if not provider_found:
add_issue(
issues,
"error",
"provider-missing",
"No known Convex provider marker was found in the main entry files.",
None,
root,
f"Checked: {', '.join(checked_files)}. Add `ConvexProvider`, `ConvexProviderWithClerk`, or `ConvexProviderWithAuth` at the root.",
)
client_count, client_files = count_convex_clients(root)
if client_count == 0:
add_issue(
issues,
"error",
"convex-client-missing",
"No `new ConvexReactClient(...)` call was found in the project.",
None,
root,
"Create one root-level client and pass it into the provider.",
)
elif client_count > 1:
add_issue(
issues,
"warning",
"multiple-convex-clients",
f"Found {client_count} `ConvexReactClient` constructions.",
None,
root,
f"Keep only one app-wide client. Found in: {', '.join(client_files)}",
)
backend_files = find_backend_files(root)
if not backend_files and convex_dir.exists():
add_issue(
issues,
"warning",
"no-backend-files",
"The `convex/` directory exists but no backend source files were found outside `_generated`.",
convex_dir,
root,
"Add at least one function file such as `convex/tasks.ts` or run the scaffold script.",
)
for file in backend_files:
scan_backend_file(file, root, issues)
# ESLint guidance
if not dep_exists(pkg, "@convex-dev/eslint-plugin"):
add_issue(
issues,
"warning",
"convex-eslint-plugin-missing",
"The official Convex ESLint plugin is not listed in package.json.",
package_json_path,
root,
"Install `@convex-dev/eslint-plugin` and enable its recommended rules.",
)
# TypeScript strict mode guidance
tsconfig = root / "tsconfig.json"
if tsconfig.exists():
try:
tsconfig_text = read_text(tsconfig)
strict_enabled = re.search(r'"strict"\s*:\s*true', tsconfig_text) is not None
if not strict_enabled:
add_issue(
issues,
"warning",
"ts-strict-disabled",
"tsconfig.json does not appear to enable `strict: true`.",
tsconfig,
root,
"Prefer TypeScript strict mode for Convex projects.",
)
except Exception:
add_issue(
issues,
"warning",
"tsconfig-unreadable",
"Could not read tsconfig.json to verify strict mode.",
tsconfig,
root,
)
error_count = sum(1 for issue in issues if issue.severity == "error")
warning_count = sum(1 for issue in issues if issue.severity == "warning")
summary = {
"root": str(root),
"error_count": error_count,
"warning_count": warning_count,
"checked_entry_files": [relative_to_root(path, root) for path in entry_files],
"checked_backend_files": [relative_to_root(path, root) for path in backend_files],
"issues": [asdict(issue) for issue in issues],
}
summary["ok"] = error_count == 0
return summary
def render_human(summary: dict) -> str:
lines: list[str] = []
lines.append(f"Project root: {summary['root']}")
lines.append(
f"Summary: {summary['error_count']} error(s), {summary['warning_count']} warning(s)"
)
lines.append("")
if not summary["issues"]:
lines.append("✓ No validation issues found.")
return "\n".join(lines)
grouped = {"error": [], "warning": [], "info": []}
for issue in summary["issues"]:
grouped.setdefault(issue["severity"], []).append(issue)
for severity in ("error", "warning", "info"):
bucket = grouped.get(severity, [])
if not bucket:
continue
label = severity.upper()
lines.append(label)
lines.append("-" * len(label))
for issue in bucket:
where = f" [{issue['path']}]" if issue.get("path") else ""
lines.append(f"- ({issue['code']}){where} {issue['message']}")
if issue.get("hint"):
lines.append(f" hint: {issue['hint']}")
lines.append("")
if summary["issues"]:
lines.append("Suggested next steps:")
lines.append("1. Fix the errors first.")
lines.append("2. Re-run `python scripts/validate_project.py`.")
lines.append("3. For repeated Convex warnings, add linting with `@convex-dev/eslint-plugin`.")
return "\n".join(lines).rstrip() + "\n"
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Validate an Expo + Convex project for common setup and backend issues."
)
parser.add_argument(
"--root",
type=Path,
help="Project root to validate. Defaults to the nearest parent containing package.json.",
)
parser.add_argument(
"--json",
action="store_true",
help="Emit structured JSON instead of human-readable text.",
)
parser.add_argument(
"--fail-on-warning",
action="store_true",
help="Return exit code 1 when warnings are present.",
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv or sys.argv[1:])
try:
if args.root:
root = args.root.resolve()
if not (root / "package.json").exists():
if args.json:
print(json.dumps(
{
"ok": False,
"fatal": "Provided --root does not look like a Node/Expo project (missing package.json).",
"root": str(root),
},
indent=2,
))
else:
print("Could not validate: provided --root is missing package.json.", file=sys.stderr)
return 2
else:
found = find_project_root(Path.cwd())
if found is None:
if args.json:
print(json.dumps(
{
"ok": False,
"fatal": "Could not find package.json by walking up from the current directory.",
"cwd": str(Path.cwd()),
},
indent=2,
))
else:
print("Could not find package.json by walking up from the current directory.", file=sys.stderr)
return 2
root = found
summary = scan_project(root)
if args.json:
print(json.dumps(summary, indent=2))
else:
print(render_human(summary), end="")
has_errors = summary["error_count"] > 0
has_warnings = summary["warning_count"] > 0
if has_errors:
return 1
if args.fail_on_warning and has_warnings:
return 1
return 0
except KeyboardInterrupt:
if args.json:
print(json.dumps({"ok": False, "fatal": "Validation interrupted."}, indent=2))
else:
print("Validation interrupted.", file=sys.stderr)
return 3
except Exception as exc:
if args.json:
print(json.dumps({"ok": False, "fatal": f"Internal validator error: {exc}"}, indent=2))
else:
print(f"Internal validator error: {exc}", file=sys.stderr)
return 3
if __name__ == "__main__":
raise SystemExit(main())