Expo + Convex

PreviousNext

Scaffold an Expo app with BNA UI and a Convex backend — a schema, a live query and no authentication.

Use this when you need to store data and sync it across devices, but do not want sign-in yet. You get everything from the Expo starter plus a Convex backend and a working demo query.

If you want users to sign in, go to Expo + Convex + Auth instead.

Create the app

pnpm dlx bna-ui convex my-app --no-auth

The CLI scaffolds the project, installs dependencies, then runs npx convex dev --once for you. That step is interactive: a browser window opens so you can log in or sign up, and you pick or create a Convex project. It writes your deployment URL to .env.local as EXPO_PUBLIC_CONVEX_URL, which is what app/_layout.tsx reads.

Pass --skip-convex to skip that and run it yourself later.

Run it

Convex-backed apps need two processes. In one terminal:

pnpm dlx convex dev

That watches convex/ and pushes function changes as you save. In another:

pnpm dlx expo start

Press i, a or w to open the app. The home tab shows the demo task list — add a task and it round-trips through your deployment.

What you get

On top of the Expo starter:

app/_layout.tsx      Root layout: ConvexProvider + ThemeProvider
app/(tabs)/(home)/   The demo screen, reading from Convex
convex/
├── schema.ts        The `tasks` table
├── tasks.ts         list (query), add / toggle / remove (mutations)
├── tsconfig.json    Convex's own TS config
└── _generated/      Types, regenerated by `npx convex dev`

package.json gains one dependency: convex.

The demo

convex/schema.ts defines a single table:

convex/schema.ts
import { v } from 'convex/values';
import { defineSchema, defineTable } from 'convex/server';
 
export default defineSchema({
  tasks: defineTable({
    text: v.string(),
    isCompleted: v.boolean(),
  }),
});

convex/tasks.ts exposes it. Queries read, mutations write — both are plain TypeScript functions that run on the server:

convex/tasks.ts
export const list = query({
  handler: async (ctx) => {
    return await ctx.db.query('tasks').order('desc').take(50);
  },
});
 
export const add = mutation({
  args: { text: v.string() },
  handler: async (ctx, args) => {
    return await ctx.db.insert('tasks', {
      text: args.text,
      isCompleted: false,
    });
  },
});

The screen subscribes with useQuery. It returns undefined while the first result is in flight, then stays live for the rest of the session — when anything writes to tasks, every subscribed client re-renders. There is no refetching and no cache to invalidate:

app/(tabs)/(home)/index.tsx
import { api } from '@/convex/_generated/api';
import { useMutation, useQuery } from 'convex/react';
 
const tasks = useQuery(api.tasks.list);
const addTask = useMutation(api.tasks.add);
 
if (tasks === undefined) return <Spinner size='lg' variant='circle' />;

Delete convex/tasks.ts, drop the tasks table from the schema and rewrite the home screen whenever you are ready to build your own thing.

Environment

VariableSet byUsed by
EXPO_PUBLIC_CONVEX_URLnpx convex dev, into .env.localapp/_layout.tsx to build the client

.env.local is gitignored. Your teammates run npx convex dev once to get their own, or you point them at a shared deployment.

Adding auth later

Nothing here blocks it. @convex-dev/auth layers onto an existing Convex project — install it, run npx @convex-dev/auth, spread authTables into your schema, and swap ConvexProvider for ConvexAuthProvider in the root layout. The auth guide describes the finished shape, and scaffolding a throwaway project with plain npx bna-ui convex is a quick way to see the pieces side by side.

Next