17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "convex-starter",
3 | "private": true,
4 | "scripts": {
5 | "build": "turbo run build",
6 | "dev": "turbo run dev",
7 | "lint": "turbo run lint",
8 | "format": "prettier --write \"**/*.{ts,tsx,md}\"",
9 | "check-types": "turbo run check-types"
10 | },
11 | "devDependencies": {
12 | "@repo/eslint-config": "workspace:*",
13 | "@repo/typescript-config": "workspace:*",
14 | "@turbo/gen": "^2.5.6",
15 | "prettier": "^3.6.2",
16 | "prettier-plugin-organize-imports": "^4.2.0",
17 | "prettier-plugin-tailwindcss": "^0.6.14",
18 | "turbo": "^2.5.6",
19 | "typescript": "5.9.2"
20 | },
21 | "packageManager": "pnpm@9.0.0",
22 | "engines": {
23 | "node": ">=18"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/packages/eslint-config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/eslint-config",
3 | "version": "0.0.0",
4 | "type": "module",
5 | "private": true,
6 | "exports": {
7 | "./base": "./base.js",
8 | "./next-js": "./next.js",
9 | "./react-internal": "./react-internal.js"
10 | },
11 | "devDependencies": {
12 | "@eslint/js": "^9.33.0",
13 | "@next/eslint-plugin-next": "^15.5.0",
14 | "eslint": "^9.33.0",
15 | "eslint-config-prettier": "^10.1.8",
16 | "eslint-plugin-only-warn": "^1.1.0",
17 | "eslint-plugin-react": "^7.37.5",
18 | "eslint-plugin-react-hooks": "^5.2.0",
19 | "eslint-plugin-turbo": "^2.5.6",
20 | "globals": "^16.3.0",
21 | "typescript": "^5.9.2",
22 | "typescript-eslint": "^8.40.0"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/packages/eslint-config/base.js:
--------------------------------------------------------------------------------
1 | import js from "@eslint/js";
2 | import eslintConfigPrettier from "eslint-config-prettier";
3 | import turboPlugin from "eslint-plugin-turbo";
4 | import tseslint from "typescript-eslint";
5 | import onlyWarn from "eslint-plugin-only-warn";
6 |
7 | /**
8 | * A shared ESLint configuration for the repository.
9 | *
10 | * @type {import("eslint").Linter.Config[]}
11 | * */
12 | export const config = [
13 | js.configs.recommended,
14 | eslintConfigPrettier,
15 | ...tseslint.configs.recommended,
16 | {
17 | plugins: {
18 | turbo: turboPlugin,
19 | },
20 | rules: {
21 | "turbo/no-undeclared-env-vars": "warn",
22 | },
23 | },
24 | {
25 | plugins: {
26 | onlyWarn,
27 | },
28 | },
29 | {
30 | ignores: ["dist/**"],
31 | },
32 | ];
33 |
--------------------------------------------------------------------------------
/packages/typescript-config/convex.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "compilerOptions": {
4 | "target": "ES2017",
5 | "lib": ["dom", "dom.iterable", "esnext"],
6 | "allowJs": true,
7 | "skipLibCheck": true,
8 | "strict": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "bundler",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "paths": {
23 | "@/*": ["./*"]
24 | }
25 | },
26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
27 | "exclude": ["node_modules", "components/ui/**/*.tsx"]
28 | }
29 |
--------------------------------------------------------------------------------
/apps/web/components/providers.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
4 | import { authClient } from "@repo/backend/better-auth/client";
5 | import { ConvexReactClient } from "convex/react";
6 | import { ThemeProvider as NextThemesProvider } from "next-themes";
7 | import { ReactNode } from "react";
8 |
9 | const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
10 |
11 | export function Providers({ children }: { children: ReactNode }) {
12 | return (
13 |
14 |
21 | {children}
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/packages/email/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/email",
3 | "private": true,
4 | "version": "0.0.0",
5 | "scripts": {
6 | "clean": "git clean -xdf .cache .turbo dist node_modules"
7 | },
8 | "dependencies": {
9 | "@react-email/components": "^0.5.1",
10 | "react": "^19.1.1",
11 | "react-dom": "^19.1.1"
12 | },
13 | "devDependencies": {
14 | "@repo/eslint-config": "workspace:*",
15 | "@repo/typescript-config": "workspace:*",
16 | "@types/node": "^24.3.0",
17 | "@types/react": "^19.1.11",
18 | "@types/react-dom": "^19.1.7"
19 | },
20 | "exports": {
21 | ".": {
22 | "import": "./src/index.ts",
23 | "require": "./src/index.ts"
24 | },
25 | "./templates/*": {
26 | "import": "./src/templates/*.tsx",
27 | "require": "./src/templates/*.tsx"
28 | },
29 | "./utils": {
30 | "import": "./src/utils.ts",
31 | "require": "./src/utils.ts"
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/apps/web/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Geist, Geist_Mono } from "next/font/google";
2 |
3 | import { Providers } from "@/components/providers";
4 | import "@repo/ui/globals.css";
5 | import { Toaster } from "@repo/ui/src/components/sonner";
6 |
7 | const fontSans = Geist({
8 | subsets: ["latin"],
9 | variable: "--font-sans",
10 | });
11 |
12 | const fontMono = Geist_Mono({
13 | subsets: ["latin"],
14 | variable: "--font-mono",
15 | });
16 |
17 | export const metadata = {
18 | title: "convex-starter",
19 | description: "convex-starter",
20 | };
21 |
22 | export default function RootLayout({
23 | children,
24 | }: Readonly<{
25 | children: React.ReactNode;
26 | }>) {
27 | return (
28 |
29 |
32 | {children}
33 |
34 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/apps/web/components/auth/logout-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { authClient } from "@repo/backend/better-auth/client";
4 | import { Button } from "@repo/ui/src/components/button";
5 | import { useRouter } from "next/navigation";
6 | import { useState } from "react";
7 |
8 | export default function LogoutButton() {
9 | const router = useRouter();
10 | const [isLoggingOut, setIsLoggingOut] = useState(false);
11 |
12 | const handleLogout = async () => {
13 | setIsLoggingOut(true);
14 | try {
15 | await authClient.signOut({
16 | fetchOptions: {
17 | onSuccess: () => {
18 | router.push("/login");
19 | },
20 | },
21 | });
22 | } catch (error) {
23 | console.error("Logout failed:", error);
24 | } finally {
25 | setIsLoggingOut(false);
26 | }
27 | };
28 | return (
29 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/packages/backend/convex/lib/email.tsx:
--------------------------------------------------------------------------------
1 | import { RunMutationCtx } from "@convex-dev/better-auth";
2 | import { Resend } from "@convex-dev/resend";
3 | import { render } from "@repo/email";
4 | import { components } from "../_generated/api";
5 | import "../polyfill";
6 |
7 | export const resend: Resend = new Resend(components.resend, {
8 | testMode: true,
9 | });
10 |
11 | export const sendEmail = async (
12 | ctx: RunMutationCtx,
13 | {
14 | from,
15 | to,
16 | subject,
17 | react,
18 | cc,
19 | bcc,
20 | replyTo,
21 | }: {
22 | from?: string;
23 | to: string;
24 | subject: string;
25 | react: any;
26 | cc?: string[];
27 | bcc?: string[];
28 | replyTo?: string[];
29 | }
30 | ) => {
31 | const defaultFrom = "delivered@resend.dev";
32 |
33 | await resend.sendEmail(ctx, {
34 | from: from || defaultFrom,
35 | to: to,
36 | subject,
37 | html: await render(react),
38 | ...(cc && { cc }),
39 | ...(bcc && { bcc }),
40 | ...(replyTo && { replyTo }),
41 | });
42 | };
43 |
--------------------------------------------------------------------------------
/packages/backend/convex/polyfill.ts:
--------------------------------------------------------------------------------
1 | // polyfill MessageChannel without using node:events
2 | if (typeof MessageChannel === "undefined") {
3 | class MockMessagePort {
4 | onmessage: ((ev: MessageEvent) => void) | undefined;
5 | onmessageerror: ((ev: MessageEvent) => void) | undefined;
6 |
7 | close() {}
8 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
9 | postMessage(_message: unknown, _transfer: Transferable[] = []) {}
10 | start() {}
11 | addEventListener() {}
12 | removeEventListener() {}
13 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
14 | dispatchEvent(_event: Event): boolean {
15 | return false;
16 | }
17 | }
18 |
19 | class MockMessageChannel {
20 | port1: MockMessagePort;
21 | port2: MockMessagePort;
22 |
23 | constructor() {
24 | this.port1 = new MockMessagePort();
25 | this.port2 = new MockMessagePort();
26 | }
27 | }
28 |
29 | globalThis.MessageChannel =
30 | MockMessageChannel as unknown as typeof MessageChannel;
31 | }
32 |
--------------------------------------------------------------------------------
/packages/ui/src/components/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@repo/ui/lib/utils"
4 |
5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) {
6 | return (
7 |
18 | )
19 | }
20 |
21 | export { Input }
22 |
--------------------------------------------------------------------------------
/turbo/generators/config.ts:
--------------------------------------------------------------------------------
1 | import type { PlopTypes } from "@turbo/gen";
2 |
3 | export default function generator(plop: PlopTypes.NodePlopAPI): void {
4 | plop.setGenerator("package", {
5 | description: "Generate a new package",
6 | prompts: [
7 | {
8 | type: "input",
9 | name: "name",
10 | message:
11 | "What is the name of the package? (You can skip the `@repo/` prefix)",
12 | },
13 | ],
14 | actions: [
15 | (answers) => {
16 | if (
17 | "name" in answers &&
18 | typeof answers.name === "string" &&
19 | answers.name.startsWith("@repo/")
20 | ) {
21 | answers.name = answers.name.replace("@repo/", "");
22 | }
23 | return "Config sanitized";
24 | },
25 | {
26 | type: "add",
27 | path: "packages/{{ name }}/package.json",
28 | templateFile: "templates/package.json.hbs",
29 | },
30 | {
31 | type: "add",
32 | path: "packages/{{ name }}/tsconfig.json",
33 | templateFile: "templates/tsconfig.json.hbs",
34 | },
35 | ],
36 | });
37 | }
38 |
--------------------------------------------------------------------------------
/apps/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "web",
3 | "version": "0.1.0",
4 | "type": "module",
5 | "private": true,
6 | "scripts": {
7 | "dev": "next dev --turbopack --port 3000",
8 | "build": "next build",
9 | "start": "next start",
10 | "lint": "next lint --max-warnings 0",
11 | "check-types": "tsc --noEmit"
12 | },
13 | "dependencies": {
14 | "@convex-dev/better-auth": "0.8.0-alpha.6",
15 | "@hookform/resolvers": "^5.2.1",
16 | "@repo/backend": "workspace:*",
17 | "@repo/ui": "workspace:*",
18 | "better-auth": "^1.3.7",
19 | "convex": "^1.25.4",
20 | "lucide-react": "^0.541.0",
21 | "next": "^15.5.0",
22 | "next-themes": "^0.4.6",
23 | "pg": "^8.16.3",
24 | "react": "^19.1.1",
25 | "react-dom": "^19.1.1",
26 | "react-hook-form": "^7.62.0",
27 | "sonner": "^2.0.7",
28 | "zod": "^4.0.17"
29 | },
30 | "devDependencies": {
31 | "@repo/eslint-config": "workspace:*",
32 | "@repo/typescript-config": "workspace:*",
33 | "@types/node": "^24.3.0",
34 | "@types/react": "19.1.11",
35 | "@types/react-dom": "19.1.7",
36 | "eslint": "^9.33.0",
37 | "typescript": "5.9.2"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/packages/eslint-config/react-internal.js:
--------------------------------------------------------------------------------
1 | import js from "@eslint/js";
2 | import eslintConfigPrettier from "eslint-config-prettier";
3 | import tseslint from "typescript-eslint";
4 | import pluginReactHooks from "eslint-plugin-react-hooks";
5 | import pluginReact from "eslint-plugin-react";
6 | import globals from "globals";
7 | import { config as baseConfig } from "./base.js";
8 |
9 | /**
10 | * A custom ESLint configuration for libraries that use React.
11 | *
12 | * @type {import("eslint").Linter.Config[]} */
13 | export const config = [
14 | ...baseConfig,
15 | js.configs.recommended,
16 | eslintConfigPrettier,
17 | ...tseslint.configs.recommended,
18 | pluginReact.configs.flat.recommended,
19 | {
20 | languageOptions: {
21 | ...pluginReact.configs.flat.recommended.languageOptions,
22 | globals: {
23 | ...globals.serviceworker,
24 | ...globals.browser,
25 | },
26 | },
27 | },
28 | {
29 | plugins: {
30 | "react-hooks": pluginReactHooks,
31 | },
32 | settings: { react: { version: "detect" } },
33 | rules: {
34 | ...pluginReactHooks.configs.recommended.rules,
35 | // React scope no longer necessary with new JSX transform.
36 | "react/react-in-jsx-scope": "off",
37 | },
38 | },
39 | ];
40 |
--------------------------------------------------------------------------------
/packages/ui/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/ui",
3 | "version": "0.0.0",
4 | "private": true,
5 | "exports": {
6 | "./globals.css": "./src/styles/globals.css",
7 | "./postcss.config": "./postcss.config.mjs",
8 | "./lib/*": "./src/lib/*.ts",
9 | "./components/*": "./src/components/*.tsx",
10 | "./hooks/*": "./src/hooks/*.ts"
11 | },
12 | "scripts": {
13 | "lint": "eslint . --max-warnings 0",
14 | "check-types": "tsc --noEmit"
15 | },
16 | "devDependencies": {
17 | "@repo/eslint-config": "workspace:*",
18 | "@repo/typescript-config": "workspace:*",
19 | "@tailwindcss/postcss": "^4.1.12",
20 | "@turbo/gen": "^2.5.6",
21 | "@types/node": "^24.3.0",
22 | "@types/react": "^19.1.11",
23 | "@types/react-dom": "^19.1.7",
24 | "eslint": "^9.33.0",
25 | "tailwindcss": "^4.1.12",
26 | "typescript": "5.9.2"
27 | },
28 | "dependencies": {
29 | "@radix-ui/react-label": "^2.1.7",
30 | "@radix-ui/react-slot": "^1.2.3",
31 | "class-variance-authority": "^0.7.1",
32 | "clsx": "^2.1.1",
33 | "lucide-react": "^0.541.0",
34 | "next-themes": "^0.4.6",
35 | "react": "^19.1.1",
36 | "react-dom": "^19.1.1",
37 | "sonner": "^2.0.7",
38 | "tailwind-merge": "^3.3.1",
39 | "tw-animate-css": "^1.3.7",
40 | "zod": "^4.0.17"
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/packages/eslint-config/next.js:
--------------------------------------------------------------------------------
1 | import js from "@eslint/js";
2 | import eslintConfigPrettier from "eslint-config-prettier";
3 | import tseslint from "typescript-eslint";
4 | import pluginReactHooks from "eslint-plugin-react-hooks";
5 | import pluginReact from "eslint-plugin-react";
6 | import globals from "globals";
7 | import pluginNext from "@next/eslint-plugin-next";
8 | import { config as baseConfig } from "./base.js";
9 |
10 | /**
11 | * A custom ESLint configuration for libraries that use Next.js.
12 | *
13 | * @type {import("eslint").Linter.Config[]}
14 | * */
15 | export const nextJsConfig = [
16 | ...baseConfig,
17 | js.configs.recommended,
18 | eslintConfigPrettier,
19 | ...tseslint.configs.recommended,
20 | {
21 | ...pluginReact.configs.flat.recommended,
22 | languageOptions: {
23 | ...pluginReact.configs.flat.recommended.languageOptions,
24 | globals: {
25 | ...globals.serviceworker,
26 | },
27 | },
28 | },
29 | {
30 | plugins: {
31 | "@next/next": pluginNext,
32 | },
33 | rules: {
34 | ...pluginNext.configs.recommended.rules,
35 | ...pluginNext.configs["core-web-vitals"].rules,
36 | },
37 | },
38 | {
39 | plugins: {
40 | "react-hooks": pluginReactHooks,
41 | },
42 | settings: { react: { version: "detect" } },
43 | rules: {
44 | ...pluginReactHooks.configs.recommended.rules,
45 | // React scope no longer necessary with new JSX transform.
46 | "react/react-in-jsx-scope": "off",
47 | },
48 | },
49 | ];
50 |
--------------------------------------------------------------------------------
/apps/web/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | # or
12 | pnpm dev
13 | # or
14 | bun dev
15 | ```
16 |
17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18 |
19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20 |
21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load Inter, a custom Google Font.
22 |
23 | ## Learn More
24 |
25 | To learn more about Next.js, take a look at the following resources:
26 |
27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29 |
30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
31 |
32 | ## Deploy on Vercel
33 |
34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35 |
36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
37 |
--------------------------------------------------------------------------------
/packages/backend/convex/auth.ts:
--------------------------------------------------------------------------------
1 | import {
2 | BetterAuth,
3 | type AuthFunctions,
4 | type PublicAuthFunctions,
5 | } from "@convex-dev/better-auth";
6 | import { api, components, internal } from "./_generated/api";
7 | import type { DataModel, Id } from "./_generated/dataModel";
8 | import { query } from "./_generated/server";
9 |
10 | // Typesafe way to pass Convex functions defined in this file
11 | const authFunctions: AuthFunctions = internal.auth;
12 | const publicAuthFunctions: PublicAuthFunctions = api.auth;
13 |
14 | // Initialize the component
15 | export const betterAuthComponent = new BetterAuth(components.betterAuth, {
16 | authFunctions,
17 | publicAuthFunctions,
18 | });
19 |
20 | // These are required named exports
21 | export const {
22 | createUser,
23 | updateUser,
24 | deleteUser,
25 | createSession,
26 | isAuthenticated,
27 | } = betterAuthComponent.createAuthFunctions({
28 | // Must create a user and return the user id
29 | onCreateUser: async (ctx, user) => {
30 | return ctx.db.insert("users", { email: user.email });
31 | },
32 |
33 | // Delete the user when they are deleted from Better Auth
34 | onDeleteUser: async (ctx, userId) => {
35 | await ctx.db.delete(userId as Id<"users">);
36 | },
37 | });
38 |
39 | // Example function for getting the current user
40 | // Feel free to edit, omit, etc.
41 | export const getCurrentUser = query({
42 | args: {},
43 | handler: async (ctx) => {
44 | // Get user data from Better Auth - email, name, image, etc.
45 | const userMetadata = await betterAuthComponent.getAuthUser(ctx);
46 | if (!userMetadata) {
47 | return null;
48 | }
49 | // Get user data from your application's database
50 | // (skip this if you have no fields in your users table schema)
51 | const user = await ctx.db.get(userMetadata.userId as Id<"users">);
52 | return {
53 | ...user,
54 | ...userMetadata,
55 | };
56 | },
57 | });
58 |
--------------------------------------------------------------------------------
/packages/backend/convex/_generated/dataModel.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated data model types.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * To regenerate, run `npx convex dev`.
8 | * @module
9 | */
10 |
11 | import type {
12 | DataModelFromSchemaDefinition,
13 | DocumentByName,
14 | TableNamesInDataModel,
15 | SystemTableNames,
16 | } from "convex/server";
17 | import type { GenericId } from "convex/values";
18 | import schema from "../schema.js";
19 |
20 | /**
21 | * The names of all of your Convex tables.
22 | */
23 | export type TableNames = TableNamesInDataModel;
24 |
25 | /**
26 | * The type of a document stored in Convex.
27 | *
28 | * @typeParam TableName - A string literal type of the table name (like "users").
29 | */
30 | export type Doc = DocumentByName<
31 | DataModel,
32 | TableName
33 | >;
34 |
35 | /**
36 | * An identifier for a document in Convex.
37 | *
38 | * Convex documents are uniquely identified by their `Id`, which is accessible
39 | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
40 | *
41 | * Documents can be loaded using `db.get(id)` in query and mutation functions.
42 | *
43 | * IDs are just strings at runtime, but this type can be used to distinguish them from other
44 | * strings when type checking.
45 | *
46 | * @typeParam TableName - A string literal type of the table name (like "users").
47 | */
48 | export type Id =
49 | GenericId;
50 |
51 | /**
52 | * A type describing your Convex data model.
53 | *
54 | * This type includes information about what tables you have, the type of
55 | * documents stored in those tables, and the indexes defined on them.
56 | *
57 | * This type is used to parameterize methods like `queryGeneric` and
58 | * `mutationGeneric` to make them type-safe.
59 | */
60 | export type DataModel = DataModelFromSchemaDefinition;
61 |
--------------------------------------------------------------------------------
/apps/web/components/auth/user-card.tsx:
--------------------------------------------------------------------------------
1 | import LogoutButton from "@/components/auth/logout-button";
2 | import {
3 | Card,
4 | CardContent,
5 | CardDescription,
6 | CardFooter,
7 | CardHeader,
8 | } from "@repo/ui/src/components/card";
9 | import Image from "next/image";
10 |
11 | type Props = {
12 | user: {
13 | name?: string | null;
14 | email?: string | null;
15 | image?: string | null;
16 | } | null;
17 | };
18 |
19 | function UserCard({ user }: Props) {
20 | return (
21 |
22 |
23 |
24 |
25 |
32 |
33 |
34 |
35 |
36 |
Name
37 |
{user?.name || "Not provided"}
38 |
39 |
40 |
Email
41 |
{user?.email}
42 |
43 | {user?.image && (
44 |
45 |
Avatar
46 |
47 |
54 |
55 |
56 | )}
57 |
58 |
59 |
60 |
61 |
62 |
63 | );
64 | }
65 |
66 | export default UserCard;
67 |
--------------------------------------------------------------------------------
/packages/backend/better-auth/server.ts:
--------------------------------------------------------------------------------
1 | import { convexAdapter } from "@convex-dev/better-auth";
2 | import { convex } from "@convex-dev/better-auth/plugins";
3 | import { requireMutationCtx } from "@convex-dev/better-auth/utils";
4 | import VerifyEmail from "@repo/email/templates/verify-email";
5 | import { betterAuth, BetterAuthOptions } from "better-auth";
6 | import { organization } from "better-auth/plugins";
7 | import { GenericCtx } from "../convex/_generated/server";
8 | import { betterAuthComponent } from "../convex/auth";
9 | import { sendEmail } from "../convex/lib/email";
10 |
11 | const createOptions = (ctx: GenericCtx) =>
12 | ({
13 | baseURL: process.env.APP_URL as string,
14 | database: convexAdapter(ctx, betterAuthComponent),
15 | account: {
16 | accountLinking: {
17 | enabled: true,
18 | allowDifferentEmails: true,
19 | },
20 | },
21 |
22 | emailAndPassword: {
23 | enabled: true,
24 | },
25 | emailVerification: {
26 | sendVerificationEmail: async ({ user, url }) => {
27 | await sendEmail(requireMutationCtx(ctx), {
28 | to: user.email,
29 | subject: "Verify your email address",
30 | react: VerifyEmail({ name: user.name || "", verificationUrl: url }),
31 | });
32 | },
33 | },
34 | socialProviders: {
35 | github: {
36 | clientId: process.env.GITHUB_CLIENT_ID as string,
37 | clientSecret: process.env.GITHUB_CLIENT_SECRET as string,
38 | },
39 | google: {
40 | clientId: process.env.GOOGLE_CLIENT_ID as string,
41 | clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
42 | accessType: "offline",
43 | prompt: "select_account+consent",
44 | },
45 | },
46 | plugins: [organization()],
47 | }) satisfies BetterAuthOptions;
48 |
49 | export const auth = (ctx: GenericCtx): ReturnType => {
50 | const options = createOptions(ctx);
51 | return betterAuth({
52 | ...options,
53 | plugins: [
54 | ...options.plugins,
55 | // Pass in options so plugin schema inference flows through. Only required
56 | // for plugins that customize the user or session schema.
57 | // See "Some caveats":
58 | // https://www.better-auth.com/docs/concepts/session-management#customizing-session-response
59 | convex({ options }),
60 | ],
61 | });
62 | };
63 |
--------------------------------------------------------------------------------
/packages/ui/src/components/button.tsx:
--------------------------------------------------------------------------------
1 | import { Slot } from "@radix-ui/react-slot";
2 | import { cva, type VariantProps } from "class-variance-authority";
3 | import * as React from "react";
4 |
5 | import { cn } from "@repo/ui/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
16 | outline:
17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
20 | ghost:
21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
22 | link: "text-primary underline-offset-4 hover:underline",
23 | },
24 | size: {
25 | default: "h-9 px-4 py-2 has-[>svg]:px-3",
26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
28 | icon: "size-9",
29 | },
30 | },
31 | defaultVariants: {
32 | variant: "default",
33 | size: "default",
34 | },
35 | }
36 | );
37 |
38 | function Button({
39 | className,
40 | variant,
41 | size,
42 | asChild = false,
43 | ...props
44 | }: React.ComponentProps<"button"> &
45 | VariantProps & {
46 | asChild?: boolean;
47 | }) {
48 | const Comp = asChild ? Slot : "button";
49 |
50 | return (
51 |
56 | );
57 | }
58 |
59 | export { Button, buttonVariants };
60 |
--------------------------------------------------------------------------------
/packages/ui/src/components/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@repo/ui/lib/utils"
4 |
5 | function Card({ className, ...props }: React.ComponentProps<"div">) {
6 | return (
7 |
15 | )
16 | }
17 |
18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
19 | return (
20 |
28 | )
29 | }
30 |
31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
32 | return (
33 |
38 | )
39 | }
40 |
41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
42 | return (
43 |
48 | )
49 | }
50 |
51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) {
52 | return (
53 |
61 | )
62 | }
63 |
64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) {
65 | return (
66 |
71 | )
72 | }
73 |
74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
75 | return (
76 |
81 | )
82 | }
83 |
84 | export {
85 | Card,
86 | CardHeader,
87 | CardFooter,
88 | CardTitle,
89 | CardAction,
90 | CardDescription,
91 | CardContent,
92 | }
93 |
--------------------------------------------------------------------------------
/packages/email/src/templates/verify-email.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Body,
3 | Button,
4 | Container,
5 | Head,
6 | Hr,
7 | Html,
8 | Link,
9 | Text,
10 | } from "@react-email/components";
11 |
12 | interface VerifyEmailProps {
13 | name: string;
14 | verificationUrl: string;
15 | }
16 |
17 | export default function VerifyEmail({
18 | name,
19 | verificationUrl,
20 | }: VerifyEmailProps) {
21 | return (
22 |
23 |
24 |
25 |
26 | {name ? `Hi ${name},` : "Hi there,"}
27 |
28 |
29 | Welcome! Please verify your email address to complete your account
30 | setup.
31 |
32 |
33 |
34 | Click the button below to verify your email address:
35 |
36 |
37 |
40 |
41 |
42 | Or copy and paste this link into your browser:
43 |
44 |
45 |
46 | {verificationUrl}
47 |
48 |
49 |
50 |
51 |
52 | If you didn't create an account, you can safely ignore this email.
53 |
54 |
55 |
56 | This verification link will expire in 24 hours for security reasons.
57 |
58 |
59 |
60 |
61 | );
62 | }
63 |
64 | const main = {
65 | backgroundColor: "#ffffff",
66 | fontFamily:
67 | '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif',
68 | };
69 |
70 | const container = {
71 | margin: "0 auto",
72 | padding: "20px 0 48px",
73 | maxWidth: "560px",
74 | };
75 |
76 | const heading = {
77 | fontSize: "24px",
78 | letterSpacing: "-0.5px",
79 | lineHeight: "1.3",
80 | fontWeight: "400",
81 | color: "#484848",
82 | padding: "17px 0 0",
83 | };
84 |
85 | const paragraph = {
86 | margin: "0 0 15px",
87 | fontSize: "15px",
88 | lineHeight: "1.4",
89 | color: "#3c4149",
90 | };
91 |
92 | const button = {
93 | backgroundColor: "#007ee6",
94 | borderRadius: "4px",
95 | color: "#fff",
96 | fontSize: "15px",
97 | textDecoration: "none",
98 | textAlign: "center" as const,
99 | display: "block",
100 | width: "210px",
101 | padding: "14px 7px",
102 | margin: "16px auto",
103 | };
104 |
105 | const link = {
106 | color: "#007ee6",
107 | fontSize: "14px",
108 | textDecoration: "underline",
109 | wordBreak: "break-all" as const,
110 | };
111 |
112 | const hr = {
113 | borderColor: "#e6ebf1",
114 | margin: "20px 0",
115 | };
116 |
117 | const footer = {
118 | color: "#8898aa",
119 | fontSize: "12px",
120 | lineHeight: "16px",
121 | marginTop: "12px",
122 | };
123 |
--------------------------------------------------------------------------------
/packages/backend/convex/_generated/server.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated utilities for implementing server-side Convex query and mutation functions.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * To regenerate, run `npx convex dev`.
8 | * @module
9 | */
10 |
11 | import {
12 | actionGeneric,
13 | httpActionGeneric,
14 | queryGeneric,
15 | mutationGeneric,
16 | internalActionGeneric,
17 | internalMutationGeneric,
18 | internalQueryGeneric,
19 | componentsGeneric,
20 | } from "convex/server";
21 |
22 | /**
23 | * Define a query in this Convex app's public API.
24 | *
25 | * This function will be allowed to read your Convex database and will be accessible from the client.
26 | *
27 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
28 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
29 | */
30 | export const query = queryGeneric;
31 |
32 | /**
33 | * Define a query that is only accessible from other Convex functions (but not from the client).
34 | *
35 | * This function will be allowed to read from your Convex database. It will not be accessible from the client.
36 | *
37 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
38 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
39 | */
40 | export const internalQuery = internalQueryGeneric;
41 |
42 | /**
43 | * Define a mutation in this Convex app's public API.
44 | *
45 | * This function will be allowed to modify your Convex database and will be accessible from the client.
46 | *
47 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
48 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
49 | */
50 | export const mutation = mutationGeneric;
51 |
52 | /**
53 | * Define a mutation that is only accessible from other Convex functions (but not from the client).
54 | *
55 | * This function will be allowed to modify your Convex database. It will not be accessible from the client.
56 | *
57 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
58 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
59 | */
60 | export const internalMutation = internalMutationGeneric;
61 |
62 | /**
63 | * Define an action in this Convex app's public API.
64 | *
65 | * An action is a function which can execute any JavaScript code, including non-deterministic
66 | * code and code with side-effects, like calling third-party services.
67 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
68 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
69 | *
70 | * @param func - The action. It receives an {@link ActionCtx} as its first argument.
71 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
72 | */
73 | export const action = actionGeneric;
74 |
75 | /**
76 | * Define an action that is only accessible from other Convex functions (but not from the client).
77 | *
78 | * @param func - The function. It receives an {@link ActionCtx} as its first argument.
79 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
80 | */
81 | export const internalAction = internalActionGeneric;
82 |
83 | /**
84 | * Define a Convex HTTP action.
85 | *
86 | * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object
87 | * as its second.
88 | * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`.
89 | */
90 | export const httpAction = httpActionGeneric;
91 |
--------------------------------------------------------------------------------
/packages/ui/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 | @source "../../../apps/**/*.{ts,tsx}";
3 | @source "../../../components/**/*.{ts,tsx}";
4 | @source "../**/*.{ts,tsx}";
5 |
6 | @import "tw-animate-css";
7 |
8 | @custom-variant dark (&:is(.dark *));
9 |
10 | :root {
11 | --background: oklch(1 0 0);
12 | --foreground: oklch(0.145 0 0);
13 | --card: oklch(1 0 0);
14 | --card-foreground: oklch(0.145 0 0);
15 | --popover: oklch(1 0 0);
16 | --popover-foreground: oklch(0.145 0 0);
17 | --primary: oklch(0.205 0 0);
18 | --primary-foreground: oklch(0.985 0 0);
19 | --secondary: oklch(0.97 0 0);
20 | --secondary-foreground: oklch(0.205 0 0);
21 | --muted: oklch(0.97 0 0);
22 | --muted-foreground: oklch(0.556 0 0);
23 | --accent: oklch(0.97 0 0);
24 | --accent-foreground: oklch(0.205 0 0);
25 | --destructive: oklch(0.577 0.245 27.325);
26 | --destructive-foreground: oklch(0.577 0.245 27.325);
27 | --border: oklch(0.922 0 0);
28 | --input: oklch(0.922 0 0);
29 | --ring: oklch(0.708 0 0);
30 | --chart-1: oklch(0.646 0.222 41.116);
31 | --chart-2: oklch(0.6 0.118 184.704);
32 | --chart-3: oklch(0.398 0.07 227.392);
33 | --chart-4: oklch(0.828 0.189 84.429);
34 | --chart-5: oklch(0.769 0.188 70.08);
35 | --radius: 0.625rem;
36 | --sidebar: oklch(0.985 0 0);
37 | --sidebar-foreground: oklch(0.145 0 0);
38 | --sidebar-primary: oklch(0.205 0 0);
39 | --sidebar-primary-foreground: oklch(0.985 0 0);
40 | --sidebar-accent: oklch(0.97 0 0);
41 | --sidebar-accent-foreground: oklch(0.205 0 0);
42 | --sidebar-border: oklch(0.922 0 0);
43 | --sidebar-ring: oklch(0.708 0 0);
44 | }
45 |
46 | .dark {
47 | --background: oklch(0.145 0 0);
48 | --foreground: oklch(0.985 0 0);
49 | --card: oklch(0.145 0 0);
50 | --card-foreground: oklch(0.985 0 0);
51 | --popover: oklch(0.145 0 0);
52 | --popover-foreground: oklch(0.985 0 0);
53 | --primary: oklch(0.985 0 0);
54 | --primary-foreground: oklch(0.205 0 0);
55 | --secondary: oklch(0.269 0 0);
56 | --secondary-foreground: oklch(0.985 0 0);
57 | --muted: oklch(0.269 0 0);
58 | --muted-foreground: oklch(0.708 0 0);
59 | --accent: oklch(0.269 0 0);
60 | --accent-foreground: oklch(0.985 0 0);
61 | --destructive: oklch(0.396 0.141 25.723);
62 | --destructive-foreground: oklch(0.637 0.237 25.331);
63 | --border: oklch(0.269 0 0);
64 | --input: oklch(0.269 0 0);
65 | --ring: oklch(0.556 0 0);
66 | --chart-1: oklch(0.488 0.243 264.376);
67 | --chart-2: oklch(0.696 0.17 162.48);
68 | --chart-3: oklch(0.769 0.188 70.08);
69 | --chart-4: oklch(0.627 0.265 303.9);
70 | --chart-5: oklch(0.645 0.246 16.439);
71 | --sidebar: oklch(0.205 0 0);
72 | --sidebar-foreground: oklch(0.985 0 0);
73 | --sidebar-primary: oklch(0.488 0.243 264.376);
74 | --sidebar-primary-foreground: oklch(0.985 0 0);
75 | --sidebar-accent: oklch(0.269 0 0);
76 | --sidebar-accent-foreground: oklch(0.985 0 0);
77 | --sidebar-border: oklch(0.269 0 0);
78 | --sidebar-ring: oklch(0.439 0 0);
79 | }
80 |
81 | @theme inline {
82 | --color-background: var(--background);
83 | --color-foreground: var(--foreground);
84 | --color-card: var(--card);
85 | --color-card-foreground: var(--card-foreground);
86 | --color-popover: var(--popover);
87 | --color-popover-foreground: var(--popover-foreground);
88 | --color-primary: var(--primary);
89 | --color-primary-foreground: var(--primary-foreground);
90 | --color-secondary: var(--secondary);
91 | --color-secondary-foreground: var(--secondary-foreground);
92 | --color-muted: var(--muted);
93 | --color-muted-foreground: var(--muted-foreground);
94 | --color-accent: var(--accent);
95 | --color-accent-foreground: var(--accent-foreground);
96 | --color-destructive: var(--destructive);
97 | --color-destructive-foreground: var(--destructive-foreground);
98 | --color-border: var(--border);
99 | --color-input: var(--input);
100 | --color-ring: var(--ring);
101 | --color-chart-1: var(--chart-1);
102 | --color-chart-2: var(--chart-2);
103 | --color-chart-3: var(--chart-3);
104 | --color-chart-4: var(--chart-4);
105 | --color-chart-5: var(--chart-5);
106 | --radius-sm: calc(var(--radius) - 4px);
107 | --radius-md: calc(var(--radius) - 2px);
108 | --radius-lg: var(--radius);
109 | --radius-xl: calc(var(--radius) + 4px);
110 | --color-sidebar: var(--sidebar);
111 | --color-sidebar-foreground: var(--sidebar-foreground);
112 | --color-sidebar-primary: var(--sidebar-primary);
113 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
114 | --color-sidebar-accent: var(--sidebar-accent);
115 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
116 | --color-sidebar-border: var(--sidebar-border);
117 | --color-sidebar-ring: var(--sidebar-ring);
118 | }
119 |
120 | @layer base {
121 | * {
122 | @apply border-border outline-ring/50;
123 | }
124 | body {
125 | @apply bg-background text-foreground;
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # convex-starter
2 |
3 | A highly opinionated Next.js starter with better-auth, convex, shadcn/ui, react-email, and turborepo. Pre-configured for rapid, scalable development.
4 |
5 | ## Project Structure
6 |
7 | ```
8 | convex-starter/
9 | ├── apps/
10 | │ └── web/ # Main Next.js application
11 | ├── packages/
12 | │ ├── backend/ # Convex backend
13 | │ ├── eslint-config/ # Shared ESLint configurations
14 | │ ├── typescript-config/ # Shared TypeScript configurations
15 | │ └── ui/ # Shared UI components (shadcn/ui)
16 | └── turbo/ # Turborepo configuration
17 | ```
18 |
19 | ## Features
20 |
21 | - Authentication with [Better Auth](https://better-auth.com)
22 | - Backend platform (db, functions, storage, jobs) using [Convex](https://www.convex.dev/)
23 | - UI components built with [shadcn/ui](https://ui.shadcn.com) and [Tailwind CSS](https://tailwindcss.com)
24 | - Email support with [react-email](https://react.email) and [Resend](https://resend.com)
25 | - Form handling via [react-hook-form](https://react-hook-form.com)
26 | - Monorepo setup using [Turborepo](https://turbo.build/repo)
27 |
28 | ## Getting Started
29 |
30 | ### 1. Create a New Project
31 |
32 | ```bash
33 | npx create-next-app@latest [project-name] --use-pnpm --example https://github.com/jordanliu/convex-starter
34 | ```
35 |
36 | ### 2. Install Dependencies
37 |
38 | ```bash
39 | cd [project-name]
40 | pnpm install
41 | ```
42 |
43 | ### 3. Configure Client
44 |
45 | Copy the example environment file into .env.local in apps/web, then update it with your real values.
46 |
47 | ```bash
48 | cp apps/web/.env.example apps/web/.env.local
49 | ```
50 |
51 | ### 4. Configure Convex
52 |
53 | ```bash
54 | pnpm --filter @repo/backend run setup
55 | ```
56 |
57 | This initializes your Convex project. Next, ensure your backend environment variables are uploaded to the Convex dashboard. From root run:
58 |
59 | ```bash
60 | cp packages/backend/.env.example packages/backend/.env.local
61 | ```
62 |
63 | You will then need to upload the environment variables into your Convex dashboard manually or via `convex env`. You can find more details [here](https://docs.convex.dev/production/environment-variables).
64 |
65 | ### 5. Start the Development Server
66 |
67 | ```bash
68 | pnpm dev
69 | ```
70 |
71 | This will start both the Next.js application at [http://localhost:3000](http://localhost:3000) and the Convex development server at [http://127.0.0.1:6790](http://127.0.0.1:6790).
72 |
73 | ## Available Commands
74 |
75 | ### Development
76 |
77 | ```bash
78 | pnpm dev # Start development servers for all packages
79 | pnpm build # Build all packages for production
80 | pnpm start # Start production server (requires build)
81 | ```
82 |
83 | ### Code Quality
84 |
85 | ```bash
86 | pnpm lint # Run ESLint across all packages
87 | pnpm format # Format code with Prettier
88 | pnpm check-types # Run TypeScript type checking
89 | ```
90 |
91 | ### Convex-Specific
92 |
93 | ```bash
94 | pnpm --filter @repo/backend setup # Initialize Convex project (run once)
95 | pnpm --filter @repo/backend dev # Start Convex development server only
96 | pnpm --filter @repo/backend deploy # Deploy Convex backend to production
97 | ```
98 |
99 | ### Package-Specific
100 |
101 | ```bash
102 | pnpm --filter web dev # Run only the Next.js application
103 | ```
104 |
105 | ## Project Management
106 |
107 | ### Adding New Packages
108 |
109 | ```bash
110 | turbo gen
111 | ```
112 |
113 | Follow the prompts to scaffold a new package with proper TypeScript and build configurations.
114 |
115 | ### Adding shadcn/ui Components
116 |
117 | ```bash
118 | cd apps/web
119 | pnpm dlx shadcn@canary add [component-name]
120 | ```
121 |
122 | Components are automatically added to the UI package and can be imported across the monorepo.
123 |
124 | ### Managing Dependencies
125 |
126 | ```bash
127 | # Add to specific package
128 | pnpm --filter web add [package-name]
129 | pnpm --filter @repo/ui add [package-name]
130 | pnpm --filter @repo/backend add [package-name]
131 |
132 | # Add to workspace root (affects all packages)
133 | pnpm add -w [package-name]
134 |
135 | # Add dev dependencies
136 | pnpm --filter web add -D [package-name]
137 | ```
138 |
139 | ## Deployment
140 |
141 | ### 1. Deploy Convex Backend
142 |
143 | ```bash
144 | pnpm --filter @repo/backend run deploy
145 | ```
146 |
147 | This creates your production Convex deployment and provides you with a production URL.
148 |
149 | ### 2. Configure Production Environment
150 |
151 | Update your hosting platform (Vercel, Netlify, etc.) with the production Convex URL:
152 |
153 | ```env
154 | CONVEX_URL=https://your-production-deployment.convex.cloud
155 | NEXT_PUBLIC_CONVEX_URL=https://your-production-deployment.convex.cloud
156 | ```
157 |
158 | ### 3. Build and Deploy Frontend
159 |
160 | ```bash
161 | pnpm build
162 | ```
163 |
164 | Then deploy the built application using your preferred hosting platform's deployment method.
165 |
--------------------------------------------------------------------------------
/apps/web/components/auth/register-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { zodResolver } from "@hookform/resolvers/zod";
4 | import { authClient } from "@repo/backend/better-auth/client";
5 | import { Button } from "@repo/ui/components/button";
6 | import {
7 | Card,
8 | CardContent,
9 | CardDescription,
10 | CardHeader,
11 | CardTitle,
12 | } from "@repo/ui/components/card";
13 | import { Input } from "@repo/ui/components/input";
14 | import { Label } from "@repo/ui/components/label";
15 | import { cn } from "@repo/ui/lib/utils";
16 | import Link from "next/link";
17 | import { useRouter } from "next/navigation";
18 | import { useState } from "react";
19 | import { useForm } from "react-hook-form";
20 | import { toast } from "sonner";
21 | import { z } from "zod";
22 |
23 | const registerSchema = z.object({
24 | name: z.string().min(2, "Name must be at least 2 characters"),
25 | email: z.string().email("Please enter a valid email address"),
26 | password: z.string().min(8, "Password must be at least 8 characters"),
27 | });
28 |
29 | type RegisterFormData = z.infer;
30 |
31 | export function RegisterForm({
32 | className,
33 | ...props
34 | }: React.ComponentProps<"div">) {
35 | const [isLoading, setIsLoading] = useState(false);
36 | const router = useRouter();
37 |
38 | const {
39 | register,
40 | handleSubmit,
41 | formState: { errors },
42 | } = useForm({
43 | resolver: zodResolver(registerSchema),
44 | });
45 |
46 | const onSubmit = async (data: RegisterFormData) => {
47 | setIsLoading(true);
48 |
49 | try {
50 | const { data: authData, error } = await authClient.signUp.email({
51 | name: data.name,
52 | email: data.email,
53 | password: data.password,
54 | });
55 |
56 | if (error) {
57 | toast.error("Sign up failed", {
58 | description:
59 | error.message || "Please check your information and try again.",
60 | });
61 | return;
62 | }
63 |
64 | if (authData) {
65 | console.log(authData);
66 | toast.success("Account created successfully!", {
67 | description: "Welcome! You can now start using the app.",
68 | });
69 |
70 | // Redirect to home page after successful registration
71 | router.push("/");
72 | }
73 | } catch {
74 | toast.error("Something went wrong", {
75 | description: "An unexpected error occurred. Please try again.",
76 | });
77 | } finally {
78 | setIsLoading(false);
79 | }
80 | };
81 |
82 | return (
83 |
84 |
85 |
86 | Create Account
87 | Sign up to get started
88 |
89 |
90 |
147 |
148 |
149 |
150 | );
151 | }
152 |
--------------------------------------------------------------------------------
/packages/backend/convex/_generated/server.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated utilities for implementing server-side Convex query and mutation functions.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * To regenerate, run `npx convex dev`.
8 | * @module
9 | */
10 |
11 | import {
12 | ActionBuilder,
13 | AnyComponents,
14 | HttpActionBuilder,
15 | MutationBuilder,
16 | QueryBuilder,
17 | GenericActionCtx,
18 | GenericMutationCtx,
19 | GenericQueryCtx,
20 | GenericDatabaseReader,
21 | GenericDatabaseWriter,
22 | FunctionReference,
23 | } from "convex/server";
24 | import type { DataModel } from "./dataModel.js";
25 |
26 | type GenericCtx =
27 | | GenericActionCtx
28 | | GenericMutationCtx
29 | | GenericQueryCtx;
30 |
31 | /**
32 | * Define a query in this Convex app's public API.
33 | *
34 | * This function will be allowed to read your Convex database and will be accessible from the client.
35 | *
36 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
37 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
38 | */
39 | export declare const query: QueryBuilder;
40 |
41 | /**
42 | * Define a query that is only accessible from other Convex functions (but not from the client).
43 | *
44 | * This function will be allowed to read from your Convex database. It will not be accessible from the client.
45 | *
46 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
47 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
48 | */
49 | export declare const internalQuery: QueryBuilder;
50 |
51 | /**
52 | * Define a mutation in this Convex app's public API.
53 | *
54 | * This function will be allowed to modify your Convex database and will be accessible from the client.
55 | *
56 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
57 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
58 | */
59 | export declare const mutation: MutationBuilder;
60 |
61 | /**
62 | * Define a mutation that is only accessible from other Convex functions (but not from the client).
63 | *
64 | * This function will be allowed to modify your Convex database. It will not be accessible from the client.
65 | *
66 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
67 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
68 | */
69 | export declare const internalMutation: MutationBuilder;
70 |
71 | /**
72 | * Define an action in this Convex app's public API.
73 | *
74 | * An action is a function which can execute any JavaScript code, including non-deterministic
75 | * code and code with side-effects, like calling third-party services.
76 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
77 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
78 | *
79 | * @param func - The action. It receives an {@link ActionCtx} as its first argument.
80 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
81 | */
82 | export declare const action: ActionBuilder;
83 |
84 | /**
85 | * Define an action that is only accessible from other Convex functions (but not from the client).
86 | *
87 | * @param func - The function. It receives an {@link ActionCtx} as its first argument.
88 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
89 | */
90 | export declare const internalAction: ActionBuilder;
91 |
92 | /**
93 | * Define an HTTP action.
94 | *
95 | * This function will be used to respond to HTTP requests received by a Convex
96 | * deployment if the requests matches the path and method where this action
97 | * is routed. Be sure to route your action in `convex/http.js`.
98 | *
99 | * @param func - The function. It receives an {@link ActionCtx} as its first argument.
100 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
101 | */
102 | export declare const httpAction: HttpActionBuilder;
103 |
104 | /**
105 | * A set of services for use within Convex query functions.
106 | *
107 | * The query context is passed as the first argument to any Convex query
108 | * function run on the server.
109 | *
110 | * This differs from the {@link MutationCtx} because all of the services are
111 | * read-only.
112 | */
113 | export type QueryCtx = GenericQueryCtx;
114 |
115 | /**
116 | * A set of services for use within Convex mutation functions.
117 | *
118 | * The mutation context is passed as the first argument to any Convex mutation
119 | * function run on the server.
120 | */
121 | export type MutationCtx = GenericMutationCtx;
122 |
123 | /**
124 | * A set of services for use within Convex action functions.
125 | *
126 | * The action context is passed as the first argument to any Convex action
127 | * function run on the server.
128 | */
129 | export type ActionCtx = GenericActionCtx;
130 |
131 | /**
132 | * An interface to read from the database within Convex query functions.
133 | *
134 | * The two entry points are {@link DatabaseReader.get}, which fetches a single
135 | * document by its {@link Id}, or {@link DatabaseReader.query}, which starts
136 | * building a query.
137 | */
138 | export type DatabaseReader = GenericDatabaseReader;
139 |
140 | /**
141 | * An interface to read from and write to the database within Convex mutation
142 | * functions.
143 | *
144 | * Convex guarantees that all writes within a single mutation are
145 | * executed atomically, so you never have to worry about partial writes leaving
146 | * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
147 | * for the guarantees Convex provides your functions.
148 | */
149 | export type DatabaseWriter = GenericDatabaseWriter;
150 |
--------------------------------------------------------------------------------
/apps/web/components/auth/login-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { zodResolver } from "@hookform/resolvers/zod";
4 | import { authClient } from "@repo/backend/better-auth/client";
5 | import { Button } from "@repo/ui/components/button";
6 | import {
7 | Card,
8 | CardContent,
9 | CardDescription,
10 | CardHeader,
11 | CardTitle,
12 | } from "@repo/ui/components/card";
13 | import { Input } from "@repo/ui/components/input";
14 | import { Label } from "@repo/ui/components/label";
15 | import { cn } from "@repo/ui/lib/utils";
16 | import Link from "next/link";
17 | import { useRouter } from "next/navigation";
18 | import { useState } from "react";
19 | import { useForm } from "react-hook-form";
20 | import { toast } from "sonner";
21 | import { z } from "zod";
22 |
23 | const loginSchema = z.object({
24 | email: z.string().email("Please enter a valid email address"),
25 | password: z.string().min(8, "Password must be at least 8 characters"),
26 | });
27 |
28 | type LoginFormData = z.infer;
29 |
30 | export function LoginForm({
31 | className,
32 | ...props
33 | }: React.ComponentProps<"div">) {
34 | const [isLoading, setIsLoading] = useState(false);
35 | const router = useRouter();
36 |
37 | const {
38 | register,
39 | handleSubmit,
40 | formState: { errors },
41 | } = useForm({
42 | resolver: zodResolver(loginSchema),
43 | });
44 |
45 | const onSubmit = async (data: LoginFormData) => {
46 | setIsLoading(true);
47 |
48 | try {
49 | const { data: authData, error } = await authClient.signIn.email({
50 | email: data.email,
51 | password: data.password,
52 | });
53 |
54 | if (error) {
55 | toast.error("Sign in failed", {
56 | description:
57 | error.message || "Please check your credentials and try again.",
58 | });
59 | return;
60 | }
61 |
62 | if (authData) {
63 | toast.success("Welcome back!", {
64 | description: "You have been successfully signed in.",
65 | });
66 |
67 | router.push("/");
68 | }
69 | } catch {
70 | toast.error("Something went wrong", {
71 | description: "An unexpected error occurred. Please try again.",
72 | });
73 | } finally {
74 | setIsLoading(false);
75 | }
76 | };
77 |
78 | const handleSocialLogin = async (provider: "github" | "google") => {
79 | setIsLoading(true);
80 |
81 | try {
82 | await authClient.signIn.social({
83 | provider: provider,
84 | });
85 | } catch {
86 | toast.error("Social login failed", {
87 | description: "Please try again or use email/password.",
88 | });
89 | } finally {
90 | setIsLoading(false);
91 | }
92 | };
93 |
94 | return (
95 |
96 |
97 |
98 | Welcome back
99 |
100 | Login with your GitHub or Google account
101 |
102 |
103 |
104 |
201 |
202 |
203 |
204 | );
205 | }
206 |
--------------------------------------------------------------------------------
/.cursor/rules/convex_rules.md:
--------------------------------------------------------------------------------
1 | ---
2 | description: Guidelines and best practices for building Convex projects, including database schema design, queries, mutations, and real-world examples
3 | globs: **/*.ts,**/*.tsx,**/*.js,**/*.jsx
4 | ---
5 |
6 | # Convex guidelines
7 |
8 | ## Function guidelines
9 |
10 | ### New function syntax
11 |
12 | - ALWAYS use the new function syntax for Convex functions. For example:
13 |
14 | ```typescript
15 | import { query } from "./_generated/server";
16 | import { v } from "convex/values";
17 | export const f = query({
18 | args: {},
19 | returns: v.null(),
20 | handler: async (ctx, args) => {
21 | // Function body
22 | },
23 | });
24 | ```
25 |
26 | ### Http endpoint syntax
27 |
28 | - HTTP endpoints are defined in `convex/http.ts` and require an `httpAction` decorator. For example:
29 |
30 | ```typescript
31 | import { httpRouter } from "convex/server";
32 | import { httpAction } from "./_generated/server";
33 | const http = httpRouter();
34 | http.route({
35 | path: "/echo",
36 | method: "POST",
37 | handler: httpAction(async (ctx, req) => {
38 | const body = await req.bytes();
39 | return new Response(body, { status: 200 });
40 | }),
41 | });
42 | ```
43 |
44 | - HTTP endpoints are always registered at the exact path you specify in the `path` field. For example, if you specify `/api/someRoute`, the endpoint will be registered at `/api/someRoute`.
45 |
46 | ### Validators
47 |
48 | - Below is an example of an array validator:
49 |
50 | ```typescript
51 | import { mutation } from "./_generated/server";
52 | import { v } from "convex/values";
53 |
54 | export default mutation({
55 | args: {
56 | simpleArray: v.array(v.union(v.string(), v.number())),
57 | },
58 | handler: async (ctx, args) => {
59 | //...
60 | },
61 | });
62 | ```
63 |
64 | - Below is an example of a schema with validators that codify a discriminated union type:
65 |
66 | ```typescript
67 | import { defineSchema, defineTable } from "convex/server";
68 | import { v } from "convex/values";
69 |
70 | export default defineSchema({
71 | results: defineTable(
72 | v.union(
73 | v.object({
74 | kind: v.literal("error"),
75 | errorMessage: v.string(),
76 | }),
77 | v.object({
78 | kind: v.literal("success"),
79 | value: v.number(),
80 | })
81 | )
82 | ),
83 | });
84 | ```
85 |
86 | - Always use the `v.null()` validator when returning a null value. Below is an example query that returns a null value:
87 |
88 | ```typescript
89 | import { query } from "./_generated/server";
90 | import { v } from "convex/values";
91 |
92 | export const exampleQuery = query({
93 | args: {},
94 | returns: v.null(),
95 | handler: async (ctx, args) => {
96 | console.log("This query returns a null value");
97 | return null;
98 | },
99 | });
100 | ```
101 |
102 | - Here are the valid Convex types along with their respective validators:
103 | Convex Type | TS/JS type | Example Usage | Validator for argument validation and schemas | Notes |
104 | | ----------- | ------------| -----------------------| -----------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
105 | | Id | string | `doc._id` | `v.id(tableName)` | |
106 | | Null | null | `null` | `v.null()` | JavaScript's `undefined` is not a valid Convex value. Functions the return `undefined` or do not return will return `null` when called from a client. Use `null` instead. |
107 | | Int64 | bigint | `3n` | `v.int64()` | Int64s only support BigInts between -2^63 and 2^63-1. Convex supports `bigint`s in most modern browsers. |
108 | | Float64 | number | `3.1` | `v.number()` | Convex supports all IEEE-754 double-precision floating point numbers (such as NaNs). Inf and NaN are JSON serialized as strings. |
109 | | Boolean | boolean | `true` | `v.boolean()` |
110 | | String | string | `"abc"` | `v.string()` | Strings are stored as UTF-8 and must be valid Unicode sequences. Strings must be smaller than the 1MB total size limit when encoded as UTF-8. |
111 | | Bytes | ArrayBuffer | `new ArrayBuffer(8)` | `v.bytes()` | Convex supports first class bytestrings, passed in as `ArrayBuffer`s. Bytestrings must be smaller than the 1MB total size limit for Convex types. |
112 | | Array | Array] | `[1, 3.2, "abc"]` | `v.array(values)` | Arrays can have at most 8192 values. |
113 | | Object | Object | `{a: "abc"}` | `v.object({property: value})` | Convex only supports "plain old JavaScript objects" (objects that do not have a custom prototype). Objects can have at most 1024 entries. Field names must be nonempty and not start with "$" or "_". |
114 | | Record | Record | `{"a": "1", "b": "2"}` | `v.record(keys, values)` | Records are objects at runtime, but can have dynamic keys. Keys must be only ASCII characters, nonempty, and not start with "$" or "\_". |
115 |
116 | ### Function registration
117 |
118 | - Use `internalQuery`, `internalMutation`, and `internalAction` to register internal functions. These functions are private and aren't part of an app's API. They can only be called by other Convex functions. These functions are always imported from `./_generated/server`.
119 | - Use `query`, `mutation`, and `action` to register public functions. These functions are part of the public API and are exposed to the public Internet. Do NOT use `query`, `mutation`, or `action` to register sensitive internal functions that should be kept private.
120 | - You CANNOT register a function through the `api` or `internal` objects.
121 | - ALWAYS include argument and return validators for all Convex functions. This includes all of `query`, `internalQuery`, `mutation`, `internalMutation`, `action`, and `internalAction`. If a function doesn't return anything, include `returns: v.null()` as its output validator.
122 | - If the JavaScript implementation of a Convex function doesn't have a return value, it implicitly returns `null`.
123 |
124 | ### Function calling
125 |
126 | - Use `ctx.runQuery` to call a query from a query, mutation, or action.
127 | - Use `ctx.runMutation` to call a mutation from a mutation or action.
128 | - Use `ctx.runAction` to call an action from an action.
129 | - ONLY call an action from another action if you need to cross runtimes (e.g. from V8 to Node). Otherwise, pull out the shared code into a helper async function and call that directly instead.
130 | - Try to use as few calls from actions to queries and mutations as possible. Queries and mutations are transactions, so splitting logic up into multiple calls introduces the risk of race conditions.
131 | - All of these calls take in a `FunctionReference`. Do NOT try to pass the callee function directly into one of these calls.
132 | - When using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction` to call a function in the same file, specify a type annotation on the return value to work around TypeScript circularity limitations. For example,
133 |
134 | ```
135 | export const f = query({
136 | args: { name: v.string() },
137 | returns: v.string(),
138 | handler: async (ctx, args) => {
139 | return "Hello " + args.name;
140 | },
141 | });
142 |
143 | export const g = query({
144 | args: {},
145 | returns: v.null(),
146 | handler: async (ctx, args) => {
147 | const result: string = await ctx.runQuery(api.example.f, { name: "Bob" });
148 | return null;
149 | },
150 | });
151 | ```
152 |
153 | ### Function references
154 |
155 | - Function references are pointers to registered Convex functions.
156 | - Use the `api` object defined by the framework in `convex/_generated/api.ts` to call public functions registered with `query`, `mutation`, or `action`.
157 | - Use the `internal` object defined by the framework in `convex/_generated/api.ts` to call internal (or private) functions registered with `internalQuery`, `internalMutation`, or `internalAction`.
158 | - Convex uses file-based routing, so a public function defined in `convex/example.ts` named `f` has a function reference of `api.example.f`.
159 | - A private function defined in `convex/example.ts` named `g` has a function reference of `internal.example.g`.
160 | - Functions can also registered within directories nested within the `convex/` folder. For example, a public function `h` defined in `convex/messages/access.ts` has a function reference of `api.messages.access.h`.
161 |
162 | ### Api design
163 |
164 | - Convex uses file-based routing, so thoughtfully organize files with public query, mutation, or action functions within the `convex/` directory.
165 | - Use `query`, `mutation`, and `action` to define public functions.
166 | - Use `internalQuery`, `internalMutation`, and `internalAction` to define private, internal functions.
167 |
168 | ### Pagination
169 |
170 | - Paginated queries are queries that return a list of results in incremental pages.
171 | - You can define pagination using the following syntax:
172 |
173 | ```ts
174 | import { v } from "convex/values";
175 | import { query, mutation } from "./_generated/server";
176 | import { paginationOptsValidator } from "convex/server";
177 | export const listWithExtraArg = query({
178 | args: { paginationOpts: paginationOptsValidator, author: v.string() },
179 | handler: async (ctx, args) => {
180 | return await ctx.db
181 | .query("messages")
182 | .filter((q) => q.eq(q.field("author"), args.author))
183 | .order("desc")
184 | .paginate(args.paginationOpts);
185 | },
186 | });
187 | ```
188 |
189 | Note: `paginationOpts` is an object with the following properties:
190 |
191 | - `numItems`: the maximum number of documents to return (the validator is `v.number()`)
192 | - `cursor`: the cursor to use to fetch the next page of documents (the validator is `v.union(v.string(), v.null())`)
193 | - A query that ends in `.paginate()` returns an object that has the following properties: - page (contains an array of documents that you fetches) - isDone (a boolean that represents whether or not this is the last page of documents) - continueCursor (a string that represents the cursor to use to fetch the next page of documents)
194 |
195 | ## Validator guidelines
196 |
197 | - `v.bigint()` is deprecated for representing signed 64-bit integers. Use `v.int64()` instead.
198 | - Use `v.record()` for defining a record type. `v.map()` and `v.set()` are not supported.
199 |
200 | ## Schema guidelines
201 |
202 | - Always define your schema in `convex/schema.ts`.
203 | - Always import the schema definition functions from `convex/server`:
204 | - System fields are automatically added to all documents and are prefixed with an underscore. The two system fields that are automatically added to all documents are `_creationTime` which has the validator `v.number()` and `_id` which has the validator `v.id(tableName)`.
205 | - Always include all index fields in the index name. For example, if an index is defined as `["field1", "field2"]`, the index name should be "by_field1_and_field2".
206 | - Index fields must be queried in the same order they are defined. If you want to be able to query by "field1" then "field2" and by "field2" then "field1", you must create separate indexes.
207 |
208 | ## Typescript guidelines
209 |
210 | - You can use the helper typescript type `Id` imported from './\_generated/dataModel' to get the type of the id for a given table. For example if there is a table called 'users' you can use `Id<'users'>` to get the type of the id for that table.
211 | - If you need to define a `Record` make sure that you correctly provide the type of the key and value in the type. For example a validator `v.record(v.id('users'), v.string())` would have the type `Record, string>`. Below is an example of using `Record` with an `Id` type in a query:
212 |
213 | ```ts
214 | import { query } from "./_generated/server";
215 | import { Doc, Id } from "./_generated/dataModel";
216 |
217 | export const exampleQuery = query({
218 | args: { userIds: v.array(v.id("users")) },
219 | returns: v.record(v.id("users"), v.string()),
220 | handler: async (ctx, args) => {
221 | const idToUsername: Record, string> = {};
222 | for (const userId of args.userIds) {
223 | const user = await ctx.db.get(userId);
224 | if (user) {
225 | users[user._id] = user.username;
226 | }
227 | }
228 |
229 | return idToUsername;
230 | },
231 | });
232 | ```
233 |
234 | - Be strict with types, particularly around id's of documents. For example, if a function takes in an id for a document in the 'users' table, take in `Id<'users'>` rather than `string`.
235 | - Always use `as const` for string literals in discriminated union types.
236 | - When using the `Array` type, make sure to always define your arrays as `const array: Array = [...];`
237 | - When using the `Record` type, make sure to always define your records as `const record: Record = {...};`
238 | - Always add `@types/node` to your `package.json` when using any Node.js built-in modules.
239 |
240 | ## Full text search guidelines
241 |
242 | - A query for "10 messages in channel '#general' that best match the query 'hello hi' in their body" would look like:
243 |
244 | const messages = await ctx.db
245 | .query("messages")
246 | .withSearchIndex("search_body", (q) =>
247 | q.search("body", "hello hi").eq("channel", "#general"),
248 | )
249 | .take(10);
250 |
251 | ## Query guidelines
252 |
253 | - Do NOT use `filter` in queries. Instead, define an index in the schema and use `withIndex` instead.
254 | - Convex queries do NOT support `.delete()`. Instead, `.collect()` the results, iterate over them, and call `ctx.db.delete(row._id)` on each result.
255 | - Use `.unique()` to get a single document from a query. This method will throw an error if there are multiple documents that match the query.
256 | - When using async iteration, don't use `.collect()` or `.take(n)` on the result of a query. Instead, use the `for await (const row of query)` syntax.
257 |
258 | ### Ordering
259 |
260 | - By default Convex always returns documents in ascending `_creationTime` order.
261 | - You can use `.order('asc')` or `.order('desc')` to pick whether a query is in ascending or descending order. If the order isn't specified, it defaults to ascending.
262 | - Document queries that use indexes will be ordered based on the columns in the index and can avoid slow table scans.
263 |
264 | ## Mutation guidelines
265 |
266 | - Use `ctx.db.replace` to fully replace an existing document. This method will throw an error if the document does not exist.
267 | - Use `ctx.db.patch` to shallow merge updates into an existing document. This method will throw an error if the document does not exist.
268 |
269 | ## Action guidelines
270 |
271 | - Always add `"use node";` to the top of files containing actions that use Node.js built-in modules.
272 | - Never use `ctx.db` inside of an action. Actions don't have access to the database.
273 | - Below is an example of the syntax for an action:
274 |
275 | ```ts
276 | import { action } from "./_generated/server";
277 |
278 | export const exampleAction = action({
279 | args: {},
280 | returns: v.null(),
281 | handler: async (ctx, args) => {
282 | console.log("This action does not return anything");
283 | return null;
284 | },
285 | });
286 | ```
287 |
288 | ## Scheduling guidelines
289 |
290 | ### Cron guidelines
291 |
292 | - Only use the `crons.interval` or `crons.cron` methods to schedule cron jobs. Do NOT use the `crons.hourly`, `crons.daily`, or `crons.weekly` helpers.
293 | - Both cron methods take in a FunctionReference. Do NOT try to pass the function directly into one of these methods.
294 | - Define crons by declaring the top-level `crons` object, calling some methods on it, and then exporting it as default. For example,
295 |
296 | ```ts
297 | import { cronJobs } from "convex/server";
298 | import { internal } from "./_generated/api";
299 | import { internalAction } from "./_generated/server";
300 |
301 | const empty = internalAction({
302 | args: {},
303 | returns: v.null(),
304 | handler: async (ctx, args) => {
305 | console.log("empty");
306 | },
307 | });
308 |
309 | const crons = cronJobs();
310 |
311 | // Run `internal.crons.empty` every two hours.
312 | crons.interval("delete inactive users", { hours: 2 }, internal.crons.empty, {});
313 |
314 | export default crons;
315 | ```
316 |
317 | - You can register Convex functions within `crons.ts` just like any other file.
318 | - If a cron calls an internal function, always import the `internal` object from '\_generated/api', even if the internal function is registered in the same file.
319 |
320 | ## File storage guidelines
321 |
322 | - Convex includes file storage for large files like images, videos, and PDFs.
323 | - The `ctx.storage.getUrl()` method returns a signed URL for a given file. It returns `null` if the file doesn't exist.
324 | - Do NOT use the deprecated `ctx.storage.getMetadata` call for loading a file's metadata.
325 |
326 | Instead, query the `_storage` system table. For example, you can use `ctx.db.system.get` to get an `Id<"_storage">`.
327 |
328 | ```
329 | import { query } from "./_generated/server";
330 | import { Id } from "./_generated/dataModel";
331 |
332 | type FileMetadata = {
333 | _id: Id<"_storage">;
334 | _creationTime: number;
335 | contentType?: string;
336 | sha256: string;
337 | size: number;
338 | }
339 |
340 | export const exampleQuery = query({
341 | args: { fileId: v.id("_storage") },
342 | returns: v.null();
343 | handler: async (ctx, args) => {
344 | const metadata: FileMetadata | null = await ctx.db.system.get(args.fileId);
345 | console.log(metadata);
346 | return null;
347 | },
348 | });
349 | ```
350 |
351 | - Convex storage stores items as `Blob` objects. You must convert all items to/from a `Blob` when using Convex storage.
352 |
353 | # Examples:
354 |
355 | ## Example: chat-app
356 |
357 | ### Task
358 |
359 | ```
360 | Create a real-time chat application backend with AI responses. The app should:
361 | - Allow creating users with names
362 | - Support multiple chat channels
363 | - Enable users to send messages to channels
364 | - Automatically generate AI responses to user messages
365 | - Show recent message history
366 |
367 | The backend should provide APIs for:
368 | 1. User management (creation)
369 | 2. Channel management (creation)
370 | 3. Message operations (sending, listing)
371 | 4. AI response generation using OpenAI's GPT-4
372 |
373 | Messages should be stored with their channel, author, and content. The system should maintain message order
374 | and limit history display to the 10 most recent messages per channel.
375 |
376 | ```
377 |
378 | ### Analysis
379 |
380 | 1. Task Requirements Summary:
381 |
382 | - Build a real-time chat backend with AI integration
383 | - Support user creation
384 | - Enable channel-based conversations
385 | - Store and retrieve messages with proper ordering
386 | - Generate AI responses automatically
387 |
388 | 2. Main Components Needed:
389 |
390 | - Database tables: users, channels, messages
391 | - Public APIs for user/channel management
392 | - Message handling functions
393 | - Internal AI response generation system
394 | - Context loading for AI responses
395 |
396 | 3. Public API and Internal Functions Design:
397 | Public Mutations:
398 |
399 | - createUser:
400 | - file path: convex/index.ts
401 | - arguments: {name: v.string()}
402 | - returns: v.object({userId: v.id("users")})
403 | - purpose: Create a new user with a given name
404 | - createChannel:
405 | - file path: convex/index.ts
406 | - arguments: {name: v.string()}
407 | - returns: v.object({channelId: v.id("channels")})
408 | - purpose: Create a new channel with a given name
409 | - sendMessage:
410 | - file path: convex/index.ts
411 | - arguments: {channelId: v.id("channels"), authorId: v.id("users"), content: v.string()}
412 | - returns: v.null()
413 | - purpose: Send a message to a channel and schedule a response from the AI
414 |
415 | Public Queries:
416 |
417 | - listMessages:
418 | - file path: convex/index.ts
419 | - arguments: {channelId: v.id("channels")}
420 | - returns: v.array(v.object({
421 | \_id: v.id("messages"),
422 | \_creationTime: v.number(),
423 | channelId: v.id("channels"),
424 | authorId: v.optional(v.id("users")),
425 | content: v.string(),
426 | }))
427 | - purpose: List the 10 most recent messages from a channel in descending creation order
428 |
429 | Internal Functions:
430 |
431 | - generateResponse:
432 | - file path: convex/index.ts
433 | - arguments: {channelId: v.id("channels")}
434 | - returns: v.null()
435 | - purpose: Generate a response from the AI for a given channel
436 | - loadContext:
437 | - file path: convex/index.ts
438 | - arguments: {channelId: v.id("channels")}
439 | - returns: v.array(v.object({
440 | \_id: v.id("messages"),
441 | \_creationTime: v.number(),
442 | channelId: v.id("channels"),
443 | authorId: v.optional(v.id("users")),
444 | content: v.string(),
445 | }))
446 | - writeAgentResponse:
447 | - file path: convex/index.ts
448 | - arguments: {channelId: v.id("channels"), content: v.string()}
449 | - returns: v.null()
450 | - purpose: Write an AI response to a given channel
451 |
452 | 4. Schema Design:
453 |
454 | - users
455 | - validator: { name: v.string() }
456 | - indexes:
457 | - channels
458 | - validator: { name: v.string() }
459 | - indexes:
460 | - messages
461 | - validator: { channelId: v.id("channels"), authorId: v.optional(v.id("users")), content: v.string() }
462 | - indexes
463 | - by_channel: ["channelId"]
464 |
465 | 5. Background Processing:
466 |
467 | - AI response generation runs asynchronously after each user message
468 | - Uses OpenAI's GPT-4 to generate contextual responses
469 | - Maintains conversation context using recent message history
470 |
471 | ### Implementation
472 |
473 | #### package.json
474 |
475 | ```typescript
476 | {
477 | "name": "chat-app",
478 | "description": "This example shows how to build a chat app without authentication.",
479 | "version": "1.0.0",
480 | "dependencies": {
481 | "convex": "^1.17.4",
482 | "openai": "^4.79.0"
483 | },
484 | "devDependencies": {
485 | "typescript": "^5.7.3"
486 | }
487 | }
488 | ```
489 |
490 | #### tsconfig.json
491 |
492 | ```typescript
493 | {
494 | "compilerOptions": {
495 | "target": "ESNext",
496 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
497 | "skipLibCheck": true,
498 | "allowSyntheticDefaultImports": true,
499 | "strict": true,
500 | "forceConsistentCasingInFileNames": true,
501 | "module": "ESNext",
502 | "moduleResolution": "Bundler",
503 | "resolveJsonModule": true,
504 | "isolatedModules": true,
505 | "allowImportingTsExtensions": true,
506 | "noEmit": true,
507 | "jsx": "react-jsx"
508 | },
509 | "exclude": ["convex"],
510 | "include": ["**/src/**/*.tsx", "**/src/**/*.ts", "vite.config.ts"]
511 | }
512 | ```
513 |
514 | #### convex/index.ts
515 |
516 | ```typescript
517 | import {
518 | query,
519 | mutation,
520 | internalQuery,
521 | internalMutation,
522 | internalAction,
523 | } from "./_generated/server";
524 | import { v } from "convex/values";
525 | import OpenAI from "openai";
526 | import { internal } from "./_generated/api";
527 |
528 | /**
529 | * Create a user with a given name.
530 | */
531 | export const createUser = mutation({
532 | args: {
533 | name: v.string(),
534 | },
535 | returns: v.id("users"),
536 | handler: async (ctx, args) => {
537 | return await ctx.db.insert("users", { name: args.name });
538 | },
539 | });
540 |
541 | /**
542 | * Create a channel with a given name.
543 | */
544 | export const createChannel = mutation({
545 | args: {
546 | name: v.string(),
547 | },
548 | returns: v.id("channels"),
549 | handler: async (ctx, args) => {
550 | return await ctx.db.insert("channels", { name: args.name });
551 | },
552 | });
553 |
554 | /**
555 | * List the 10 most recent messages from a channel in descending creation order.
556 | */
557 | export const listMessages = query({
558 | args: {
559 | channelId: v.id("channels"),
560 | },
561 | returns: v.array(
562 | v.object({
563 | _id: v.id("messages"),
564 | _creationTime: v.number(),
565 | channelId: v.id("channels"),
566 | authorId: v.optional(v.id("users")),
567 | content: v.string(),
568 | })
569 | ),
570 | handler: async (ctx, args) => {
571 | const messages = await ctx.db
572 | .query("messages")
573 | .withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
574 | .order("desc")
575 | .take(10);
576 | return messages;
577 | },
578 | });
579 |
580 | /**
581 | * Send a message to a channel and schedule a response from the AI.
582 | */
583 | export const sendMessage = mutation({
584 | args: {
585 | channelId: v.id("channels"),
586 | authorId: v.id("users"),
587 | content: v.string(),
588 | },
589 | returns: v.null(),
590 | handler: async (ctx, args) => {
591 | const channel = await ctx.db.get(args.channelId);
592 | if (!channel) {
593 | throw new Error("Channel not found");
594 | }
595 | const user = await ctx.db.get(args.authorId);
596 | if (!user) {
597 | throw new Error("User not found");
598 | }
599 | await ctx.db.insert("messages", {
600 | channelId: args.channelId,
601 | authorId: args.authorId,
602 | content: args.content,
603 | });
604 | await ctx.scheduler.runAfter(0, internal.index.generateResponse, {
605 | channelId: args.channelId,
606 | });
607 | return null;
608 | },
609 | });
610 |
611 | const openai = new OpenAI();
612 |
613 | export const generateResponse = internalAction({
614 | args: {
615 | channelId: v.id("channels"),
616 | },
617 | returns: v.null(),
618 | handler: async (ctx, args) => {
619 | const context = await ctx.runQuery(internal.index.loadContext, {
620 | channelId: args.channelId,
621 | });
622 | const response = await openai.chat.completions.create({
623 | model: "gpt-4o",
624 | messages: context,
625 | });
626 | const content = response.choices[0].message.content;
627 | if (!content) {
628 | throw new Error("No content in response");
629 | }
630 | await ctx.runMutation(internal.index.writeAgentResponse, {
631 | channelId: args.channelId,
632 | content,
633 | });
634 | return null;
635 | },
636 | });
637 |
638 | export const loadContext = internalQuery({
639 | args: {
640 | channelId: v.id("channels"),
641 | },
642 | returns: v.array(
643 | v.object({
644 | role: v.union(v.literal("user"), v.literal("assistant")),
645 | content: v.string(),
646 | })
647 | ),
648 | handler: async (ctx, args) => {
649 | const channel = await ctx.db.get(args.channelId);
650 | if (!channel) {
651 | throw new Error("Channel not found");
652 | }
653 | const messages = await ctx.db
654 | .query("messages")
655 | .withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
656 | .order("desc")
657 | .take(10);
658 |
659 | const result = [];
660 | for (const message of messages) {
661 | if (message.authorId) {
662 | const user = await ctx.db.get(message.authorId);
663 | if (!user) {
664 | throw new Error("User not found");
665 | }
666 | result.push({
667 | role: "user" as const,
668 | content: `${user.name}: ${message.content}`,
669 | });
670 | } else {
671 | result.push({ role: "assistant" as const, content: message.content });
672 | }
673 | }
674 | return result;
675 | },
676 | });
677 |
678 | export const writeAgentResponse = internalMutation({
679 | args: {
680 | channelId: v.id("channels"),
681 | content: v.string(),
682 | },
683 | returns: v.null(),
684 | handler: async (ctx, args) => {
685 | await ctx.db.insert("messages", {
686 | channelId: args.channelId,
687 | content: args.content,
688 | });
689 | return null;
690 | },
691 | });
692 | ```
693 |
694 | #### convex/schema.ts
695 |
696 | ```typescript
697 | import { defineSchema, defineTable } from "convex/server";
698 | import { v } from "convex/values";
699 |
700 | export default defineSchema({
701 | channels: defineTable({
702 | name: v.string(),
703 | }),
704 |
705 | users: defineTable({
706 | name: v.string(),
707 | }),
708 |
709 | messages: defineTable({
710 | channelId: v.id("channels"),
711 | authorId: v.optional(v.id("users")),
712 | content: v.string(),
713 | }).index("by_channel", ["channelId"]),
714 | });
715 | ```
716 |
717 | #### src/App.tsx
718 |
719 | ```typescript
720 | export default function App() {
721 | return