Files
convex-monorepo/AGENTS.md

59 KiB
Raw Blame History

AGENTS.md — Convex Turbo Monorepo

This is the definitive onboarding guide for AI agents (and humans) working in this repository. Read it fully before making any changes. It covers architecture, patterns, constraints, and known issues — everything you need to work effectively without breaking things or introducing inconsistencies.


Table of Contents

  1. What This Repo Is
  2. Critical Rules — Read First
  3. Monorepo Architecture Overview
  4. Self-Hosted Stack & Runtime Requirements
  5. Environment Variables — Complete Reference
  6. Dependency Management — The Catalog System
  7. Convex Backend Deep Dive
  8. Next.js App Deep Dive
  9. UI Package (@gib/ui)
  10. Tools — Shared Configuration Packages
  11. Docker & Deployment
  12. Code Style Reference
  13. Adding New Features — Checklists
  14. Expo App — Known Issues, Do Not Touch
  15. Known Issues & Technical Debt
  16. Quick Command Reference

1. What This Repo Is

This is a personal full-stack monorepo template for spinning up self-hosted web applications quickly. The core idea: clone the repo, configure the environment variables, stand up two Docker containers for Convex, reverse-proxy them through nginx-proxy-manager, and you have a production-ready Next.js website with:

  • Self-hosted Convex as the backend/database (not Convex Cloud)
  • Convex Auth with two providers: Authentik (OAuth SSO) and a custom Password provider with email OTP via a self-hosted UseSend instance
  • Sentry for error tracking and performance monitoring
  • Plausible for privacy-focused analytics
  • A stubbed Expo app that is present for future mobile work but is not the focus

The primary deliverable from this template is the Next.js web app. Every project built from this template has been entirely focused on apps/next/ and packages/backend/. The Expo app exists structurally but is non-functional and should be ignored unless explicitly asked to work on it.

When working in this repo as an AI agent, you are almost certainly working on an existing running instance of this template — the Convex backend is already deployed, auth is already configured, and your job is to build features on top of the established foundation.


2. Critical Rules — Read First

These are hard constraints. Violating them will break things that are intentionally configured and difficult to re-stabilize.

Never do these without explicit instruction:

  • NEVER modify anything inside packages/backend/convex/_generated/ — these files are auto-generated by the Convex CLI and will be overwritten. Read them for type information, never edit them.

  • NEVER rename apps/next/next.config.js to next.config.ts — the config uses jiti to import ./src/env.ts (a TypeScript file) at build time. This pattern only works when the config itself is a .js file. Renaming it to .ts will break the build.

  • NEVER rename apps/next/src/proxy.ts to middleware.tsproxy.ts is the correct and intentional name for the Next.js route handler file in Next.js 16+. This was an official rename from the Next.js team to reduce confusion. The file works exactly as middleware did; it's just named differently now.

  • NEVER modify the Sentry configuration files without explicit instruction — these took significant effort to get working correctly with the standalone Docker build, Plausible proxy, and self-hosted Sentry. The files to leave alone are:

    • apps/next/next.config.js
    • apps/next/src/instrumentation.ts
    • apps/next/src/instrumentation-client.ts
    • apps/next/src/sentry.server.config.ts
  • NEVER remove typescript.ignoreBuildErrors: true from next.config.js — some shadcn/ui components produce spurious TypeScript build errors. This flag keeps the build working. It's intentional and should stay until explicitly removed.

  • NEVER modify tools/tailwind/theme.css without explicit instruction — this file contains the entire OKLCH-based design system. When a theme change is needed, the file is replaced wholesale with a new theme generated from a shadcn theme generator. Don't tweak individual values without being asked.

  • NEVER touch packages/backend/convex/http.ts — this file only calls auth.addHttpRoutes(http) and must stay exactly as it is for Convex Auth to work.

Always do these:

  • ALWAYS use bun typecheck to validate your work, never bun build. Typecheck is fast and sufficient. Build is slow and for production deployments only.

  • ALWAYS use const arrow functions over function declarations — this is a strong style preference throughout the codebase:

    // ✅ Correct
    const handleSubmit = async (data: FormData) => { ... };
    const MyComponent = () => { ... };
    
    // ❌ Wrong
    function handleSubmit(data: FormData) { ... }
    function MyComponent() { ... }
    

    Note: exported Convex functions (query, mutation, action) are already defined using the Convex builder pattern and are fine as-is.

  • ALWAYS import env vars from @/env, never from process.env directly. The ESLint restrictEnvAccess rule will error if you use process.env anywhere outside of apps/next/src/env.ts.

  • ALWAYS add .js extensions when importing from Convex-generated files:

    // ✅ Correct
    import type { Id } from '@gib/backend/convex/_generated/dataModel.js';
    // ❌ Wrong — will fail at runtime
    import { api } from '@gib/backend/convex/_generated/api';
    import { api } from '@gib/backend/convex/_generated/api.js';
    

    The project uses "type": "module" in all package.json files, which requires explicit file extensions for ESM imports. The .js extension is required even though the source files are .ts.


3. Monorepo Architecture Overview

This is a Turborepo monorepo with three top-level categories of packages:

convex-monorepo/
├── apps/
│   ├── next/           # @gib/next — Next.js 16 web application
│   └── expo/           # @gib/expo — Expo 54 mobile app (WIP, broken)
├── packages/
│   ├── backend/        # @gib/backend — Self-hosted Convex backend
│   └── ui/             # @gib/ui — Shared shadcn/ui component library
├── tools/
│   ├── eslint/         # @gib/eslint-config — ESLint configuration
│   ├── prettier/       # @gib/prettier-config — Prettier + import sorting
│   ├── tailwind/       # @gib/tailwind-config — Tailwind v4 + theme
│   └── typescript/     # @gib/tsconfig — Shared TypeScript configs
├── docker/             # Docker Compose for self-hosted deployment
├── turbo/              # Turborepo generators (scaffolding)
├── turbo.json          # Turborepo pipeline configuration
├── package.json        # Root workspace + dependency catalogs
└── .env                # Central environment variables (gitignored)

