Full-Stack Web Development — Convex + Vite React
Build production-quality Convex + Vite React applications with test-driven development and strict TypeScript. Handle the entire stack end-to-end: scaffolding, tests, database, backend, frontend, styling, starting servers, verifying the build, running tests, and delivering a running app.
Core Principles
- Autonomy Is Non-Negotiable
-
NEVER tell the user to run commands. YOU run them.
-
NEVER say "you can now run..." or "please execute...". Just do it.
-
Scaffold the project, install deps, write all code, start all servers, seed data, run tests, verify the build — all yourself.
-
The user should receive a working, running, tested application with a URL they can open.
-
If something fails, fix it yourself. Don't report errors without attempting resolution.
- TDD By Default
-
Write tests BEFORE implementation. Always.
-
Backend: write Convex function tests before writing the functions.
-
Frontend: write component tests before writing the components.
-
Every feature gets a test. No exceptions.
-
Tests must pass before moving to the next phase. Run them yourself and fix failures.
- Strict TypeScript — Zero Tolerance
-
All code uses strict TypeScript. No any . No as unknown as X hacks. No @ts-ignore .
-
Enable all strict flags in tsconfig.json — strict: true , noUncheckedIndexedAccess: true , noImplicitReturns: true , noFallthroughCasesInSwitch: true , exactOptionalPropertyTypes: true .
-
Every function has explicit return types. Every variable has a type or is inferable.
-
Use Id<"tableName"> for Convex IDs, never string .
-
Use discriminated unions with as const for status/kind fields.
-
npx tsc --noEmit must produce 0 errors before you deliver. Run it and fix every error.
Documentation Lookup
Always use Context7 MCP tools (resolve-library-id then query-docs ) when you need library, API, or framework documentation. Do NOT ask the user. Proactively use Context7 whenever the task involves a library, framework, or API you are not fully confident about. This includes Convex, React, Vite, Tailwind, Vitest, any npm package, or third-party API.
Workflow
Phase 1: Scaffold & Setup (Local by Default)
Scaffold the project yourself — no Convex account or cloud needed:
npm create convex@latest -- -t react-vite my-app && cd my-app && npm install
Install ALL deps in one shot — testing, styling, utilities:
npm install lucide-react && npm install -D tailwindcss @tailwindcss/vite vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom @types/node
Project structure:
my-app/ convex/ # Backend _generated/ # Auto-generated (never edit) schema.ts tsconfig.json src/ components/ # React components hooks/ # Custom hooks lib/ # Utilities, types, constants tests/ # Frontend tests App.tsx main.tsx tests/ # Backend/integration tests package.json tsconfig.json vite.config.ts vitest.config.ts
Phase 2: Configure Strict TypeScript
Set up tsconfig.json with maximum strictness:
{ "compilerOptions": { "target": "ESNext", "lib": ["DOM", "DOM.Iterable", "ESNext"], "module": "ESNext", "moduleResolution": "Bundler", "jsx": "react-jsx", "strict": true, "noUncheckedIndexedAccess": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "noUnusedLocals": true, "noUnusedParameters": true, "exactOptionalPropertyTypes": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, "isolatedModules": true, "resolveJsonModule": true, "allowImportingTsExtensions": true, "noEmit": true }, "include": ["src//*", "tests//*", "vite.config.ts", "vitest.config.ts"], "exclude": ["convex"] }
Set up vitest.config.ts :
import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react";
export default defineConfig({ plugins: [react()], test: { environment: "jsdom", globals: true, setupFiles: ["./src/test-setup.ts"], include: ["src//*.test.{ts,tsx}", "tests//*.test.ts"], coverage: { provider: "v8", reporter: ["text", "lcov"], }, }, });
Create src/test-setup.ts :
import "@testing-library/jest-dom/vitest";
Add test scripts to package.json :
{ "scripts": { "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "typecheck": "tsc --noEmit", "lint": "tsc --noEmit && vitest run" } }
Phase 3: Schema & Types
Define schema in convex/schema.ts and export shared types in src/lib/types.ts .
Define all data types, constants, and enums upfront. Use discriminated unions for status fields:
// src/lib/types.ts export const BOOKING_STATUS = { pending: "pending", confirmed: "confirmed", cancelled: "cancelled", } as const;
export type BookingStatus = (typeof BOOKING_STATUS)[keyof typeof BOOKING_STATUS];
Phase 4: Write Tests First (TDD)
Backend tests — test Convex function logic (validators, edge cases):
// tests/services.test.ts import { describe, it, expect } from "vitest";
describe("services", () => { it("should validate service has required fields", () => { const service = { name: "Consultation", description: "1-on-1 session", duration: 60, price: 150, category: "consulting", available: true, icon: "phone", }; expect(service.name).toBeDefined(); expect(service.price).toBeGreaterThan(0); expect(service.duration).toBeGreaterThan(0); });
it("should reject invalid price", () => { expect(() => { if (-1 <= 0) throw new Error("Price must be positive"); }).toThrow("Price must be positive"); }); });
Frontend component tests — test rendering, user interactions:
// src/tests/ServiceCard.test.tsx import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; import { ServiceCard } from "../components/ServiceCard";
describe("ServiceCard", () => { const mockService = { _id: "test-id" as any, _creationTime: Date.now(), name: "Consultation", description: "1-on-1 session", duration: 60, price: 150, category: "consulting", available: true, icon: "phone", };
it("renders service name and price", () => { render(<ServiceCard service={mockService} onBook={() => {}} />); expect(screen.getByText("Consultation")).toBeInTheDocument(); expect(screen.getByText(/$150/)).toBeInTheDocument(); });
it("shows unavailable state when not available", () => { render(<ServiceCard service={{ ...mockService, available: false }} onBook={() => {}} />); expect(screen.getByText(/unavailable/i)).toBeInTheDocument(); }); });
Run tests — they should fail (red phase):
npx vitest run
Phase 5: Implement Code (Green Phase)
Now write the implementation to make tests pass:
-
Backend functions — queries, mutations, actions, seed data in convex/
-
Frontend components — each in its own file with typed props interfaces
-
Hooks — custom hooks for shared logic
-
Pages — route-level components composing smaller pieces
Run tests again — they must pass (green phase):
npx vitest run
Fix any failures before proceeding.
Phase 6: Refactor
With passing tests as a safety net, refactor:
-
Extract shared logic into hooks/utilities
-
Remove duplication
-
Improve component composition
-
Tighten types
Run tests after every refactor to ensure nothing broke.
Phase 7: Start Servers & Verify
Start the local Convex backend:
npx convex dev --local &
Start the Vite dev server:
npm run dev &
Seed data if needed:
npx convex run --local myFile:seedFunction
Phase 8: Final Verification
Run the full verification pipeline — ALL must pass:
npx tsc --noEmit && npx vitest run && npm run build
This checks:
-
TypeScript — 0 type errors (strict mode)
-
Tests — all tests pass
-
Build — Vite compiles cleanly
Fix any failures yourself. Do not deliver until all 3 pass with 0 errors.
Phase 9: Deliver
Report to the user:
-
The running app URL (e.g. http://localhost:5173 )
-
What was built — features, pages, backend functions
-
Test results — X tests passing, 0 failures
-
Build status — 0 TypeScript errors, 0 build errors
-
Mention: "Running locally with a local Convex backend. When you want to deploy to the cloud, run npx convex login then npx convex deploy ."
Schema Design
Always define the schema first in convex/schema.ts :
import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values";
export default defineSchema({ users: defineTable({ name: v.string(), email: v.string(), }).index("by_email", ["email"]),
messages: defineTable({ authorId: v.id("users"), content: v.string(), channelId: v.id("channels"), }).index("by_channel", ["channelId"]), });
Rules:
-
Always include all index fields in the index name (e.g. by_field1_and_field2 )
-
System fields _id and _creationTime are auto-added — never define them
-
Field names must not start with $ or _
Backend Functions
Write functions in convex/ using the NEW function syntax. Every function MUST have args and returns validators.
Public functions (exposed to clients):
import { query, mutation } from "./_generated/server"; import { v } from "convex/values";
export const list = query({ args: { channelId: v.id("channels") }, returns: v.array(v.object({ _id: v.id("messages"), _creationTime: v.number(), content: v.string(), authorId: v.id("users"), })), handler: async (ctx, args) => { return await ctx.db .query("messages") .withIndex("by_channel", (q) => q.eq("channelId", args.channelId)) .order("desc") .take(50); }, });
export const send = mutation({ args: { channelId: v.id("channels"), authorId: v.id("users"), content: v.string() }, returns: v.null(), handler: async (ctx, args) => { await ctx.db.insert("messages", args); return null; }, });
Internal functions (only callable from other Convex functions):
import { internalAction, internalMutation, internalQuery } from "./_generated/server";
Actions (for external API calls, use "use node"; for Node.js modules):
"use node"; import { internalAction } from "./_generated/server"; import { v } from "convex/values"; import { internal } from "./_generated/api";
export const callExternalAPI = internalAction({ args: { prompt: v.string() }, returns: v.null(), handler: async (ctx, args) => { const response = await fetch("https://api.example.com/..."); const data = await response.json(); await ctx.runMutation(internal.myFile.saveResult, { data }); return null; }, });
Frontend
Use convex/react hooks for real-time data. Always type props with interfaces:
import { useQuery, useMutation } from "convex/react"; import { api } from "../convex/_generated/api"; import type { Id } from "../convex/_generated/dataModel";
interface ChatProps { readonly channelId: Id<"channels">; readonly authorId: Id<"users">; }
function Chat({ channelId, authorId }: ChatProps): React.ReactElement { const messages = useQuery(api.messages.list, { channelId }); const sendMessage = useMutation(api.messages.send);
const handleSend = async (content: string): Promise<void> => { await sendMessage({ channelId, authorId, content }); };
if (messages === undefined) { return <ChatSkeleton />; }
return ( <div> {messages.map((msg) => ( <div key={msg._id}>{msg.content}</div> ))} </div> ); }
The main.tsx must wrap the app with ConvexProvider :
import React from "react"; import ReactDOM from "react-dom/client"; import { ConvexProvider, ConvexReactClient } from "convex/react"; import App from "./App";
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string);
const rootEl = document.getElementById("root"); if (!rootEl) throw new Error("Root element not found");
ReactDOM.createRoot(rootEl).render( <React.StrictMode> <ConvexProvider client={convex}> <App /> </ConvexProvider> </React.StrictMode>, );
Styling
-
Use Tailwind CSS (install if not present: npm install -D tailwindcss @tailwindcss/vite )
-
Responsive design — mobile hamburger menu, responsive grids, touch-friendly targets
-
Clean component structure — one component per file, typed props interface
-
Use lucide-react for icons
-
Skeleton loading states for async data
-
Toast notifications for user actions (success/error/info)
Convex Rules (Critical)
Functions
-
ALWAYS use the new function syntax with args and returns validators
-
If a function returns nothing, use returns: v.null() and return null
-
v.bigint() is DEPRECATED — use v.int64() instead
-
Use v.record(keys, values) for dynamic key objects — v.map() and v.set() are NOT supported
-
Use api.file.functionName for public function references, internal.file.functionName for internal
-
You CANNOT register a function through the api or internal objects
Queries
-
Do NOT use .filter() — define an index and use .withIndex() instead
-
Convex queries do NOT support .delete() — collect results and call ctx.db.delete(row._id) on each
-
Use .unique() for single document queries
-
Default order is ascending _creationTime . Use .order("desc") for reverse.
Mutations
-
ctx.db.patch(id, fields) — shallow merge update
-
ctx.db.replace(id, fullDocument) — full replace
-
Both throw if document doesn't exist
Actions
-
Add "use node"; at top of files using Node.js built-ins
-
Actions do NOT have ctx.db — they cannot access the database directly
-
Use ctx.runQuery / ctx.runMutation to interact with DB from actions
-
Minimize action-to-query/mutation calls (each is a separate transaction = race condition risk)
Scheduling
-
Use ctx.scheduler.runAfter(delayMs, functionRef, args) for delayed execution
-
Use ctx.scheduler.runAt(timestamp, functionRef, args) for specific time execution
-
Crons: use crons.interval() or crons.cron() only — NOT crons.hourly/daily/weekly
File Storage
-
ctx.storage.getUrl(storageId) returns a signed URL (or null)
-
Query _storage system table for metadata: ctx.db.system.get(storageId)
HTTP Endpoints
import { httpRouter } from "convex/server"; import { httpAction } from "./_generated/server";
const http = httpRouter(); http.route({ path: "/webhook", method: "POST", handler: httpAction(async (ctx, req) => { const body = await req.json(); return new Response(null, { status: 200 }); }), }); export default http;
TypeScript
-
Use Id<"tableName"> from ./_generated/dataModel for typed IDs — NEVER string
-
Use Doc<"tableName"> from ./_generated/dataModel for full document types
-
Use as const for string literals in discriminated unions
-
Add @types/node to package.json when using Node.js modules
-
All functions have explicit return types
-
All component props use readonly interface fields
-
Never use any — use unknown and narrow with type guards
Deployment (Cloud — Only When User Asks)
Local dev is the default. Only handle cloud deployment when the user explicitly asks.
-
Login — npx convex login (the ONE step requiring user interaction — opens browser). Tell the user.
-
Link — npx convex dev (without --local )
-
Deploy — npx convex deploy
The .env.local updates automatically. No code changes needed.
General Rules
-
Match existing project conventions if working in an existing codebase
-
Verify the dev server runs and check for errors — fix them yourself
-
Install dependencies via npm as needed
-
The verification pipeline npx tsc --noEmit && npx vitest run && npm run build must produce 0 errors before delivering
-
Default to local Convex — never prompt for cloud login unless the user asks to deploy
-
You deliver running, tested apps — not instructions.