Skip to main content
Shipr uses Convex as its real-time database and backend platform.

Why Convex?

  • Real-time subscriptions - UI updates automatically when data changes
  • Type-safe queries - Full TypeScript support with generated types
  • Built-in authentication - Integrates with Clerk JWT tokens
  • File storage - Upload and serve files with signed URLs
  • Server functions - Queries, mutations, and actions in TypeScript

Schema

The database schema is defined in convex/schema.ts:
~/workspace/source/convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  users: defineTable({
    clerkId: v.string(),
    email: v.string(),
    name: v.optional(v.string()),
    imageUrl: v.optional(v.string()),
    plan: v.optional(v.string()),
    onboardingCompleted: v.optional(v.boolean()),
    onboardingStep: v.optional(v.string()),
  }).index("by_clerk_id", ["clerkId"]),

  files: defineTable({
    storageId: v.id("_storage"),
    userId: v.id("users"),
    fileName: v.string(),
    mimeType: v.string(),
    size: v.number(),
  })
    .index("by_user_id", ["userId"])
    .index("by_storage_id", ["storageId"]),

  chatThreads: defineTable({
    userId: v.id("users"),
    title: v.string(),
    lastMessageAt: v.number(),
  })
    .index("by_user_id", ["userId"])
    .index("by_user_id_last_message", ["userId", "lastMessageAt"]),

  chatMessages: defineTable({
    userId: v.id("users"),
    threadId: v.id("chatThreads"),
    role: v.union(v.literal("user"), v.literal("assistant")),
    content: v.string(),
  })
    .index("by_user_id", ["userId"])
    .index("by_thread_id", ["threadId"]),
});

Tables

Users

Stores user profiles synced from Clerk.
FieldTypeDescription
clerkIdstringClerk user ID (indexed)
emailstringPrimary email
namestring?Full name
imageUrlstring?Profile image URL
planstring?"free" or "pro"
onboardingCompletedboolean?Onboarding completion status
onboardingStepstring?Current onboarding step

Files

Stores file upload metadata and references to Convex storage.
FieldTypeDescription
storageIdId<"_storage">Reference to stored file
userIdId<"users">Owner of the file
fileNamestringSanitized original filename
mimeTypestringMIME type (e.g., “image/png”)
sizenumberFile size in bytes

Chat Threads

Stores AI chat conversation threads.
FieldTypeDescription
userIdId<"users">Thread owner
titlestringAuto-generated thread title
lastMessageAtnumberTimestamp of last message

Chat Messages

Stores individual messages within chat threads.
FieldTypeDescription
userIdId<"users">Message owner
threadIdId<"chatThreads">Parent thread
role"user" | "assistant"Message sender
contentstringMessage text

Environment Variables

.env.example
# Convex Database
NEXT_PUBLIC_CONVEX_URL=https://...convex.cloud
VariableDescription
NEXT_PUBLIC_CONVEX_URLConvex deployment URL

Queries vs Mutations

Queries

Read data from the database. Automatically re-run when data changes.
import { useQuery } from "convex/react";
import { api } from "@convex/_generated/api";

function MyComponent() {
  const user = useQuery(api.users.getCurrentUser);
  
  if (user === undefined) return <Loading />;
  if (user === null) return <NotFound />;
  
  return <div>{user.name}</div>;
}

Mutations

Write data to the database.
import { useMutation } from "convex/react";
import { api } from "@convex/_generated/api";

function MyComponent() {
  const updateStep = useMutation(api.users.updateOnboardingStep);
  
  const handleNext = async () => {
    await updateStep({ step: "profile" });
  };
  
  return <button onClick={handleNext}>Next</button>;
}

Authentication

All Convex functions automatically receive Clerk authentication context:
~/workspace/source/convex/users.ts
export const getCurrentUser = query({
  args: {},
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) {
      return null;
    }

    return await ctx.db
      .query("users")
      .withIndex("by_clerk_id", (q) => q.eq("clerkId", identity.subject))
      .first();
  },
});
Always check ctx.auth.getUserIdentity() in mutations to enforce authentication and ownership.

Automatic Timestamps

Convex automatically adds _creationTime to every document:
const file = await ctx.db.get(fileId);
console.log(file._creationTime); // Unix timestamp in milliseconds
No need to manually track createdAt fields - use _creationTime instead.

Build docs developers (and LLMs) love