How the pieces connect

The dependency graph flows like this:

@gib/tsconfig
  └── @gib/eslint-config
  └── @gib/tailwind-config
  └── @gib/prettier-config
        └── @gib/ui (components + shared styles)
        └── @gib/backend (Convex functions + types)
              └── @gib/next (consumes everything)

tools/ packages are pure configuration — they export ESLint rules, TypeScript compiler options, Tailwind PostCSS config, and Prettier options. Every app and package extends them; you should rarely need to modify them.

packages/ui contains every shadcn/ui component plus a few custom additions. Everything is re-exported from a single @gib/ui barrel — no package-specific deep imports needed.

packages/backend is the Convex backend. Only the convex/ subdirectory is synced to the Convex deployment. The scripts/ and types/ directories sit outside convex/ intentionally so they're not uploaded to Convex cloud.

Turborepo pipeline

turbo.json defines the task pipeline. Key points for agents:

  • globalEnv — all environment variables that affect builds must be listed here, or Turborepo won't include them in cache keys and cached outputs may be stale. Whenever you add a new env var, add it to globalEnv.
  • globalDependencies**/.env.*local is tracked, so changes to .env.local files invalidate the cache.
  • The build task runs ^build (dependencies first), then emits to dist/.
  • The dev task is not cached and not persistent (persistent: false means Turborepo considers it non-blocking).
  • typecheck and lint both depend on ^topo and ^build, meaning they wait for dependency packages to build first.

4. Self-Hosted Stack & Runtime Requirements

Before any development work can start

The Convex backend must be running and reachable. bun dev:next will fail or behave unexpectedly if it cannot connect to Convex. In practice, when you are working in this repo on an active project, the Convex containers are already running on the server and have been configured. If you are starting fresh from the template, see the README for the full setup walkthrough.

