fullstack-vite-convex
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
1. 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.
2. 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.
3. Strict TypeScript — Zero Tolerance
- All code uses strict TypeScript. No
any. Noas unknown as Xhacks. 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, neverstring. - Use discriminated unions with
as constfor status/kind fields. npx tsc --noEmitmust 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 loginthennpx 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
_idand_creationTimeare 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-reactfor 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
argsandreturnsvalidators - If a function returns nothing, use
returns: v.null()andreturn null v.bigint()is DEPRECATED — usev.int64()instead- Use
v.record(keys, values)for dynamic key objects —v.map()andv.set()are NOT supported - Use
api.file.functionNamefor public function references,internal.file.functionNamefor internal - You CANNOT register a function through the
apiorinternalobjects
Queries
- Do NOT use
.filter()— define an index and use.withIndex()instead - Convex queries do NOT support
.delete()— collect results and callctx.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 updatectx.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.runMutationto 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()orcrons.cron()only — NOTcrons.hourly/daily/weekly
File Storage
ctx.storage.getUrl(storageId)returns a signed URL (or null)- Query
_storagesystem 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/dataModelfor typed IDs — NEVERstring - Use
Doc<"tableName">from./_generated/dataModelfor full document types - Use
as constfor string literals in discriminated unions - Add
@types/nodeto package.json when using Node.js modules - All functions have explicit return types
- All component props use
readonlyinterface fields - Never use
any— useunknownand 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 buildmust 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.