Skip to main content
This guide walks you through setting up Convex as your backend provider. For Supabase setup, see the Backend & Database guide.

Quick Start

1. Initialize Convex

From your project root:
# Start the Convex development server
npx convex dev
This will:
  • Create a new Convex project (or link to existing)
  • Save CONVEX_DEPLOYMENT to .env.local
  • Save EXPO_PUBLIC_CONVEX_URL to .env.local
  • Start watching for changes
First time? You’ll be prompted to log in to Convex and create a project.

2. Set Up Authentication Keys

Convex Auth requires JWT_PRIVATE_KEY and JWKS environment variables for signing and verifying tokens. The easiest way to set them up:
# Generate both keys using the Convex Auth CLI
npx @convex-dev/auth
This will prompt you to generate and set both JWT_PRIVATE_KEY and JWKS automatically.
If you have uncommitted changes or the CLI fails, generate keys manually:
# Create a script to generate matching keys
cat << 'EOF' > generate_keys.mjs
import crypto from 'crypto';

const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
  modulusLength: 2048,
  publicKeyEncoding: { type: 'spki', format: 'pem' },
  privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});

const publicKeyObj = crypto.createPublicKey(publicKey);
const jwk = publicKeyObj.export({ format: 'jwk' });
jwk.alg = 'RS256';
jwk.use = 'sig';
jwk.kid = crypto.randomUUID();

console.log('=== JWT_PRIVATE_KEY ===');
console.log(privateKey);
console.log('=== JWKS ===');
console.log(JSON.stringify({ keys: [jwk] }));
EOF

node generate_keys.mjs
Then set both values in Convex:
npx convex env set JWT_PRIVATE_KEY -- "<paste-private-key>"
npx convex env set JWKS '<paste-jwks-json>'
rm generate_keys.mjs
Both JWT_PRIVATE_KEY and JWKS are required. Missing either will cause sign-in to fail.

3. Set Backend Provider

Add to apps/app/.env:
EXPO_PUBLIC_BACKEND_PROVIDER=convex

4. Add Seed Data

npx convex run seed:run
This creates demo users, posts, and sample data for testing.

5. Run Your App

cd apps/app
yarn ios  # or yarn android, yarn web

Project Structure

your-project/
├── convex/                    # Convex backend
│   ├── _generated/           # Auto-generated types (don't edit)
│   ├── auth.ts               # Auth provider configuration
│   ├── schema.ts             # Database schema
│   ├── seed.ts               # Seed data utilities
│   ├── realtime.ts           # Presence & broadcast functions
│   ├── users.ts              # User-related functions
│   ├── storage.ts            # File storage functions
│   ├── crons.ts              # Scheduled jobs
│   └── lib/
│       └── security.ts       # Auth helpers (requireAuth, etc.)
└── apps/app/
    └── app/
        ├── hooks/convex/     # Convex-specific hooks
        ├── providers/        # ConvexProvider setup
        └── services/backend/ # Backend abstraction layer

Environment Variables

Convex uses server-side environment variables (set in Convex Dashboard) and client-side variables (in your .env file).

Required Server-Side Variables