The two Convex-related Docker containers are:

  • convex-backend — the actual Convex runtime (default: https://api.convexmonorepo.gbrown.org)
  • convex-dashboard — the admin UI (default: https://dashboard.convexmonorepo.gbrown.org)

The packages/backend/.env.local file

You may notice a .env.local file in packages/backend/. This file is auto-generated by the convex dev command and is not something agents need to create or manage. It contains CONVEX_URL and CONVEX_SITE_URL values that Convex uses internally when running bun dev:backend. It is gitignored and can be safely ignored.

CONVEX_SITE_URL — a nuance worth understanding

CONVEX_SITE_URL is used by packages/backend/convex/auth.config.ts as the CORS domain that Convex Auth trusts for token exchange. For local development, this should always be http://localhost:3000 (the Next.js dev server). For production, this needs to match your public domain — but this value must be updated directly in the Convex Dashboard (environment variables section), not just in the root .env.

The value in .env is always http://localhost:3000 for dev. When you need Convex Auth to work on your production domain, go to the Convex Dashboard at https://dashboard.convexmonorepo.gbrown.org and update CONVEX_SITE_URL there.


5. Environment Variables — Complete Reference

The single source of truth: /.env

All environment variables live in the root .env file. Every app and package loads them via dotenv-cli using the with-env script pattern:

"with-env": "dotenv -e ../../.env --"

The root .env is gitignored. The root .env.example is the committed template that shows all required variable names without real values. Keep .env.example up to date whenever you add a new env var.

Complete variable reference

Variable Used By Purpose Sync to Convex?
NODE_ENV Next.js development / production No
SENTRY_AUTH_TOKEN Next.js build Source map upload to Sentry No
NEXT_PUBLIC_SITE_URL Next.js Public URL of the Next.js app No
NEXT_PUBLIC_CONVEX_URL Next.js Convex backend API URL No
NEXT_PUBLIC_PLAUSIBLE_URL Next.js Self-hosted Plausible instance URL No
NEXT_PUBLIC_SENTRY_DSN Next.js Sentry DSN for error reporting No
NEXT_PUBLIC_SENTRY_URL Next.js build Self-hosted Sentry URL No
NEXT_PUBLIC_SENTRY_ORG Next.js build Sentry organization slug No
NEXT_PUBLIC_SENTRY_PROJECT_NAME Next.js build Sentry project name No
CONVEX_SELF_HOSTED_URL Convex CLI URL of the self-hosted Convex backend No
CONVEX_SELF_HOSTED_ADMIN_KEY Convex CLI Admin key for the self-hosted backend No
CONVEX_SITE_URL Convex Auth CORS domain for token exchange (localhost:3000 for dev) Yes (Dashboard)
USESEND_API_KEY Convex backend API key for the self-hosted UseSend instance Yes
USESEND_URL Convex backend URL of the self-hosted UseSend instance Yes
USESEND_FROM_EMAIL Convex backend From address for transactional emails (e.g. App <noreply@example.com>) Yes
AUTH_AUTHENTIK_ID Convex backend Authentik OAuth client ID Yes
AUTH_AUTHENTIK_SECRET Convex backend Authentik OAuth client secret Yes
AUTH_AUTHENTIK_ISSUER Convex backend Authentik issuer URL Yes

Typesafe env vars — apps/next/src/env.ts

The Next.js app uses @t3-oss/env-nextjs with Zod to validate all environment variables at build time. This file is the contract between the .env file and the application code.

The ESLint restrictEnvAccess rule in @gib/eslint-config blocks direct process.env access everywhere except in env.ts. All app code must import the validated env object:

import { env } from '@/env';

// ✅ Correct — validated and typed
const url = env.NEXT_PUBLIC_CONVEX_URL;

// ❌ Will trigger ESLint error
const url = process.env.NEXT_PUBLIC_CONVEX_URL;

Adding a new environment variable — the 4-step checklist

Every time you introduce a new env var, all four of these steps are required:

Step 1: Add to /.env

MY_NEW_VAR=value

Step 2: Add to apps/next/src/env.ts

// In the server{} block for server-only vars:
MY_NEW_VAR: z.string(),

// In the client{} block for NEXT_PUBLIC_ vars:
NEXT_PUBLIC_MY_VAR: z.url(),

// And in the runtimeEnv{} block:
MY_NEW_VAR: process.env.MY_NEW_VAR,

Step 3: Add to turbo.json globalEnv array

"globalEnv": [
  ...,
  "MY_NEW_VAR"
]

Skipping this means Turborepo won't bust its cache when the variable changes.

Step 4 (if the backend needs it): Sync to Convex

# From packages/backend/
bun with-env npx convex env set MY_NEW_VAR "value"

Or set it directly in the Convex Dashboard.

Also update /.env.example with the new variable (empty value or a placeholder).


6. Dependency Management — The Catalog System

What catalogs are

The root package.json uses Bun workspaces with a catalog system — a single source of truth for shared dependency versions. When a package declares "react": "catalog:react19", it means "use whatever version is defined in the react19 catalog in the root package.json". This prevents version drift across packages.

The current catalogs:

"catalog": {
  "@eslint/js": "^9.38.0",
  "@tailwindcss/postcss": "^4.1.16",
  "@types/node": "^22.19.15",
  "eslint": "^9.39.4",
  "prettier": "^3.8.1",
  "tailwindcss": "^4.1.16",
  "typescript": "^5.9.3",
  "zod": "^4.3.6"
},
"catalogs": {
  "convex": {
    "convex": "^1.33.1",
    "@convex-dev/auth": "^0.0.81"
  },
  "react19": {
    "@types/react": "~19.1.0",
    "@types/react-dom": "~19.1.0",
    "react": "19.1.4",
    "react-dom": "19.1.4"
  }
}

Usage in package package.json files:

"dependencies": {
  "convex": "catalog:convex",
  "react": "catalog:react19",
  "typescript": "catalog:",
  "zod": "catalog:"
},
"devDependencies": {
  "@gib/eslint-config": "workspace:*",
  "@gib/ui": "workspace:*"
}

The catalog: syntax (no name) refers to the default catalog. catalog:convex and catalog:react19 refer to named catalogs.

The update regression — how to safely update dependencies

Do NOT run bun update directly inside individual package directories. This is a known issue: running bun update in a package directory will replace catalog: references with explicit hardcoded version strings, breaking the single-source-of-truth system.

The correct dependency update workflow:

  1. Edit the version in the root package.json catalog section
  2. Run bun install from the root
  3. Verify with bun lint:ws (which runs sherif to check workspace consistency)

sherif runs on postinstall

The root package.json has "postinstall": "sherif". This means every time you run bun install, sherif runs automatically and will fail the install if any catalog: references are broken or if package versions are inconsistent. This is a feature, not a bug — it catches the regression described above immediately.

If you see sherif errors after running bun install, it usually means a catalog: reference was accidentally replaced with a hardcoded version. Fix it by restoring the catalog: syntax in the affected package.json.

Adding new packages to the monorepo

Use the Turborepo generator to scaffold a new package:

bun turbo gen init

This will prompt you for:

  1. Package name — enter with or without the @gib/ prefix (it's stripped automatically)
  2. Dependencies — space-separated list of npm packages to install

The generator creates:

  • packages/<name>/eslint.config.ts
  • packages/<name>/package.json (with workspace devDeps and latest versions for any specified deps)
  • packages/<name>/tsconfig.json
  • packages/<name>/src/index.ts

After scaffolding it runs bun install and formats the new files.


7. Convex Backend Deep Dive

Package structure

packages/backend/
├── convex/                    # ← Only this directory is synced to Convex
│   ├── _generated/            # Auto-generated — NEVER edit
│   ├── custom/
│   │   └── auth/
│   │       ├── index.ts       # Barrel: Password + validatePassword exports
│   │       └── providers/
│   │           ├── password.ts    # Custom Password provider
│   │           └── usesend.ts     # UseSend email OTP provider
│   ├── auth.config.ts         # CORS domain configuration
│   ├── auth.ts                # Auth setup + all user-related functions
│   ├── crons.ts               # Cron jobs (commented examples)
│   ├── files.ts               # File upload/storage
│   ├── http.ts                # HTTP routes (auth only) — DO NOT TOUCH
│   ├── schema.ts              # Database schema — the source of truth
│   └── tsconfig.json          # Convex-required TS config
├── scripts/
│   └── generateKeys.mjs       # RS256 JWT keypair generator for Convex Auth
└── types/
    ├── auth.ts                # Password validation constants
    └── index.ts               # Barrel export

Important: The backend package has no root tsconfig.json — only convex/tsconfig.json. This is intentional and follows Convex's recommended structure. When bun typecheck runs from the repo root, it will print TypeScript help output for @gib/backend (because there's no valid project to check). This is expected and not an error.

Schema design — always extend users directly

The preferred and enforced approach in this repo is to extend the users table directly for all application data. Do not create a separate profiles table.

The Convex Auth library (@convex-dev/auth) provides base fields via authTables (name, image, email, emailVerificationTime, phone, phoneVerificationTime, isAnonymous). Custom fields are added directly to the users table definition in schema.ts:

// packages/backend/convex/schema.ts
const applicationTables = {
  users: defineTable({
    name: v.optional(v.string()),
    image: v.optional(v.string()), // stores Convex storage ID as string
    email: v.optional(v.string()),
    emailVerificationTime: v.optional(v.number()),
    phone: v.optional(v.string()),
    phoneVerificationTime: v.optional(v.number()),
    isAnonymous: v.optional(v.boolean()),
    /* Custom fields below */
    themePreference: v.optional(
      v.union(v.literal('light'), v.literal('dark'), v.literal('system')),
    ),
  })
    .index('email', ['email'])
    .index('phone', ['phone'])
    .index('name', ['name']),
};

export default defineSchema({
  ...authTables,
  ...applicationTables,
});

The themePreference field demonstrates the pattern. When your app needs to store user-specific data (preferences, profile info, settings), add a field here.

Note on image: The image field is typed as v.string() because authTables defines it that way. In practice it stores a Convex storage ID (which is a string at runtime). The updateUser mutation accepts v.id('_storage') and casts it when writing. This is a known, harmless type mismatch — leave it as v.string() in the schema for now.

Writing backend functions

The three Convex function types:

  • query — read-only, reactive, called from client with useQuery or server with preloadQuery. Always check auth if the data should be protected.
  • mutation — write operations (database inserts, patches, deletes). Runs transactionally. Cannot make external HTTP calls.
  • action — for side effects: calling external APIs, Convex storage operations, or certain @convex-dev/auth operations. Can call ctx.runQuery and ctx.runMutation internally.

Always authenticate inside protected functions:

import { getAuthUserId } from '@convex-dev/auth/server';
import { ConvexError, v } from 'convex/values';

import { mutation, query } from './_generated/server';

export const getMyData = query({
  args: {},
  handler: async (ctx) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) return null; // or throw if auth is truly required
    return ctx.db.get(userId);
  },
});

