58 KiB
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
- What This Repo Is
- Critical Rules — Read First
- Monorepo Architecture Overview
- Self-Hosted Stack & Runtime Requirements
- Environment Variables — Complete Reference
- Dependency Management — The Catalog System
- Convex Backend Deep Dive
- Next.js App Deep Dive
- UI Package (
@gib/ui) - Tools — Shared Configuration Packages
- Docker & Deployment
- Code Style Reference
- Adding New Features — Checklists
- Expo App — Known Issues, Do Not Touch
- Known Issues & Technical Debt
- 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.jstonext.config.ts— the config usesjitito import./src/env.ts(a TypeScript file) at build time. This pattern only works when the config itself is a.jsfile. Renaming it to.tswill break the build. -
NEVER rename
apps/next/src/proxy.tstomiddleware.ts—proxy.tsis 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.jsapps/next/src/instrumentation.tsapps/next/src/instrumentation-client.tsapps/next/src/sentry.server.config.ts
-
NEVER remove
typescript.ignoreBuildErrors: truefromnext.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.csswithout 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 callsauth.addHttpRoutes(http)and must stay exactly as it is for Convex Auth to work.
Always do these:
-
ALWAYS use
bun typecheckto validate your work, neverbun build. Typecheck is fast and sufficient. Build is slow and for production deployments only. -
ALWAYS use
constarrow functions overfunctiondeclarations — 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 fromprocess.envdirectly. The ESLintrestrictEnvAccessrule will error if you useprocess.envanywhere outside ofapps/next/src/env.ts. -
ALWAYS add
.jsextensions 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 allpackage.jsonfiles, which requires explicit file extensions for ESM imports. The.jsextension 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 toglobalEnv.globalDependencies—**/.env.*localis tracked, so changes to.env.localfiles invalidate the cache.- The
buildtask runs^build(dependencies first), then emits todist/. - The
devtask is not cached and not persistent (persistent: falsemeans Turborepo considers it non-blocking). typecheckandlintboth depend on^topoand^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:
- Edit the version in the root
package.jsoncatalog section - Run
bun installfrom the root - Verify with
bun lint:ws(which runssherifto 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:
- Package name — enter with or without the
@gib/prefix (it's stripped automatically) - Dependencies — space-separated list of npm packages to install
The generator creates:
packages/<name>/eslint.config.tspackages/<name>/package.json(with workspace devDeps and latest versions for any specified deps)packages/<name>/tsconfig.jsonpackages/<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 withuseQueryor server withpreloadQuery. 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/authoperations. Can callctx.runQueryandctx.runMutationinternally.
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:
-
Authentik — OAuth SSO. The
allowDangerousEmailAccountLinking: trueflag allows users who previously registered with the Password provider to link their Authentik account if it shares the same email address. -
Password — custom provider in
convex/custom/auth/providers/password.ts. UsesUseSendOTPfor email verification during sign-up andUseSendOTPPasswordResetfor 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 instanceUSESEND_URL— the base URL of your self-hosted UseSend instanceUSESEND_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 verificationUseSendOTPPasswordReset— 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:
-
IP banning —
banSuspiciousIPs(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. -
Auth routing —
convexAuthNextjsMiddlewarehandles authentication:- Unauthenticated users hitting
/profileare redirected to/sign-in - Authenticated users hitting
/sign-inare redirected to/
- Unauthenticated users hitting
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-for → x-real-ip → cf-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 deploymenttypescript.ignoreBuildErrors: true— suppresses TypeScript errors during build (shadcn components can produce spurious errors)serverExternalPackages: ['require-in-the-middle']— Sentry compatibilitytranspilePackages: ['@gib/backend', '@gib/ui']— workspace packages need transpilation since Next.js doesn't transpilenode_modulesby defaultimages.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
withPlausibleProxythenwithSentryConfig
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.1–0.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 baseAvatarfor user avatarsBasedProgress— progress bar variantImageCrop— image cropping UI built onreact-image-crop; used in the profile avatar upload flowSubmitButton— button with built-in loading state for form submissionsStatusMessage— standardized success/error message displaySpinner— 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— returnstruewhen viewport width is below 768pxuseOnClickOutside— 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:
- Any internal imports the component makes (to other shadcn components,
cn, hooks) must be updated to come from@gib/uiinstead of relative paths - 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 withrecommended,recommendedTypeChecked, andstylisticTypeCheckedrules. Used by all packages.reactConfig— addseslint-plugin-reactandreact-hooksrules.nextjsConfig— adds@next/eslint-plugin-nextrules including core-web-vitals.restrictEnvAccess— blocks directprocess.envaccess outsideenv.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 suppressimport/consistent-type-specifier-style— prefer top-levelimport typeover inlinetype
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 throughouttrailingComma: 'all'— trailing commas on all multi-line structuresprintWidth: 80,tabWidth: 2tailwindFunctions: ['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 viapostcss.config.js.
@gib/tsconfig
base.json— strict TypeScript config for all packages. Notable settings:noUncheckedIndexedAccess: true(array/object access returnsT | undefined),moduleResolution: "Bundler",module: "Preserve",isolatedModules: true. UsestsBuildInfoFilefor incremental caching in.cache/.compiled-package.json— extends base with declaration emit settings for packages that need to output.d.tsfiles.
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-backendis healthy
next-app (built from docker/Dockerfile)
- Multi-stage build:
oven/bun:alpinebuilder →node:20-alpinerunner - 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 is one .dockerignore file at the repo root: /.dockerignore. Docker always
reads the .dockerignore from the root of the build context, which is set to ../
(the repo root) in compose.yml — so the root file is the only one that ever applies.
The .env file is intentionally included in the build context (the .env lines are
commented out in /.dockerignore). This is how SENTRY_AUTH_TOKEN and NEXT_PUBLIC_*
variables reach the Next.js build step inside the container.
Do not un-comment those lines 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.
Note on docker/.env: This file is entirely separate and serves a different
purpose. It is read by Docker Compose itself (not the build) to interpolate the
${VARIABLE} placeholders in compose.yml — things like container names, network
names, and Convex origin URLs. It is not the same as the root /.env and the two
are not interchangeable.
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 typefor type-only imports (enforced by ESLint) - Import order is handled automatically by Prettier on format — don't manually sort
- Always use
.jsextension 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— useunknownwith 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 metadataapps/next/next.config.js— updateimages.remotePatternsfor your domainapps/next/src/app/layout.tsx— update the root<html lang>, site title, etc.- Root
package.json— update the workspacenamefield to fit the new project /.env— fill out all values for the new deploymentdocker/.env— fill out container names and domain URLs
13. Adding New Features — Checklists
Adding a new field to the users table
- Add the field to
usersinpackages/backend/convex/schema.ts - Add or update the relevant
mutationinpackages/backend/convex/auth.ts - Run
bun dev:backendto letconvex devregenerate_generated/types - Add the corresponding UI in the profile page or wherever the field is managed
- Update
packages/backend/types/auth.tsif the field has shared validation constants
Adding a new Convex function
- Write the function in the appropriate
.tsfile inpackages/backend/convex/ - Run
bun dev:backend—convex dev --until-successwill push it and regenerate_generated/api.tswith the new export - Import using
api.moduleName.functionNamewith the.jsextension:import { api } from '@gib/backend/convex/_generated/api.js';
Adding a new page
- Create
apps/next/src/app/<route>/page.tsx - If the page needs data: use the
preloadQuery+usePreloadedQuerySSR pattern - If the page should be protected: add its path to
isProtectedRouteinsrc/proxy.ts - Add a layout file if you need a custom
<title>:export const metadata = { title: 'Page Name' }; - Run
bun typecheckto validate
Adding a new environment variable
See the 4-step checklist in Section 5.
Adding a new UI component via shadcn
- Run
bun ui-addfrom the repo root - Select the component(s) to add
- Update any internal imports in the new files to use
@gib/uiinstead of relative paths - Add exports to
packages/ui/src/index.tsx - Run
bun typecheckto 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 install →
bun 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 ✅ Fixed
validatePassword() in packages/backend/convex/custom/auth/providers/password.ts
now fully matches PASSWORD_REGEX in packages/backend/types/auth.ts. Both enforce:
minimum 8 characters, maximum 100, no whitespace, at least one digit, one lowercase
letter, one uppercase letter, and at least one special character ([\p{P}\p{S}]).
3. 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.
The redundant docker/.dockerignore has been deleted — it was never read by Docker
since the build context is always the repo root.
4. 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.
5. 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).
6. 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.
7. 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/.
8. 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.
9. 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