These must be set in the Convex Dashboard → Settings → Environment Variables:
VariableRequiredDescription
JWT_PRIVATE_KEYYesRSA private key (PKCS#8 format) for signing auth tokens
JWKSYesJSON Web Key Set (public key) for verifying tokens
SITE_URLFor OAuthPublic URL for OAuth callbacks (e.g., https://your-project.convex.site)
Generate and set both keys:
npx @convex-dev/auth
Or see the manual setup in Quick Start above.

OAuth Provider Variables

Set these in Convex Dashboard if using social login:
VariableProvider
AUTH_GOOGLE_IDGoogle OAuth
AUTH_GOOGLE_SECRETGoogle OAuth
AUTH_APPLE_IDApple Sign-In
AUTH_APPLE_SECRETApple Sign-In
AUTH_GITHUB_IDGitHub OAuth
AUTH_GITHUB_SECRETGitHub OAuth
RESEND_API_KEYMagic Link/OTP emails
RESEND_FROM_EMAILMagic Link sender address
APP_NAMEApp name shown in emails

Client-Side Variables

These go in apps/app/.env:
VariableDescription
EXPO_PUBLIC_BACKEND_PROVIDERSet to convex
EXPO_PUBLIC_CONVEX_URLYour Convex deployment URL

Useful Commands

# List all environment variables
npx convex env list

# Set a variable
npx convex env set VARIABLE_NAME "value"

# Remove a variable
npx convex env unset VARIABLE_NAME

Authentication Setup

The boilerplate includes pre-configured auth providers in convex/auth.ts:

Available Providers

ProviderUsageRequirements
PasswordsignIn('password', { email, password })None
GooglesignIn('google')OAuth credentials
ApplesignIn('apple')OAuth credentials
GitHubsignIn('github')OAuth credentials
Email/OTPsignIn('resend', { email })Resend API key

Configure OAuth Providers

  1. Go to Convex Dashboard
  2. Select your project → Settings → Environment Variables
  3. Add the required credentials:
Required for OAuth (all providers):
SITE_URL=https://your-project.convex.site
SITE_URL must be a public URL for mobile OAuth. OAuth won’t work on iOS/Android simulators if SITE_URL is set to localhost or 127.0.0.1. See Mobile OAuth Requirements for details.
Google OAuth:
AUTH_GOOGLE_ID=your-client-id
AUTH_GOOGLE_SECRET=your-client-secret
Apple Sign-In:
AUTH_APPLE_ID=your-services-id
AUTH_APPLE_SECRET=your-client-secret-jwt
GitHub OAuth:
AUTH_GITHUB_ID=your-client-id
AUTH_GITHUB_SECRET=your-client-secret
Magic Link / OTP (via Resend):
RESEND_API_KEY=your-resend-api-key
RESEND_FROM_EMAIL=noreply@yourdomain.com
APP_NAME=YourAppName
Never put secrets in client code. OAuth secrets must be set in the Convex Dashboard, not in your .env file.
Check your current environment variables:
npx convex env list

Using Auth in Your App

The useAuth() Hook

Use useAuth() for all auth operations. It’s the ONLY auth hook you need:
import { useAuth } from '@/hooks'

function ProfileScreen() {
  const {
    isAuthenticated,
    isLoading,
    user,
    signIn,
    signUp,
    signOut,
    signInWithGoogle,
    signInWithApple,
    signInWithMagicLink,
    verifyOtp,
    updateProfile,
  } = useAuth()

  if (isLoading) return <Loading />

  if (!isAuthenticated) {
    return (
      <View>
        <Button
          onPress={signInWithGoogle}
          title="Sign in with Google"
        />
        <Button
          onPress={signInWithApple}
          title="Sign in with Apple"
        />
      </View>
    )
  }

  return (
    <View>
      <Text>Welcome, {user?.displayName}</Text>
      <Button onPress={signOut} title="Sign Out" />
    </View>
  )
}
useAuth() works with both Supabase and Convex - all auth methods including magic link/OTP are supported on both backends.

Database Operations

Queries (Reading Data)

import { useQuery } from '@/hooks'
import { api } from '@convex/_generated/api'

function PostList() {
  // Automatically reactive - updates when data changes
  const posts = useQuery(api.posts.list)

  if (posts === undefined) return <Loading />

  return posts.map(post => <PostCard key={post._id} post={post} />)
}

Mutations (Writing Data)

import { useMutation } from '@/hooks'
import { api } from '@convex/_generated/api'

function CreatePost() {
  const createPost = useMutation(api.posts.create)

  const handleSubmit = async () => {
    await createPost({
      title: 'My Post',
      content: 'Hello world!',
    })
  }

  return <Button onPress={handleSubmit} title="Create" />
}

Security

Convex uses function-level security. Always use the security helpers in convex/lib/security.ts:
import { requireAuth, requireOwnership } from "./lib/security"

export const updatePost = mutation({
  args: { postId: v.id("posts"), title: v.string() },
  handler: async (ctx, args) => {
    // Throws if not authenticated
    const userId = await requireAuth(ctx)

    // Throws if user doesn't own this post
    await requireOwnership(ctx, "posts", args.postId, userId, "authorId")

    // Safe to update
    await ctx.db.patch(args.postId, { title: args.title })
  },
})

Available Helpers

HelperDescription
requireAuth(ctx)Throws if not authenticated, returns userId
getAuthUserId(ctx)Returns userId or null
requireOwnership(ctx, table, id, userId, field)Throws if user doesn’t own resource
requireRole(ctx, userId, roles[])Throws if user lacks required role
auditLog(ctx, userId, action, metadata)Log security-relevant actions

Seed Data

Commands

# Populate demo data
npx convex run seed:run

# Check database status
npx convex run seed:status

# Clear all seed data
npx convex run seed:clear

# Add a test user
npx convex run seed:addTestUser

Demo Data Included

  • 2 demo users: demo@shipnative.app, test@shipnative.app
  • 3 sample posts: Published and draft examples
  • Sample comments: With threading
  • Welcome notifications: For each user

Realtime Features

Convex queries are reactive by default. The boilerplate also includes explicit presence and broadcast functions:

Presence Tracking

import { useMutation, useQuery } from '@/hooks'
import { api } from '@convex/_generated/api'

function CollaborativeDoc({ docId }) {
  const joinPresence = useMutation(api.realtime.joinPresence)
  const presence = useQuery(api.realtime.getPresence, {
    channel: `doc:${docId}`
  })

  useEffect(() => {
    joinPresence({ channel: `doc:${docId}`, state: { status: 'viewing' } })
    return () => leavePresence({ channel: `doc:${docId}` })
  }, [docId])

  return <Text>{presence?.count} users online</Text>
}

Broadcast Messages

const broadcast = useMutation(api.realtime.broadcast)

// Send cursor position to all users
broadcast({
  channel: `doc:${docId}`,
  event: 'cursor_move',
  payload: { x: 100, y: 200 }
})

File Storage

Convex provides built-in file storage. Upload files directly to Convex and get URLs for display.

Upload Files

import { useMutation } from '@/hooks'
import { api } from '@convex/_generated/api'

function FileUploader() {
  const generateUploadUrl = useMutation(api.storage.generateUploadUrl)
  const saveFile = useMutation(api.storage.saveFile)

  const uploadFile = async (file: File) => {
    // 1. Get upload URL from Convex
    const uploadUrl = await generateUploadUrl()

    // 2. Upload file directly to Convex storage
    const response = await fetch(uploadUrl, {
      method: 'POST',
      headers: { 'Content-Type': file.type },
      body: file,
    })
    const { storageId } = await response.json()

    // 3. Save metadata to database
    await saveFile({
      storageId,
      filename: file.name,
      contentType: file.type,
      size: file.size,
    })
  }

  return <Button onPress={() => pickAndUpload()} title="Upload" />
}

Get File URLs

import { useQuery } from '@/hooks'
import { api } from '@convex/_generated/api'

function FileList() {
  const files = useQuery(api.storage.listMyFiles)

  return (
    <FlatList
      data={files}
      renderItem={({ item }) => (
        <View>
          <Text>{item.filename}</Text>
          <Image source={{ uri: item.url }} />
        </View>
      )}
    />
  )
}

Cron Jobs

Convex supports scheduled tasks via cron jobs defined in convex/crons.ts.

Built-in Jobs

JobIntervalDescription
Cleanup stale presence1 minuteRemoves presence records older than 1 minute
Cleanup expired broadcasts5 minutesRemoves broadcast messages past TTL

Adding Custom Crons

// In convex/crons.ts
import { cronJobs } from "convex/server"
import { internal } from "./_generated/api"

const crons = cronJobs()

// Run every hour
crons.interval(
  "my custom job",
  { hours: 1 },
  internal.myModule.myFunction,
  { arg1: "value" }
)

// Run daily at midnight UTC
crons.daily(
  "daily cleanup",
  { hourUTC: 0, minuteUTC: 0 },
  internal.cleanup.runDaily
)

export default crons

Type Safety

Convex provides end-to-end type safety from your schema to your React components.

How It Works

// 1. Schema defines types
const schema = defineSchema({
  posts: defineTable({
    title: v.string(),
    views: v.number(),
  }),
})

// 2. Generated types flow through automatically
const posts = useQuery(api.posts.list)
// ^? Post[] | undefined

// 3. TypeScript catches errors at compile time
const createPost = useMutation(api.posts.create)
await createPost({ title: 123 }) // ❌ Error: number not assignable to string
await createPost({ title: "Hello" }) // ✅ Works

Benefits

  • No manual type definitions - types are generated from your schema
  • Autocomplete everywhere - IDE knows all available fields
  • Catch errors early - TypeScript errors before you run the code
  • Refactoring safety - rename a field and see all usages
Run npx convex dev to regenerate types after schema changes. Types are in convex/_generated/.

Deployment

Development

npx convex dev  # Runs locally, auto-syncs changes

Production

npx convex deploy  # Deploy to production

Environment Variables

Production secrets must be set in the Convex Dashboard:
  1. Go to dashboard.convex.dev
  2. Select your project
  3. Settings → Environment Variables
  4. Add all OAuth credentials and API keys

Troubleshooting

”Missing environment variable JWT_PRIVATE_KEY”, “JWKS”, or “pkcs8 format” error

This means the auth keys are missing or incorrectly formatted. Run the Convex Auth setup:
npx @convex-dev/auth
If you have uncommitted changes, see the manual setup instructions.
  • JWT_PRIVATE_KEY must be in PKCS#8 PEM format (starts with -----BEGIN PRIVATE KEY-----)
  • JWKS must be valid JSON with a keys array containing the matching public key
  • Both must be generated together from the same key pair

”Convex URL not configured” in app

Ensure .env.local has EXPO_PUBLIC_CONVEX_URL set, then restart Metro:
yarn start --clear

Auth not persisting

  • Check SecureStore permissions (iOS/Android)
  • Verify ConvexProvider wraps your app
  • Check console for errors

Types not updating

Regenerate types:
npx convex dev  # Auto-regenerates _generated/

Local backend not running

npx convex dev  # Must be running in a terminal

OAuth not working on mobile

OAuth requires a publicly accessible SITE_URL. If you’re using local Convex (127.0.0.1), OAuth will show “Convex Auth is running” instead of redirecting to the OAuth provider.
# Check your SITE_URL
npx convex env list

# Deploy to cloud and set public URL
npx convex deploy
npx convex env set SITE_URL "https://your-project.convex.site"
See Social Login - Mobile OAuth Requirements for full details.

Next Steps