export const updateMyData = mutation({
  args: { value: v.string() },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new ConvexError('Not authenticated.');
    await ctx.db.patch(userId, { someField: args.value });
  },
});

Why updateUserPassword is an action, not a mutation

@convex-dev/auth's modifyAccountCredentials function makes internal HTTP calls under the hood. Convex mutations cannot make external/internal HTTP calls — only actions can. That's why any password change operation must be an action:

export const updateUserPassword = action({
  args: { currentPassword: v.string(), newPassword: v.string() },
  handler: async (ctx, { currentPassword, newPassword }) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new ConvexError('Not authenticated.');
    // retrieveAccount and modifyAccountCredentials both require action context
    const verified = await retrieveAccount(ctx, {
      provider: 'password',
      account: { id: user.email, secret: currentPassword },
    });
    if (!verified) throw new ConvexError('Current password is incorrect.');
    await modifyAccountCredentials(ctx, {
      provider: 'password',
      account: { id: user.email, secret: newPassword },
    });
  },
});

Similarly, any auth function that needs to call into Convex Auth internals (retrieveAccount, modifyAccountCredentials, createAccount, etc.) must be an action.

Authentication setup

// convex/auth.ts
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
  providers: [Authentik({ allowDangerousEmailAccountLinking: true }), Password],
});

Two providers are configured:

  1. Authentik — OAuth SSO. The allowDangerousEmailAccountLinking: true flag allows users who previously registered with the Password provider to link their Authentik account if it shares the same email address.

  2. Password — custom provider in convex/custom/auth/providers/password.ts. Uses UseSendOTP for email verification during sign-up and UseSendOTPPasswordReset for password reset flows.

The UseSend email provider

convex/custom/auth/providers/usesend.ts is the custom email OTP provider. It reads configuration from environment variables:

  • USESEND_API_KEY — the API key for your UseSend instance
  • USESEND_URL — the base URL of your self-hosted UseSend instance
  • USESEND_FROM_EMAIL — the from address for emails (e.g. My App <noreply@example.com>)

The provider creates two exports used in password.ts:

  • UseSendOTP — 20-minute OTP for sign-up email verification
  • UseSendOTPPasswordReset — 1-hour OTP for password reset

Email templates are currently inline HTML strings in usesend.ts. The @react-email/components and react-email packages are in package.json as planned dependencies — React Email templates will eventually replace the inline HTML.

File uploads

convex/files.ts provides two functions:

// Get a signed upload URL (call this first)
const uploadUrl = await generateUploadUrl();

// POST your file to the URL, get back a storage ID
// Then store the ID:
await updateUser({ image: storageId });

// Get a temporary signed URL to display the image:
const imageUrl = await getImageUrl({ storageId });

The profile avatar upload in apps/next/src/components/layout/auth/profile/avatar-upload.tsx demonstrates the complete flow: crop the image client-side → get an upload URL → POST to Convex storage → save the storage ID to the user record.

Cron jobs

convex/crons.ts is the place for scheduled jobs. The file currently contains commented-out examples. When you need to add a cron job, add it to this file following the pattern in the comments.

Generating JWT keys for Convex Auth

When setting up a new deployment, you need to generate RS256 JWT keys for Convex Auth to work:

# From packages/backend/
bun run scripts/generateKeys.mjs

This outputs JWT_PRIVATE_KEY and JWKS values that must be synced to Convex:

bun with-env npx convex env set JWT_PRIVATE_KEY "..."
bun with-env npx convex env set JWKS "..."

8. Next.js App Deep Dive

Files you should not casually modify

File Why
next.config.js Sentry, Plausible, standalone build — carefully configured
src/proxy.ts Auth routing + IP banning — the project's middleware
src/env.ts The env contract — add to it, don't restructure it
src/instrumentation.ts Sentry server setup — don't touch
src/instrumentation-client.ts Sentry client setup — don't touch
src/sentry.server.config.ts Sentry server config — don't touch
src/app/styles.css Tailwind setup + theme import — don't touch

Route structure

The app uses the Next.js App Router. All authenticated routes live in the (auth) route group (which doesn't create a URL segment — it's just for organization):

Route File Rendering Purpose
/ app/page.tsx Server Component Landing page (hero, features, tech stack, CTA)
/sign-in app/(auth)/sign-in/page.tsx Client Component Sign in, sign up, email OTP — all in one
/profile app/(auth)/profile/page.tsx Server Component User profile management
/forgot-password app/(auth)/forgot-password/page.tsx Client Component Password reset flow

The /profile route is protected — src/proxy.ts redirects unauthenticated users to /sign-in. The /sign-in route redirects already-authenticated users to /.

To protect a new route, add its pattern to isProtectedRoute in src/proxy.ts:

const isProtectedRoute = createRouteMatcher([
  '/profile',
  '/dashboard',
  '/settings',
]);

The dual Convex provider setup

Two providers are required and both must be present:

1. ConvexAuthNextjsServerProvider — wraps the root <html> element in app/layout.tsx. This is the server-side half that handles cookie reading and initial token passing. It has no visible UI.

2. ConvexClientProvider — a 'use client' component in components/providers/ConvexClientProvider.tsx. This wraps ConvexAuthNextjsProvider and initializes the ConvexReactClient. All client-side reactive queries and mutations flow through this.

// app/layout.tsx (server)
<ConvexAuthNextjsServerProvider>
  <html lang='en'>
    <body>
      <ConvexClientProvider>{children}</ConvexClientProvider>
    </body>
  </html>
</ConvexAuthNextjsServerProvider>

Removing either provider breaks auth. The server provider is needed for SSR token hydration; the client provider is needed for reactive updates.

SSR data preloading — the preferred pattern for new pages

For any page that needs Convex data, use preloadQuery on the server and usePreloadedQuery on the client. This avoids a loading flash and gives you SSR-rendered content.

Server component (the page file):

// app/(auth)/some-page/page.tsx
import { SomeComponent } from '@/components/some-component';
import { preloadQuery } from 'convex/nextjs';

import { api } from '@gib/backend/convex/_generated/api.js';

const SomePage = async () => {
  const preloadedData = await preloadQuery(api.someModule.someQuery);

  return <SomeComponent preloadedData={preloadedData} />;
};

export default SomePage;

Client component (the interactive part):

// components/some-component.tsx
'use client';

import type { Preloaded } from 'convex/react';
import { usePreloadedQuery } from 'convex/react';

import type { api } from '@gib/backend/convex/_generated/api.js';

interface Props {
  preloadedData: Preloaded<typeof api.someModule.someQuery>;
}

export const SomeComponent = ({ preloadedData }: Props) => {
  const data = usePreloadedQuery(preloadedData);
  // data is immediately available with SSR value, then stays reactive
  return <div>{data.name}</div>;
};

This is the pattern used on the /profile page and should be used for all new data-fetching pages.

Auth-conditional UI — always use a loading skeleton

Any component that renders differently based on whether the user is authenticated must show a loading skeleton while auth resolves. Never render a "signed in" state optimistically or flash from "not logged in" to "logged in".

The AvatarDropdown component in src/components/layout/header/controls/AvatarDropdown.tsx is the canonical example:

'use client';

import { useAuthActions } from '@convex-dev/auth/react';
import { useConvexAuth } from 'convex/react';

export const AvatarDropdown = () => {
  const { isLoading, isAuthenticated } = useConvexAuth();

  if (isLoading) {
    // Always show a skeleton while auth state is resolving
    return <Skeleton className='h-9 w-9 rounded-full' />;
  }

  if (!isAuthenticated) {
    return <SignInButton />;
  }

  return <UserAvatarWithDropdown />;
};

src/proxy.ts — route protection and IP banning

This file is the Next.js proxy/middleware. It does two things in sequence:

  1. IP banningbanSuspiciousIPs(request) runs first. It checks the incoming request against known malicious patterns (path traversal, PHP probes, etc.) and rate-limits suspicious IPs. If a ban is triggered, a 403 is returned immediately before auth runs.

  2. Auth routingconvexAuthNextjsMiddleware handles authentication:

    • Unauthenticated users hitting /profile are redirected to /sign-in
    • Authenticated users hitting /sign-in are redirected to /

Session cookies are configured with a 30-day max age.

The IP banning system (src/lib/proxy/ban-sus-ips.ts)

This is an in-memory security layer. Key behaviors:

  • Tracks request counts per IP using an in-memory Map
  • 10 requests matching malicious patterns within 1 minute → 30-minute ban
  • 10 404 responses within 2 minutes → ban
  • Detects and bans suspicious HTTP methods (TRACE, PUT, DELETE, PATCH)
  • The ban response message is deliberately direct — consider softening it for professional/client-facing deployments
  • Bans reset on server restart (in-memory). This is a known limitation; a future enhancement would persist bans to Convex or Redis.

Agents can extend the suspicious patterns by adding to the arrays at the top of the file if new attack vectors are observed.

IP detection order: x-forwarded-forx-real-ipcf-connecting-ip → host. This handles the nginx-proxy-manager reverse proxy setup correctly.

next.config.js — key behaviors

  • output: 'standalone' — generates a self-contained build for Docker deployment
  • typescript.ignoreBuildErrors: true — suppresses TypeScript errors during build (shadcn components can produce spurious errors)
  • serverExternalPackages: ['require-in-the-middle'] — Sentry compatibility
  • transpilePackages: ['@gib/backend', '@gib/ui'] — workspace packages need transpilation since Next.js doesn't transpile node_modules by default
  • images.remotePatterns — currently only allows *.gbrown.org. If you're deploying for a different domain, update this or profile pictures won't load.
  • serverActions.bodySizeLimit: '10mb' — allows large file uploads for avatar
  • The config is wrapped with withPlausibleProxy then withSentryConfig

Fonts

Fonts are configured in apps/next/src/app/layout.tsx using next/font. The current typeface system:

  • Kanit — display font used in the header logo and CTA (loaded via next/font/google)
  • Poppins — primary sans-serif body font
  • Libre Baskerville — serif font (available for editorial use)
  • Victor Mono — monospace font (available for code blocks)

The fonts are injected as CSS variables and mapped in tools/tailwind/theme.css. To change fonts, update the next/font imports in layout.tsx and update the corresponding CSS variable assignments in theme.css.

Analytics and monitoring

Plausible is injected via next-plausible and proxied through the Next.js app itself via withPlausibleProxy in next.config.js. This means analytics requests go to / (the Next.js server) first, which then forwards them to the Plausible instance. Ad blockers that block plausible.io directly cannot block this setup.

Sentry is configured with tracesSampleRate: 1.0 (100% trace sampling) on both client and server. This is appropriate for development and low-traffic deployments. For high-traffic production sites, reduce this to 0.10.2 in instrumentation-client.ts and sentry.server.config.ts.


9. UI Package (@gib/ui)

The single-import rule

Everything from the UI package is importable from the single @gib/ui entry point. Never import from specific component file paths:

// ✅ Correct — single import from @gib/ui
import { Button, Card, CardContent, cn, useIsMobile } from '@gib/ui';
// ❌ Wrong — never import from subpaths like this
import { Button } from '@gib/ui/src/button';

This includes utility functions (cn, ccn), all components, and all hooks.

Available components

The UI package contains the complete shadcn/ui component set (new-york style) plus custom additions. Key custom components:

  • BasedAvatar — shows user initials or a user icon when no image is set; use this instead of the base Avatar for user avatars
  • BasedProgress — progress bar variant
  • ImageCrop — image cropping UI built on react-image-crop; used in the profile avatar upload flow
  • SubmitButton — button with built-in loading state for form submissions
  • StatusMessage — standardized success/error message display
  • Spinner — loading spinner

Full component list: Accordion, Alert, AlertDialog, AspectRatio, Avatar, Badge, BasedAvatar, BasedProgress, Breadcrumb, Button, ButtonGroup, Calendar, Card, Carousel, Chart, Checkbox, Collapsible, Combobox, Command, ContextMenu, Dialog, Drawer, DropdownMenu, Empty, Field, Form, HoverCard, ImageCrop, Input, InputGroup, InputOTP, Item, Kbd, Label, NavigationMenu, NativeSelect, Pagination, Popover, Progress, RadioGroup, Resizable, ScrollArea, Select, Separator, Sheet, Sidebar, Skeleton, Slider, Sonner (Toaster), Spinner, StatusMessage, SubmitButton, Switch, Table, Tabs, Textarea, ThemeProvider, ThemeToggle, Toggle, ToggleGroup, Tooltip.

Available hooks (also from @gib/ui)

  • useIsMobile — returns true when viewport width is below 768px
  • useOnClickOutside — fires a callback when a click occurs outside a ref element

Adding a new component via bun ui-add

# From the repo root
bun ui-add

This runs bunx shadcn@latest add interactively. After adding a new component:

  1. Any internal imports the component makes (to other shadcn components, cn, hooks) must be updated to come from @gib/ui instead of relative paths
  2. Export the new component(s) from packages/ui/src/index.tsx

If you add a new hook, add it to packages/ui/src/hooks/ and export it from both packages/ui/src/hooks/index.tsx and the main packages/ui/src/index.tsx.

components.json note

The shadcn components.json file specifies style: "new-york" and baseColor: "zinc". The zinc base color is irrelevant — the actual visual theme is entirely determined by tools/tailwind/theme.css which overrides all color tokens with OKLCH values. The zinc setting only affects how bunx shadcn generates new component defaults; the runtime appearance is controlled by the theme.


10. Tools — Shared Configuration Packages

@gib/eslint-config

Three exported configs used across the monorepo:

  • baseConfig — TypeScript-aware ESLint with recommended, recommendedTypeChecked, and stylisticTypeChecked rules. Used by all packages.
  • reactConfig — adds eslint-plugin-react and react-hooks rules.
  • nextjsConfig — adds @next/eslint-plugin-next rules including core-web-vitals.
  • restrictEnvAccess — blocks direct process.env access outside env.ts.

Key enforced rules:

  • @typescript-eslint/consistent-type-imports — type imports must be separate (import type)
  • @typescript-eslint/no-non-null-assertion — non-null assertions (!) are errors
  • @typescript-eslint/no-unused-vars — unused vars are warnings; prefix with _ to suppress
  • import/consistent-type-specifier-style — prefer top-level import type over inline type

Note: the consistent-type-imports enforcement is a known item to potentially relax in the future (see Known Issues).

@gib/prettier-config

Key settings:

  • singleQuote: true, jsxSingleQuote: true — single quotes throughout
  • trailingComma: 'all' — trailing commas on all multi-line structures
  • printWidth: 80, tabWidth: 2
  • tailwindFunctions: ['cn', 'cva'] — Tailwind class sorting in these functions

Import order (enforced automatically by @ianvs/prettier-plugin-sort-imports):

1. Type imports (all)
2. react / react-native
3. next / expo
4. Third-party packages
5. @gib/* type imports, then @gib/* value imports
6. Local type imports, then local relative imports

You do not need to manually order imports — Prettier handles it on format. Run bun format:fix to apply.

@gib/tailwind-config

  • theme.css — the complete OKLCH-based design system. Light mode: warm cream background with reddish-brown primary. Dark mode: warm dark background with orange-warm primary. Do not modify without explicit instruction. When the theme needs to change, the entire file is replaced with a new theme generated from a shadcn theme generator.
  • postcss-config.js — re-exports { plugins: { '@tailwindcss/postcss': {} } }. Used by all apps via postcss.config.js.

@gib/tsconfig

  • base.json — strict TypeScript config for all packages. Notable settings: noUncheckedIndexedAccess: true (array/object access returns T | undefined), moduleResolution: "Bundler", module: "Preserve", isolatedModules: true. Uses tsBuildInfoFile for incremental caching in .cache/.
  • compiled-package.json — extends base with declaration emit settings for packages that need to output .d.ts files.

11. Docker & Deployment

Container overview

Three Docker services are defined in docker/compose.yml:

convex-backend (ghcr.io/get-convex/convex-backend)

  • The self-hosted Convex runtime
  • Data persists in docker/data/ (gitignored, must be backed up)
  • Health check via curl http://localhost:3210/version
  • Watchtower-enabled for automatic image updates
  • stop_signal: SIGINT + 10s grace period for clean shutdown

convex-dashboard (ghcr.io/get-convex/convex-dashboard)

  • Admin UI for the Convex backend
  • Starts only after convex-backend is healthy

next-app (built from docker/Dockerfile)

  • Multi-stage build: oven/bun:alpine builder → node:20-alpine runner
  • Standalone Next.js output (CMD ["node", "apps/next/server.js"])
  • Reverse-proxied by nginx — ports are not directly exposed

All services share the external nginx-bridge Docker network.

Typical production deployment workflow

The Convex backend and dashboard are long-running and rarely need to be rebuilt. The typical workflow when you've made changes to the Next.js app:

# On the production server (via SSH)
cd docker/

# Build the Next.js app with current source
sudo docker compose build next-app

# Once build succeeds, bring up the new container
sudo docker compose up -d next-app

If you need to start everything from scratch:

sudo docker compose up -d convex-backend convex-dashboard
# Wait for backend to be healthy, then:
sudo docker compose up -d next-app

The .dockerignore situation (known issue)

There are two .dockerignore files:

  • /.dockerignore (root) — has .env commented out, meaning the .env file IS included in the Docker build context
  • /docker/.dockerignore — explicitly excludes .env

The Docker build context is set to the repo root in compose.yml. The root .dockerignore is the one that applies. Because .env is not excluded, it gets sent to the builder and environment variables are available at build time. This is how SENTRY_AUTH_TOKEN and other build-time vars reach the Next.js build step.

This is a known imperfect approach but it works. Do not "fix" it without understanding the full implications — removing .env from the build context would break Sentry source map uploads and NEXT_PUBLIC_* variable baking into the client bundle.

Convex data persistence

docker/data/ is mounted as a Docker volume and contains all Convex database data. This directory is gitignored. Back it up before any server migrations, restarts, or image updates.

generate_convex_admin_key

A bash script in docker/ that generates the admin key from a running Convex backend:

cd docker/
./generate_convex_admin_key

This runs the key generation script inside the convex-backend container and prints the CONVEX_SELF_HOSTED_ADMIN_KEY value to stdout.


12. Code Style Reference

Functions — always const arrow syntax

// ✅ Correct — use const for all functions
const fetchUser = async (userId: string) => {
  return await db.get(userId);
};

const MyComponent = ({ name }: { name: string }) => {
  return <div>{name}</div>;
};

// ❌ Wrong — function declarations
function fetchUser(userId: string) { ... }
function MyComponent({ name }: { name: string }) { ... }

Naming conventions

Thing Convention Example
React components PascalCase UserProfile.tsx
Non-component files kebab-case.ts ban-sus-ips.ts
Functions and variables camelCase const getUserById
Constants SCREAMING_SNAKE_CASE PASSWORD_MIN
TypeScript types/interfaces PascalCase type UserDoc
Unused vars/params prefix with _ const _unused

Imports

  • Use import type for type-only imports (enforced by ESLint)
  • Import order is handled automatically by Prettier on format — don't manually sort
  • Always use .js extension for Convex-generated file imports
  • All UI components and utilities come from @gib/ui
  • All env vars come from @/env

Error handling

// Backend — throw ConvexError with a user-readable message
import { ConvexError } from 'convex/values';

throw new ConvexError('User not found.');

// Frontend — catch ConvexError and display with toast
try {
  await someConvexMutation(args);
} catch (e) {
  if (e instanceof ConvexError) {
    toast.error(e.message);
  }
}

TypeScript

  • Strict mode is on everywhere (strict: true, noUncheckedIndexedAccess: true)
  • Array/object index access returns T | undefined — always handle the undefined case
  • Avoid any — use unknown with type guards or proper generics
  • Non-null assertions (!) are ESLint errors — prove your types instead

Template customization points

When using this repo as a template for a new project (not working on the existing deployment), these things should be updated:

  • apps/next/src/lib/metadata.ts — update the title template and metadata
  • apps/next/next.config.js — update images.remotePatterns for your domain
  • apps/next/src/app/layout.tsx — update the root <html lang>, site title, etc.
  • Root package.json — update the workspace name field to fit the new project
  • /.env — fill out all values for the new deployment
  • docker/.env — fill out container names and domain URLs

13. Adding New Features — Checklists

Adding a new field to the users table

  1. Add the field to users in packages/backend/convex/schema.ts
  2. Add or update the relevant mutation in packages/backend/convex/auth.ts
  3. Run bun dev:backend to let convex dev regenerate _generated/ types
  4. Add the corresponding UI in the profile page or wherever the field is managed
  5. Update packages/backend/types/auth.ts if the field has shared validation constants

Adding a new Convex function

  1. Write the function in the appropriate .ts file in packages/backend/convex/
  2. Run bun dev:backendconvex dev --until-success will push it and regenerate _generated/api.ts with the new export
  3. Import using api.moduleName.functionName with the .js extension:
    import { api } from '@gib/backend/convex/_generated/api.js';
    

Adding a new page

  1. Create apps/next/src/app/<route>/page.tsx
  2. If the page needs data: use the preloadQuery + usePreloadedQuery SSR pattern
  3. If the page should be protected: add its path to isProtectedRoute in src/proxy.ts
  4. Add a layout file if you need a custom <title>:
    export const metadata = { title: 'Page Name' };
    
  5. Run bun typecheck to validate

Adding a new environment variable

See the 4-step checklist in Section 5.

Adding a new UI component via shadcn

  1. Run bun ui-add from the repo root
  2. Select the component(s) to add
  3. Update any internal imports in the new files to use @gib/ui instead of relative paths
  4. Add exports to packages/ui/src/index.tsx
  5. Run bun typecheck to validate

14. Expo App — Known Issues, Do Not Touch

The apps/expo/ directory exists as a placeholder for future mobile development. It is currently non-functional — it still contains the original T3 Turbo template code and has not been migrated to use Convex or @convex-dev/auth.

Do not work on the Expo app unless explicitly asked. If you are asked to work on it, be aware that the following are all broken and will need to be addressed:

File Issue
src/utils/api.tsx Imports AppRouter from @acme/api — package does not exist. Uses tRPC, which is not used anywhere in this repo.
src/utils/auth.ts Uses better-auth / @better-auth/expo instead of @convex-dev/auth
src/app/index.tsx Old T3 Turbo template code using tRPC mutations and better-auth session patterns
src/app/post/[id].tsx Same — tRPC and better-auth patterns throughout
postcss.config.js References @acme/tailwind-config — package does not exist
src/styles.css Imports from @acme/tailwind-config — package does not exist
eslint.config.mts Imports from @acme/eslint-config — package does not exist
eas.json Specifies pnpm 9.15.4 as the package manager — should be bun

When the time comes to properly implement the Expo app, the right approach is to start fresh with Convex + @convex-dev/auth patterns (mirroring how the Next.js app works), not to try to incrementally fix the broken T3 Turbo code.


15. Known Issues & Technical Debt

A comprehensive list of known issues, organized by priority. Add new issues here as they're discovered.


1. Catalog update regression ⚠️ High priority

Running bun update inside individual package directories replaces catalog: references with hard-coded version strings, breaking the single-source-of-truth system. The correct update workflow (edit root package.json catalog → bun installbun lint:ws) needs to be better enforced/documented. Investigate whether Bun has a native fix for this or if a custom update script is needed.


2. Password validation mismatch ⚠️ Medium priority

packages/backend/types/auth.ts exports PASSWORD_REGEX which requires special characters. packages/backend/convex/custom/auth/providers/password.ts::validatePassword() does NOT check for special characters. These two are inconsistent. The frontend sign-in form does enforce special characters via its own Zod schema, but the backend validation is weaker than the type definition implies. Either align validatePassword() to match PASSWORD_REGEX, or update PASSWORD_REGEX to not require special characters.


3. import type enforcement removed Fixed

The @typescript-eslint/consistent-type-imports and import/consistent-type-specifier-style rules that forced separate import type statements have been removed from tools/eslint/base.ts. Type imports can now be mixed inline or on separate lines — whatever reads most naturally in context.


4. const arrow function lint rule added Fixed

eslint-plugin-prefer-arrow-functions has been added to tools/eslint/base.ts and the prefer-arrow-functions/prefer-arrow-functions rule is now enforced as a warning. All function declarations across the codebase have been auto-converted to const arrow function syntax.


5. Turbo generator uses pnpm Fixed in this session

turbo/generators/config.ts previously called pnpm i and pnpm prettier. This has been fixed to use bun install and bun prettier.


6. Root .dockerignore includes .env in build context Known, works intentionally

The root .dockerignore has .env commented out, meaning the .env file is sent to the Docker build context. This is how build-time env vars (Sentry token, NEXT_PUBLIC_*) reach the Next.js build step. It's a known imperfect approach but fully functional. A proper solution would use Docker build args (ARG) or multi-stage secrets, but this requires careful restructuring to avoid breaking Sentry source map uploads.


7. In-memory IP banning resets on restart Future enhancement

src/lib/proxy/ban-sus-ips.ts stores bans in a JavaScript Map. Bans reset whenever the Next.js server restarts (container restart, redeploy, etc.). A more robust solution would persist bans to Convex (using a bans table with TTL-based cleanup via a cron job) or to Redis.


8. apps/next/src/lib/metadata.ts has hardcoded branding ⚠️ Template concern

When using this as a template for a new project, metadata.ts must be updated: the title template ('%s | Convex Monorepo') and any other project-specific strings. Same for next.config.js images.remotePatterns (currently *.gbrown.org).


9. No CI/CD Future enhancement

There is no .github/workflows/ directory. All deployment is done manually via SSH. A future enhancement would add GitHub Actions (or Gitea Actions, since this repo is on Gitea) for automated lint and typecheck on pull requests.


10. Scripts organization Future exploration

Currently:

  • packages/backend/scripts/generateKeys.mjs — generates JWT keys (run manually)
  • docker/generate_convex_admin_key — bash script to get the admin key from Docker

These are separate workflows that both need to be run once when setting up a new deployment. Explore combining these into a unified setup script — potentially one that generates the keys AND automatically syncs them to Convex in one step, possibly moving everything to docker/scripts/.


11. Expo eas.json specifies pnpm ⚠️ Low priority (Expo is WIP anyway)

apps/expo/eas.json specifies pnpm 9.15.4 as the package manager. Should specify bun. This is low priority since the Expo app is not in active development.


12. React Email templates not yet implemented Future enhancement

@react-email/components and react-email are in packages/backend/package.json as planned dependencies. The current usesend.ts uses inline HTML strings for email templates. These will be replaced with proper React Email templates in a future update.


16. Quick Command Reference

# ── Development ───────────────────────────────────────────────────────────────
bun dev              # Start all apps (Next.js + Expo + Convex backend)
bun dev:next         # Start Next.js + Convex backend (most common)
bun dev:backend      # Start Convex backend only
bun dev:expo         # Start Expo + Convex backend
bun dev:expo:tunnel  # Expo with tunnel for physical device testing

# ── Quality Checks ────────────────────────────────────────────────────────────
bun typecheck        # ← PREFERRED — TypeScript checking, fast
bun lint             # ESLint all packages
bun lint:fix         # ESLint with auto-fix
bun lint:ws          # Check workspace consistency with sherif
bun format           # Check Prettier formatting
bun format:fix       # Apply Prettier formatting

# ── Build (production only) ───────────────────────────────────────────────────
bun build            # Build all packages

# ── Utilities ─────────────────────────────────────────────────────────────────
bun ui-add           # Add shadcn/ui components interactively
bun clean            # Clean all node_modules (git clean -xdf)
bun clean:ws         # Clean workspace caches only

# ── Single Package (Turborepo filters) ────────────────────────────────────────
bun turbo run typecheck -F @gib/next      # Typecheck Next.js app only
bun turbo run lint -F @gib/backend        # Lint backend only
bun turbo run dev -F @gib/next            # Dev Next.js only (no Convex)

# ── Scaffolding ───────────────────────────────────────────────────────────────
bun turbo gen init   # Scaffold a new package

# ── Convex CLI (run from packages/backend/) ───────────────────────────────────
bun dev              # Runs convex dev (push functions + watch)
bun setup            # Runs convex dev --until-success (push once then exit)
bun with-env npx convex env set VAR "value"  # Sync env var to Convex deployment

# ── Docker (run from docker/) ─────────────────────────────────────────────────
sudo docker compose up -d convex-backend convex-dashboard   # Start Convex
sudo docker compose build next-app                          # Build Next.js image
sudo docker compose up -d next-app                          # Deploy Next.js
sudo docker compose logs -f                                 # Stream all logs
./generate_convex_admin_key                                 # Get admin key