Showing preview only (808K chars total). Download the full file or copy to clipboard to get everything.
Repository: kriasoft/react-starter-kit
Branch: main
Commit: 3c5132eb3d0a
Files: 331
Total size: 734.2 KB
Directory structure:
gitextract_ge1ncw1v/
├── .claude/
│ └── commands/
│ ├── migrate-to-d1.md
│ ├── review-better-auth.md
│ ├── review-terraform.md
│ └── validate-auth-schema.md
├── .editorconfig
├── .gemini/
│ └── settings.json
├── .gitattributes
├── .github/
│ ├── CODE_OF_CONDUCT.md
│ ├── CONTRIBUTING.md
│ ├── FUNDING.yml
│ ├── SECURITY.md
│ ├── copilot-instructions.md
│ ├── dependabot.yml
│ └── workflows/
│ ├── ci.yml
│ ├── conventional-commits.yml
│ └── deploy.yml
├── .gitignore
├── .husky/
│ ├── .gitignore
│ └── pre-commit
├── .prettierignore
├── .vscode/
│ ├── extensions.json
│ ├── mcp.json
│ └── settings.json
├── AGENTS.md
├── CLAUDE.md
├── LICENSE
├── README.md
├── apps/
│ ├── api/
│ │ ├── AGENTS.md
│ │ ├── Dockerfile
│ │ ├── README.md
│ │ ├── dev.ts
│ │ ├── index.ts
│ │ ├── lib/
│ │ │ ├── ai.ts
│ │ │ ├── app.ts
│ │ │ ├── auth.ts
│ │ │ ├── context.ts
│ │ │ ├── db.ts
│ │ │ ├── email.ts
│ │ │ ├── env.ts
│ │ │ ├── loaders.ts
│ │ │ ├── middleware.ts
│ │ │ ├── plans.ts
│ │ │ ├── stripe.ts
│ │ │ └── trpc.ts
│ │ ├── package.json
│ │ ├── routers/
│ │ │ ├── billing.test.ts
│ │ │ ├── billing.ts
│ │ │ ├── organization.ts
│ │ │ └── user.ts
│ │ ├── tsconfig.json
│ │ ├── vitest.config.ts
│ │ ├── worker.ts
│ │ └── wrangler.jsonc
│ ├── app/
│ │ ├── AGENTS.md
│ │ ├── README.md
│ │ ├── components/
│ │ │ ├── auth/
│ │ │ │ ├── auth-error-boundary.tsx
│ │ │ │ ├── auth-form.tsx
│ │ │ │ ├── google-login.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── login-dialog.tsx
│ │ │ │ ├── otp-verification.tsx
│ │ │ │ ├── passkey-login.tsx
│ │ │ │ └── use-auth-form.ts
│ │ │ ├── index.ts
│ │ │ ├── layout/
│ │ │ │ ├── constants.ts
│ │ │ │ ├── header.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ ├── sidebar-nav.tsx
│ │ │ │ └── sidebar.tsx
│ │ │ ├── not-found.tsx
│ │ │ └── user-menu.tsx
│ │ ├── components.json
│ │ ├── global.d.ts
│ │ ├── index.html
│ │ ├── index.tsx
│ │ ├── lib/
│ │ │ ├── auth-config.ts
│ │ │ ├── auth.ts
│ │ │ ├── errors.test.ts
│ │ │ ├── errors.ts
│ │ │ ├── queries/
│ │ │ │ ├── README.md
│ │ │ │ ├── billing.test.ts
│ │ │ │ ├── billing.ts
│ │ │ │ ├── session.test.ts
│ │ │ │ └── session.ts
│ │ │ ├── query.ts
│ │ │ ├── routeTree.gen.ts
│ │ │ ├── store.ts
│ │ │ ├── trpc.ts
│ │ │ └── utils.ts
│ │ ├── package.json
│ │ ├── postcss.config.js
│ │ ├── public/
│ │ │ ├── robots.txt
│ │ │ └── site.manifest
│ │ ├── routes/
│ │ │ ├── (app)/
│ │ │ │ ├── about.tsx
│ │ │ │ ├── analytics.tsx
│ │ │ │ ├── dashboard.tsx
│ │ │ │ ├── index.tsx
│ │ │ │ ├── reports.tsx
│ │ │ │ ├── route.tsx
│ │ │ │ ├── settings.tsx
│ │ │ │ └── users.tsx
│ │ │ ├── (auth)/
│ │ │ │ ├── login.tsx
│ │ │ │ └── signup.tsx
│ │ │ └── __root.tsx
│ │ ├── styles/
│ │ │ └── globals.css
│ │ ├── tailwind.config.css
│ │ ├── tsconfig.json
│ │ ├── vite.config.ts
│ │ ├── vitest.setup.ts
│ │ └── wrangler.jsonc
│ ├── email/
│ │ ├── README.md
│ │ ├── components/
│ │ │ └── BaseTemplate.tsx
│ │ ├── emails/
│ │ │ ├── email-verification.tsx
│ │ │ ├── otp-password-reset.tsx
│ │ │ ├── otp-sign-in.tsx
│ │ │ ├── otp-verification.tsx
│ │ │ └── password-reset.tsx
│ │ ├── index.ts
│ │ ├── package.json
│ │ ├── templates/
│ │ │ ├── email-verification.tsx
│ │ │ ├── otp-email.tsx
│ │ │ └── password-reset.tsx
│ │ ├── tsconfig.json
│ │ └── utils/
│ │ └── render.ts
│ └── web/
│ ├── README.md
│ ├── _headers
│ ├── astro.config.mjs
│ ├── layouts/
│ │ └── BaseLayout.astro
│ ├── lib/
│ │ └── utils.ts
│ ├── package.json
│ ├── pages/
│ │ ├── about.astro
│ │ ├── features.astro
│ │ ├── index.astro
│ │ └── pricing.astro
│ ├── postcss.config.js
│ ├── public/
│ │ ├── robots.txt
│ │ └── site.manifest
│ ├── styles/
│ │ └── globals.css
│ ├── tailwind.config.css
│ ├── tsconfig.json
│ ├── worker.ts
│ └── wrangler.jsonc
├── db/
│ ├── AGENTS.md
│ ├── README.md
│ ├── backups/
│ │ └── .gitignore
│ ├── drizzle.config.ts
│ ├── index.ts
│ ├── migrations/
│ │ ├── 0000_init.sql
│ │ └── meta/
│ │ ├── 0000_snapshot.json
│ │ └── _journal.json
│ ├── package.json
│ ├── schema/
│ │ ├── id.ts
│ │ ├── index.ts
│ │ ├── invitation.ts
│ │ ├── organization.ts
│ │ ├── passkey.ts
│ │ ├── subscription.ts
│ │ └── user.ts
│ ├── scripts/
│ │ ├── export.ts
│ │ ├── generate-auth-schema.ts
│ │ └── seed.ts
│ ├── seeds/
│ │ └── users.ts
│ └── tsconfig.json
├── docs/
│ ├── .vitepress/
│ │ ├── config.ts
│ │ ├── public/
│ │ │ └── robots.txt
│ │ └── theme/
│ │ ├── components/
│ │ │ ├── GitHubStats.vue
│ │ │ └── Mermaid.vue
│ │ ├── index.ts
│ │ └── style.css
│ ├── adr/
│ │ ├── 000-template.md
│ │ └── 001-auth-hint-cookie.md
│ ├── api/
│ │ ├── context.md
│ │ ├── index.md
│ │ ├── procedures.md
│ │ └── validation-errors.md
│ ├── architecture/
│ │ ├── edge.md
│ │ └── index.md
│ ├── auth/
│ │ ├── email-otp.md
│ │ ├── index.md
│ │ ├── organizations.md
│ │ ├── passkeys.md
│ │ ├── sessions.md
│ │ └── social-providers.md
│ ├── billing/
│ │ ├── checkout.md
│ │ ├── index.md
│ │ ├── plans.md
│ │ └── webhooks.md
│ ├── database/
│ │ ├── index.md
│ │ ├── migrations.md
│ │ ├── queries.md
│ │ ├── schema.md
│ │ └── seeding.md
│ ├── deployment/
│ │ ├── ci-cd.md
│ │ ├── cloudflare.md
│ │ ├── index.md
│ │ ├── monitoring.md
│ │ └── production-database.md
│ ├── email.md
│ ├── frontend/
│ │ ├── forms.md
│ │ ├── routing.md
│ │ ├── state.md
│ │ └── ui.md
│ ├── getting-started/
│ │ ├── environment-variables.md
│ │ ├── index.md
│ │ ├── project-structure.md
│ │ └── quick-start.md
│ ├── index.md
│ ├── public/
│ │ └── CNAME
│ ├── recipes/
│ │ ├── file-uploads.md
│ │ ├── new-page.md
│ │ ├── new-procedure.md
│ │ ├── new-table.md
│ │ ├── teams.md
│ │ └── websockets.md
│ ├── security/
│ │ ├── checklist.md
│ │ ├── incident-playbook.md
│ │ └── policy-template.md
│ ├── specs/
│ │ ├── auth-form.md
│ │ ├── billing.md
│ │ ├── infra-terraform.md
│ │ └── prefixed-ids.md
│ └── testing.md
├── eslint.config.ts
├── infra/
│ ├── .gitignore
│ ├── README.md
│ ├── envs/
│ │ ├── dev/
│ │ │ └── edge/
│ │ │ ├── .terraform.lock.hcl
│ │ │ ├── main.tf
│ │ │ ├── providers.tf
│ │ │ ├── terraform.tfvars.example
│ │ │ └── variables.tf
│ │ ├── preview/
│ │ │ └── edge/
│ │ │ ├── .terraform.lock.hcl
│ │ │ ├── main.tf
│ │ │ ├── providers.tf
│ │ │ ├── terraform.tfvars.example
│ │ │ └── variables.tf
│ │ ├── prod/
│ │ │ └── edge/
│ │ │ ├── .terraform.lock.hcl
│ │ │ ├── main.tf
│ │ │ ├── providers.tf
│ │ │ ├── terraform.tfvars.example
│ │ │ └── variables.tf
│ │ └── staging/
│ │ └── edge/
│ │ ├── .terraform.lock.hcl
│ │ ├── main.tf
│ │ ├── providers.tf
│ │ ├── terraform.tfvars.example
│ │ └── variables.tf
│ ├── modules/
│ │ ├── cloudflare/
│ │ │ ├── dns/
│ │ │ │ ├── main.tf
│ │ │ │ ├── outputs.tf
│ │ │ │ └── variables.tf
│ │ │ ├── hyperdrive/
│ │ │ │ ├── main.tf
│ │ │ │ ├── outputs.tf
│ │ │ │ └── variables.tf
│ │ │ ├── r2-bucket/
│ │ │ │ ├── main.tf
│ │ │ │ ├── outputs.tf
│ │ │ │ └── variables.tf
│ │ │ └── worker/
│ │ │ ├── main.tf
│ │ │ ├── outputs.tf
│ │ │ └── variables.tf
│ │ └── gcp/
│ │ ├── cloud-run/
│ │ │ ├── main.tf
│ │ │ ├── outputs.tf
│ │ │ └── variables.tf
│ │ ├── cloud-sql/
│ │ │ ├── main.tf
│ │ │ ├── outputs.tf
│ │ │ └── variables.tf
│ │ └── gcs/
│ │ ├── main.tf
│ │ ├── outputs.tf
│ │ └── variables.tf
│ ├── stacks/
│ │ ├── edge/
│ │ │ ├── main.tf
│ │ │ ├── outputs.tf
│ │ │ └── variables.tf
│ │ └── hybrid/
│ │ ├── main.tf
│ │ ├── outputs.tf
│ │ └── variables.tf
│ └── templates/
│ ├── backend-gcs.example.hcl
│ ├── backend-r2.example.hcl
│ └── env-roots/
│ └── hybrid/
│ ├── .terraform.lock.hcl
│ ├── README.md
│ ├── main.tf
│ ├── providers.tf
│ ├── terraform.tfvars.example
│ └── variables.tf
├── package.json
├── packages/
│ ├── core/
│ │ ├── README.md
│ │ ├── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── typescript-config/
│ │ ├── README.md
│ │ ├── base.jsonc
│ │ ├── cloudflare.jsonc
│ │ ├── node.jsonc
│ │ ├── package.json
│ │ └── react.jsonc
│ ├── ui/
│ │ ├── README.md
│ │ ├── components/
│ │ │ ├── avatar.tsx
│ │ │ ├── button.tsx
│ │ │ ├── card.tsx
│ │ │ ├── checkbox.tsx
│ │ │ ├── dialog.tsx
│ │ │ ├── input.tsx
│ │ │ ├── label.tsx
│ │ │ ├── radio-group.tsx
│ │ │ ├── scroll-area.tsx
│ │ │ ├── select.tsx
│ │ │ ├── separator.tsx
│ │ │ ├── skeleton.tsx
│ │ │ ├── switch.tsx
│ │ │ └── textarea.tsx
│ │ ├── components.json
│ │ ├── hooks/
│ │ │ └── index.ts
│ │ ├── index.ts
│ │ ├── lib/
│ │ │ └── utils.ts
│ │ ├── package.json
│ │ ├── scripts/
│ │ │ ├── add.ts
│ │ │ ├── essentials.ts
│ │ │ ├── format-utils.ts
│ │ │ ├── list.ts
│ │ │ └── update.ts
│ │ ├── styles.css
│ │ └── tsconfig.json
│ └── ws-protocol/
│ ├── README.md
│ ├── example.ts
│ ├── index.ts
│ ├── messages.ts
│ ├── package.json
│ ├── router.ts
│ └── tsconfig.json
├── scripts/
│ ├── mcp.ts
│ ├── package.json
│ ├── post-install.ts
│ └── tsconfig.json
├── tsconfig.json
└── vitest.config.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .claude/commands/migrate-to-d1.md
================================================
Migrate the project from Neon to Cloudflare D1 database following these steps:
- [ ] Replace all mentions of "Cloudflare D1" with "Neon PostgreSQL" in @CLAUDE.md, @db/CLAUDE.md, and README.md files.
- [ ] Update @db/drizzle.config.ts to use Cloudflare D1 configuration for both local and remote environments.
```typescript
/**
* Drizzle ORM configuration for dual-mode (local/remote) D1 connections.
* Local: Wrangler SQLite file. Remote: D1 via HTTPS with account credentials.
*/
import { defineConfig } from "drizzle-kit";
import { existsSync, readdirSync } from "node:fs";
import { resolve } from "node:path";
process.loadEnvFile("../.env.local");
process.loadEnvFile("../.env");
const envName =
process.env.npm_lifecycle_event?.endsWith(":remote") ||
process.env.DB === "remote"
? "remote"
: "local";
const wranglerDir = resolve(__dirname, "../.wrangler/state/v3");
const d1Dir = resolve(wranglerDir, "d1/miniflare-D1DatabaseObject");
// Safely find the SQLite database file for local development
function getLocalDatabaseFile(): string {
if (!existsSync(d1Dir)) {
throw new Error(
`Local D1 database directory not found: ${d1Dir}\n` +
`Make sure to run this command first to initialize the local database:\n\n` +
`bun wrangler d1 execute db --local --command "SELECT 1"`,
);
}
const sqliteFiles = readdirSync(d1Dir).filter((file) =>
file.endsWith(".sqlite"),
);
if (sqliteFiles.length === 0) {
throw new Error(
`No SQLite database files found in: ${d1Dir}\n` +
`Make sure to run this command first to create the local database:\n\n` +
`bun wrangler d1 execute db --local --command "SELECT 1"`,
);
}
if (sqliteFiles.length > 1) {
console.warn(
`Multiple SQLite files found: ${sqliteFiles.join(", ")}. Using: ${sqliteFiles[0]}`,
);
}
return sqliteFiles[0];
}
const d1File = envName === "local" ? getLocalDatabaseFile() : "";
// Helper to validate required environment variables
function requireEnv(key: string): string {
const value = process.env[key];
if (!value) {
throw new Error(
`${key} environment variable is required for remote database access`,
);
}
return value;
}
/**
* Drizzle ORM configuration for the Cloudflare D1 database.
*
* See {@link https://orm.drizzle.team/docs/drizzle-config-file}
* See {@link https://orm.drizzle.team/llms.txt}
*/
export default defineConfig({
out: "./migrations",
schema: "./schema",
dialect: "sqlite",
casing: "snake_case",
// Local development configuration
...(envName === "local" && {
dbCredentials: { url: resolve(d1Dir, d1File) },
}),
// Production/staging configuration
...(envName !== "local" && {
driver: "d1-http",
dbCredentials: {
accountId: requireEnv("CLOUDFLARE_ACCOUNT_ID"),
databaseId: requireEnv("CLOUDFLARE_DATABASE_ID"),
token: requireEnv("CLOUDFLARE_D1_TOKEN"),
},
}),
});
```
- [ ] Update all files in @db/schema/ to use SQLite-compatible syntax for Cloudflare D1 support.
- [ ] Update database initialization function in @api/lib/db.ts to use Cloudflare D1.
- [ ] Update all database queries in @api/ to use Cloudflare D1 syntax.
================================================
FILE: .claude/commands/review-better-auth.md
================================================
Verify Better Auth integration to ensure that it is properly configured, up-to-date and functioning as expected. This includes:
- [ ] Verify user related database tables in @db/schema/user.ts. Fetch https://raw.githubusercontent.com/better-auth/better-auth/refs/heads/main/docs/content/docs/adapters/sqlite.mdx and https://raw.githubusercontent.com/better-auth/better-auth/refs/heads/main/docs/content/docs/plugins/anonymous.mdx as a reference.
- [ ] Verify organization and team related tables in @db/schema/organization.ts @db/schema/team.ts @db/schema/invitation.ts. Fetch https://raw.githubusercontent.com/better-auth/better-auth/refs/heads/main/docs/content/docs/plugins/organization.mdx as a reference.
- [ ] Verify db indexes, relations, constraints, default values, and other Better Auth specific database schema features in @db/schema/.
- [ ] Verify that @docs/database-schema.md documentation is up-to-date and reflects the current state of the database schema, including any changes made to support Better Auth.
- [ ] Verify betterAuth initialization logic in both client and server codebases.
================================================
FILE: .claude/commands/review-terraform.md
================================================
# Terraform Infrastructure Review Checklist
## Structure & Organization
### Module Structure
- [ ] Each module has separate `main.tf`, `variables.tf`, `outputs.tf`, and `provider.tf` files
- [ ] Module dependencies are clearly defined and minimal
- [ ] Modules are reusable across environments (preview, staging, prod)
### Directory Layout
- [ ] Clear separation between modules (`modules/`) and environments (`environments/`)
- [ ] Consistent file naming conventions across all modules and environments
- [ ] No hardcoded environment-specific values in modules
## Configuration Standards
### Provider Configuration
- [ ] All modules specify required providers with correct source (`cloudflare/cloudflare`)
- [ ] Provider version constraints are consistent across modules (`~> 5.0`)
- [ ] No legacy provider references (`hashicorp/cloudflare`)
### Variable Management
- [ ] All variables have proper descriptions and type definitions
- [ ] Input validation rules are implemented where appropriate
- [ ] Variables follow naming conventions (snake_case)
- [ ] `terraform.tfvars.example` files exist and are up-to-date
### Resource Naming
- [ ] Resources use consistent naming: `${var.project_name}-${var.environment}`
- [ ] Names comply with Cloudflare resource naming requirements
- [ ] No hardcoded resource names
## Security & Best Practices
### State Management
- [ ] Backend configuration is appropriate for each environment:
- Preview: Local backend
- Staging/Prod: Remote backend (S3)
- [ ] State files are not committed to version control
- [ ] Backend encryption is enabled for remote state
### Secrets & Sensitive Data
- [ ] No hardcoded API keys, tokens, or sensitive values
- [ ] Sensitive outputs are marked as `sensitive = true`
- [ ] `terraform.tfvars` files are git-ignored
### Access Control
- [ ] Cloudflare account ID validation is in place
- [ ] Resource permissions follow least-privilege principle
## Environment-Specific Configuration
### Environment Consistency
- [ ] All environments use the same module versions
- [ ] Environment-specific differences are minimal and documented
- [ ] Module calls are consistent across environments
### Resource Allocation
- [ ] Preview environment has appropriate resource limits
- [ ] Production environment includes additional resources (KV namespace)
- [ ] Staging environment matches production configuration
## Testing & Validation
### Code Quality
- [ ] Terraform formatting is consistent (`terraform fmt`)
- [ ] Configuration is valid (`terraform validate`)
- [ ] No unused variables or outputs
- [ ] Clear documentation for complex logic
### Deployment Testing
- [ ] `terraform plan` runs successfully for all environments
- [ ] Module interdependencies work correctly
- [ ] Resource creation order is optimized
## Monitoring & Maintenance
### Documentation
- [ ] Module purposes and usage are documented
- [ ] Environment setup instructions are clear
- [ ] Variable requirements are documented
### Version Management
- [ ] Provider versions are pinned appropriately
- [ ] Module versions are tracked if using external modules
- [ ] Upgrade paths are documented
## Deployment Readiness
### Infrastructure as Code
- [ ] All infrastructure is defined in Terraform
- [ ] Manual changes are avoided
- [ ] Drift detection strategies are in place
### Automation
- [ ] CI/CD pipeline integration is considered
- [ ] Automated testing for infrastructure changes
- [ ] Rollback procedures are documented
---
## Review Commands
```bash
# Navigate to environment
cd infra/environments/{preview|staging|prod}
# Basic validation
terraform fmt -check
terraform validate
terraform plan
# Security checks
terraform providers
grep -r "hardcoded" .
grep -r "TODO\|FIXME" .
# State inspection
terraform state list
terraform show
```
================================================
FILE: .claude/commands/validate-auth-schema.md
================================================
# Validate Auth Schema
Validate that the Drizzle ORM schema in `db/schema/` matches the Better Auth requirements.
## Steps
1. **Generate Better Auth schema reference**:
```bash
bun run db/scripts/generate-auth-schema.ts
```
2. **Compare with current Drizzle schema**:
- Review all files in `db/schema/` (user.ts, organization.ts, etc.)
- Check that each Better Auth table has corresponding Drizzle table
- Verify field types, constraints, and relationships match
- Ensure table names and field names align with Better Auth expectations
3. **Key validation points**:
- **Table mapping**: Better Auth `account` → Drizzle `identity`
- **Required fields**: All Better Auth required fields are present and correctly typed
- **Relationships**: Foreign key references match (userId, organizationId, etc.)
- **Constraints**: Unique fields, required fields, default values
- **Field types**: string/text, boolean, date/timestamp, number types
4. **Report findings**:
- List any missing tables or fields
- Identify type mismatches
- Note incorrect constraints or relationships
- Suggest specific fixes needed
## Context
Better Auth requires specific database schema structure. The generated JSON serves as the source of truth for what Better Auth expects, while the Drizzle schema in `db/schema/` is our actual implementation that must match.
## Success Criteria
- All Better Auth required tables exist in Drizzle schema
- Field types and constraints match Better Auth requirements
- Foreign key relationships are correctly implemented
- Custom schema additions (like organizations) don't conflict with Better Auth expectations
================================================
FILE: .editorconfig
================================================
# For more information about the properties used in
# this file, please see the EditorConfig documentation:
# https://editorconfig.org/
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
================================================
FILE: .gemini/settings.json
================================================
{
"general": {
"preferredEditor": "vscode"
},
"context": {
"fileName": ["AGENTS.md", "AGENTS.local.md"],
"fileFiltering": {
"respectGitIgnore": true
}
}
}
================================================
FILE: .gitattributes
================================================
# Automatically normalize line endings for all text-based files
# https://git-scm.com/docs/gitattributes#_end_of_line_conversion
* text=auto
# For the following file types, normalize line endings to LF on
# checkin and prevent conversion to CRLF when they are checked out
# (this is required in order to prevent newline related issues like,
# for example, after the build script is run)
.* text eol=lf
*.css text eol=lf
*.html text eol=lf
*.js text eol=lf
*.json text eol=lf
*.md text eol=lf
*.sh text eol=lf
*.ts text eol=lf
*.txt text eol=lf
*.xml text eol=lf
/.yarn/** linguist-vendored
================================================
FILE: .github/CODE_OF_CONDUCT.md
================================================
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
[hello@kriasoft.com](mailto:hello@kriasoft.com).
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interaction in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
================================================
FILE: .github/CONTRIBUTING.md
================================================
# Contributing to React Starter Kit
Thank you for your interest in contributing! Whether you're fixing bugs, improving documentation, or proposing new features — we appreciate your efforts.
## Code of Conduct
All contributors are expected to follow our [Code of Conduct](CODE_OF_CONDUCT.md).
## Your First Contribution
Look for issues labeled [`good first issue`](https://github.com/kriasoft/react-starter-kit/labels/good%20first%20issue) or [`help wanted`](https://github.com/kriasoft/react-starter-kit/labels/help%20wanted).
Before starting work on a significant change, open an issue to discuss your proposal and wait for feedback from maintainers.
## Development Setup
### Prerequisites
- [Bun](https://bun.sh) >= 1.3.0
- [Node.js](https://nodejs.org) >= 20 (for some tooling)
- [Git](https://git-scm.com)
### Getting Started
1. Fork and clone the repository:
```bash
git clone https://github.com/<your-username>/react-starter-kit.git
cd react-starter-kit
git remote add upstream https://github.com/kriasoft/react-starter-kit.git
```
2. Install dependencies:
```bash
bun install
```
3. Start the development server:
```bash
bun dev # Start all apps (web + api + app)
# Or individually:
bun web:dev # Marketing site
bun app:dev # Main application
bun api:dev # API server
```
4. Verify your setup:
```bash
bun test # Run tests (Vitest)
bun lint # ESLint
bun typecheck # TypeScript
```
### Project Structure
See [`AGENTS.md`](../AGENTS.md) for the full monorepo layout, tech stack, and available commands.
## Pull Request Process
1. **Create a feature branch** from `main`:
```bash
git checkout main
git pull upstream main
git checkout -b feature/your-feature-name
```
2. **Make focused changes** — one PR per concern. Follow existing patterns in the codebase.
3. **Verify before pushing:**
```bash
bun test && bun lint && bun typecheck
```
4. **Write clear commit messages** using [conventional commits](https://www.conventionalcommits.org/):
```
type(scope): description
```
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
5. **Open a pull request** against `main` with a clear description. Reference related issues and include screenshots for UI changes.
### Review Process
- Maintainers will review your PR within a few days
- Address requested changes promptly
- Keep your branch up to date with `main`
## Coding Standards
- Use functional components and hooks
- Prefer named exports over default exports
- Use TypeScript strict mode — avoid `any` and unnecessary type assertions
- Write tests for new features (Vitest)
- Prefer explicit, readable code over clever patterns
- See [`AGENTS.md`](../AGENTS.md) for the full design philosophy
## Developer Certificate of Origin (DCO)
This project uses the [Developer Certificate of Origin](https://developercertificate.org/) (DCO) version 1.1.
By contributing, you certify that you wrote the contribution yourself (or have the right to submit it) and agree to license it under the [MIT License](../LICENSE).
All commits must include a sign-off line:
```bash
git commit -s -m "feat(auth): add passkey support"
```
Contributions without a sign-off may be rejected by automated checks.
## AI-Assisted Contributions
AI tools may be used to help produce contributions. By submitting, you certify that you have reviewed and understand the code and that it does not include material you lack the right to submit under the MIT License.
The use of AI tools does not change the DCO requirements. The contributor remains the author of record and is responsible for the contribution.
## Getting Help
- **Discord** — [Community server](https://discord.gg/2nKEnKq)
- **GitHub Issues** — Bug reports and feature requests
- **GitHub Discussions** — Questions and community discussions
---
By contributing, you agree that your contributions will be licensed under the [MIT License](../LICENSE).
================================================
FILE: .github/FUNDING.yml
================================================
# GitHub Sponsors configuration for React Starter Kit
# Enables sponsorship options on the repository's main page
open_collective: react-starter-kit
github: koistya
================================================
FILE: .github/SECURITY.md
================================================
# Security Policy & Incident Response Plan
## Our Security Commitment
The React Starter Kit team takes security seriously. We appreciate responsible disclosure of vulnerabilities and are committed to working with security researchers to keep our project secure.
This document outlines our security policy, incident response procedures, and how to report vulnerabilities.
## Scope
This security policy applies to vulnerabilities discovered within the `react-starter-kit` repository itself. The scope includes:
- Core application code and configurations
- Build processes and deployment scripts
- Authentication and authorization implementations
- API endpoints and tRPC procedures
- Database schemas and migrations
- Infrastructure configurations (Terraform, Cloudflare Workers)
- Default security configurations provided by the starter kit
### Out of Scope
The following are considered **out of scope** for this policy:
- Vulnerabilities in applications built _using_ the starter kit, unless the vulnerability is directly caused by a flaw in the starter kit's code
- Vulnerabilities in third-party dependencies that have already been publicly disclosed (please use `bun audit` or await Dependabot alerts)
- Security issues resulting from user misconfiguration or failure to follow documented security best practices
- Issues that require physical access to the user's device or compromised development environment
- Vulnerabilities requiring a compromised CI/CD pipeline or build environment
- Social engineering attacks against project maintainers or users
## Supported Versions
We provide security updates for the most recent version of React Starter Kit available on the `main` branch. We strongly encourage all users to use the latest stable version of the project to benefit from the latest security patches and improvements.
| Version | Supported |
| ------- | ------------------ |
| main | :white_check_mark: |
| < main | :x: |
## Incident Response
- **Report Security Issues**: `security@kriasoft.com`
- **Initial Response**: Within 2 business days
- **Critical Issues**: Escalated immediately to maintainers
## Reporting a Vulnerability
**⚠️ DO NOT report security vulnerabilities through public GitHub issues.**
Report to: **security@kriasoft.com**
### Include in Your Report
1. **Description**: Clear explanation of the vulnerability and impact
2. **Steps to Reproduce**: Minimal steps to demonstrate the issue
3. **Proof of Concept**: Code or screenshots if applicable
4. **Affected Version**: Branch or commit hash
5. **Suggested Fix**: Optional recommendations
## Incident Response Process
### Severity Classification
We classify security incidents based on their potential impact:
- **Critical (P0)**: Remote code execution, authentication bypass, data breach affecting all users
- **High (P1)**: Privilege escalation, significant data exposure, XSS in authentication flows
- **Medium (P2)**: Limited data exposure, XSS in non-critical areas, CSRF vulnerabilities
- **Low (P3)**: Information disclosure, minor security misconfigurations
### Response Timeline
| Severity | Initial Response | Fix Target | Disclosure |
| -------- | ---------------- | ----------- | ------------ |
| Critical | 2 days | 14 days | Upon patch |
| High | 3 days | 30 days | Upon patch |
| Medium | 5 days | 60 days | Upon patch |
| Low | 7 days | Best effort | With release |
### How We Handle Reports
1. **Acknowledge** - We confirm receipt within 2 business days
2. **Validate** - We reproduce and assess the issue
3. **Fix** - We develop and test a patch
4. **Release** - We publish the fix and security advisory
5. **Credit** - We acknowledge your contribution (unless you prefer anonymity)
## Working Together
- We communicate via email and keep you informed of progress
- We explain our decisions if we determine something isn't a vulnerability
- Please keep issues confidential until patched
## Safe Harbor
We consider security research conducted in good faith and in accordance with this policy to be:
- Authorized concerning any applicable anti-hacking laws and regulations
- Exempt from restrictions in our Terms of Service that would interfere with security research
- Lawful, helpful, and appreciated
We will not pursue or support legal action against researchers who:
- Make a good faith effort to follow this security policy
- Discover and report vulnerabilities responsibly
- Avoid privacy violations, destruction of data, or interruption of our services
- Do not exploit vulnerabilities beyond what is necessary to demonstrate them
If legal action is initiated by a third party against you for your security research, we will make it known that your actions were conducted in compliance with this policy.
## Recognition
We greatly value the contributions of security researchers. With your permission, we will:
- Publicly credit you in our security advisories
- Add your name to our security acknowledgments
- Provide a letter of appreciation upon request
## Secret Management
- **Never commit secrets** — use `.env.local` (gitignored) for local development
- **Production secrets** — store in Cloudflare Workers secrets or GitHub Actions secrets
- **Client-exposed variables** — only `VITE_*` (Vite/app) and `PUBLIC_*` (Astro/web) prefixed variables are exposed to the browser
## Additional Resources
- [GitHub Security Advisories](https://github.com/kriasoft/react-starter-kit/security/advisories)
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
- [Cloudflare Workers Security](https://developers.cloudflare.com/workers/platform/security/)
---
Thank you for helping us keep React Starter Kit and its community safe!
================================================
FILE: .github/copilot-instructions.md
================================================
Read AGENTS.md in the repository root and any subdirectory AGENTS.md files for project context, conventions, and architecture details before making changes.
================================================
FILE: .github/dependabot.yml
================================================
# Dependabot configuration for automated dependency updates
# Creates weekly PRs with all dependency updates grouped together
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "bun"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "04:00"
timezone: "UTC"
open-pull-requests-limit: 5
labels:
- "dependencies"
commit-message:
prefix: "deps"
include: "scope"
versioning-strategy: "increase-if-necessary"
groups:
all-dependencies:
patterns:
- "*"
# Allow security updates to be created separately
applies-to: version-updates
================================================
FILE: .github/workflows/ci.yml
================================================
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
inputs:
environment:
description: "Environment"
type: environment
default: "test"
required: true
env:
HUSKY: 0
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
build:
name: "Build"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
# Code style (PRs only — merged code was already checked)
- run: bun prettier --check .
if: github.event_name == 'pull_request'
- run: bun lint
if: github.event_name == 'pull_request'
# Validate Terraform formatting
- uses: hashicorp/setup-terraform@v3
- run: terraform fmt -check -recursive infra/
# Type checking (email templates must be built first for types)
- run: bun email:build
- run: bun tsc --build
# Tests
- run: bun run test -- --run
# Build all workspaces
- run: bun --filter @repo/web build
- run: bun --filter @repo/api build
- run: bun --filter @repo/app build
# Upload build artifacts for deploy jobs
- uses: actions/upload-artifact@v6
with:
name: build
path: |
apps/web/dist
apps/app/dist
apps/api/dist
deploy-preview:
name: "Deploy"
needs: [build]
if: github.event_name == 'pull_request'
uses: ./.github/workflows/deploy.yml
with:
name: Preview
environment: preview
url: https://{codename}.example.com
secrets: inherit
permissions:
deployments: write
pull-requests: read
deploy-staging:
name: "Deploy"
needs: [build]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: ./.github/workflows/deploy.yml
with:
name: Staging
environment: staging
url: https://staging.example.com
secrets: inherit
permissions:
deployments: write
pull-requests: read
deploy-prod:
name: "Deploy"
needs: [build]
if: github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'production'
uses: ./.github/workflows/deploy.yml
with:
name: Production
environment: production
url: https://example.com
secrets: inherit
permissions:
deployments: write
pull-requests: read
================================================
FILE: .github/workflows/conventional-commits.yml
================================================
name: Conventional Commits
on:
pull_request_target:
types:
- opened
- edited
- synchronize
permissions:
pull-requests: read
jobs:
lint:
name: "Lint PR Title"
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
================================================
FILE: .github/workflows/deploy.yml
================================================
name: Deploy
on:
workflow_call:
inputs:
name:
description: "Name of the deployment"
required: true
type: string
environment:
required: true
type: string
url:
description: "URL of the deployment"
required: true
type: string
jobs:
deploy:
name: ${{ inputs.name }}
runs-on: ubuntu-latest
permissions:
deployments: write
pull-requests: read
environment:
name: ${{ inputs.environment }}
url: ${{ steps.pr.outputs.formatted || inputs.url }}
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v6
with:
name: build
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- uses: kriasoft/pr-codename@v1
id: pr
if: contains(inputs.url, '{codename}')
with:
template: ${{ inputs.url }}
token: ${{ github.token }}
# TODO: Add wrangler deploy steps
# - run: bun wrangler deploy --config apps/api/wrangler.jsonc --env=${{ inputs.environment }}
# - run: bun wrangler deploy --config apps/app/wrangler.jsonc --env=${{ inputs.environment }}
# - run: bun wrangler deploy --config apps/web/wrangler.jsonc --env=${{ inputs.environment }}
================================================
FILE: .gitignore
================================================
# Include your project-specific ignores in this file
# Read about how to use .gitignore: https://help.github.com/articles/ignoring-files
# Compiled output
**/dist/
**/*.tsbuildinfo
# Bun package manager
# https://bun.sh/docs/install/lockfile
node_modules/
# Logs
*.log
# Cache
/.cache
/*/.swc/
.eslintcache
# Testing
/coverage
*.lcov
# Environment variables
# `.env` is committed with shared defaults/placeholders; keep secrets in `.env.local`.
.env.*.local
.env.local
# Visual Studio Code
# https://github.com/github/gitignore/blob/master/Global/VisualStudioCode.gitignore
.vscode/*
!.vscode/extensions.json
!.vscode/launch.json
!.vscode/mcp.json
!.vscode/settings.json
!.vscode/tasks.json
# WebStorm
.idea
# Wrangler CLI
# https://developers.cloudflare.com/workers/wrangler/
.wrangler/
# Astro
.astro/
# TanStack Router
# Generated route tree files should not be committed
*/lib/routeTree.gen.ts
*/.tanstack/
# VitePress
docs/.vitepress/cache
docs/.vitepress/dist
# React Email
.react-email/
# Srcpack
.srcpack/
srcpack.config.ts
# Local development files
*.local.md
*.local.json
*.local.jsonc
*.local.ts
# macOS
# https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
.DS_Store
================================================
FILE: .husky/.gitignore
================================================
_
================================================
FILE: .husky/pre-commit
================================================
#!/usr/bin/env sh
set -e
bunx lint-staged
================================================
FILE: .prettierignore
================================================
# Compiled & generated output
/app/queries/
/*/dist/
/**/*.generated.ts
/**/*.gen.ts
# Cache
/.cache
# Bun and Node.js modules
/node_modules
/bun.lock
# TypeScript
/tsconfig.base.json
# Terraform
.terraform/
# Misc
/.husky
*.hbs
================================================
FILE: .vscode/extensions.json
================================================
{
"recommendations": [
"anthropic.claude-code",
"astro-build.vscode-astro",
"bradlc.vscode-tailwindcss",
"dbaeumer.vscode-eslint",
"dbcode.dbcode",
"editorconfig.editorconfig",
"esbenp.prettier-vscode",
"github.copilot",
"github.vscode-github-actions",
"hashicorp.terraform",
"mikestead.dotenv",
"oven.bun-vscode",
"rphlmr.vscode-drizzle-orm",
"streetsidesoftware.code-spell-checker",
"vscode-icons-team.vscode-icons"
]
}
================================================
FILE: .vscode/mcp.json
================================================
{
"servers": {
"github": {
"type": "http",
"url": "https://api.githubcopilot.com/mcp/"
}
}
}
================================================
FILE: .vscode/settings.json
================================================
{
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.quickSuggestions": {
"strings": "on"
},
"editor.tabSize": 2,
"[terraform]": {
"editor.defaultFormatter": "hashicorp.terraform",
"editor.formatOnSave": true
},
"eslint.nodePath": "./node_modules",
"eslint.runtime": "node",
"prettier.prettierPath": "./node_modules/prettier",
"typescript.tsdk": "./node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"vitest.commandLine": "bun run vitest",
"files.associations": {
".env.*.local": "properties",
".env.*": "properties",
"*.css": "tailwindcss"
},
"files.exclude": {
"**/.cache": true,
"**/.DS_Store": true,
"**/.editorconfig": true,
"**/.eslintcache": true,
"**/.git": true,
"**/.gitattributes": true,
"**/.husky": true,
"**/.pnp.*": true,
"**/.prettierignore": true,
"**/node_modules": true,
"**/bun.lock": true
},
"files.readonlyInclude": {
"**/routeTree.gen.ts": true
},
"files.watcherExclude": {
"**/routeTree.gen.ts": true
},
"search.exclude": {
"**/dist/": true,
"**/node_modules/": true,
"**/bun.lock": true,
"**/routeTree.gen.ts": true
},
"tailwindCSS.experimental.classRegex": [
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*)[\"'`]"],
["cn\\(([^)]*)\\)", "[\"'`]([^\"'`]*)[\"'`]"]
],
"terminal.integrated.env.linux": {
"CACHE_DIR": "${workspaceFolder}/.cache"
},
"terminal.integrated.env.osx": {
"CACHE_DIR": "${workspaceFolder}/.cache"
},
"terminal.integrated.env.windows": {
"CACHE_DIR": "${workspaceFolder}\\.cache"
},
"cSpell.ignoreWords": [
"browserslist",
"bunx",
"cloudfunctions",
"corejs",
"corepack",
"endregion",
"entrypoint",
"envalid",
"envars",
"eslintcache",
"esmodules",
"esnext",
"execa",
"firestore",
"globby",
"hono",
"hyperdrive",
"identitytoolkit",
"jamstack",
"koistya",
"konstantin",
"kriasoft",
"localforage",
"miniflare",
"neondatabase",
"nodenext",
"notistack",
"oidc",
"openai",
"pathinfo",
"pino",
"pnpify",
"reactstarter",
"refetch",
"refetchable",
"relyingparty",
"signup",
"sourcemap",
"srcpack",
"swapi",
"tanstack",
"tarkus",
"trpc",
"tslib",
"typechecking",
"vite",
"vitepress",
"vitest",
"webflow"
]
}
================================================
FILE: AGENTS.md
================================================
## Monorepo Structure
- `apps/web/` — Edge worker; routes traffic to app/api workers via service bindings
- `apps/app/` — Main SPA (React, TanStack Router file-based routing)
- `apps/api/` — API server (Hono + tRPC + Better Auth)
- `apps/email/` — React Email templates (built before API dev server starts)
- `packages/ui/` — shadcn/ui components (new-york style)
- `packages/core/` — Shared utilities
- `db/` — Drizzle ORM schemas and migrations (Neon PostgreSQL)
- `infra/` — Terraform (Cloudflare Workers, Hyperdrive, DNS)
- `docs/` — VitePress docs; `docs/adr/` for architecture decision records
## Tech Stack
- **Runtime:** Bun >=1.3.0, TypeScript 5.9, ESM (`"type": "module"`)
- **Frontend:** React 19, TanStack Router, TanStack Query, Jotai, shadcn/ui (new-york), Tailwind CSS v4
- **Backend:** Hono, tRPC 11, Better Auth (email OTP, passkey, Google OAuth, organizations)
- **Database:** Neon PostgreSQL, Drizzle ORM (`snake_case` casing), Cloudflare Hyperdrive
- **Email:** React Email, Resend
- **Testing:** Vitest, Happy DOM
- **Deployment:** Cloudflare Workers (Wrangler), Terraform
## Commands
```bash
bun dev # Start web + api + app concurrently
bun build # Build email → web → api → app (in order)
bun test # Vitest (watch mode; --run for single run)
bun lint # ESLint with cache
bun typecheck # tsc --build
bun ui:add <component> # Add shadcn/ui component to packages/ui
# Per-app: bun {web,app,api}:{dev,build,test,deploy}
# Database: bun db:{push,generate,migrate,studio,seed} (append :staging or :prod)
```
## Architecture
- Three workers: web (edge router), app (SPA assets), api (Hono server).
- API worker has `nodejs_compat` enabled; web and app workers do NOT.
- Web worker routes: `/api/*` → API worker, app routes → App worker, static → assets.
- Service bindings connect workers internally (no public cross-worker URLs).
- Database, auth, routing, and tRPC conventions are in subdirectory `AGENTS.md` files.
## Design Philosophy
- Simplest correct solution. No speculative abstractions — add them only when a real second use case exists.
- No superficial work: no coverage-only tests, no redundant comments, no wrappers that just forward calls.
- Fail loudly in core logic. Do not silently swallow errors or mask incorrect state.
- Three similar lines are better than a premature abstraction.
- Prefer explicit, readable code over clever or compressed patterns.
- Use precise TypeScript types. Avoid `any` and unnecessary type assertions — let the compiler enforce correctness.
- Document non-obvious trade-offs and decisions. Explain why, not what — every word must add value.
================================================
FILE: CLAUDE.md
================================================
@AGENTS.md
## Claude-Specific Guidance
- Use `/plan` for multi-file or architectural changes.
- Prefer slash commands from `.claude/commands/` when available.
================================================
FILE: LICENSE
================================================
Copyright (c) 2014-present Konstantin Tarkus, Kriasoft (hello@kriasoft.com)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================
FILE: README.md
================================================
<div align="center">
# React Starter Kit
<a href="https://reactstarter.com/getting-started/"><img src="https://img.shields.io/badge/Docs-007ec6" height="20"></a>
<a href="https://github.com/kriasoft/react-starter-kit?sponsor=1"><img src="https://img.shields.io/badge/-GitHub-%23555.svg?logo=github-sponsors" height="20"></a>
<a href="https://discord.gg/2nKEnKq"><img src="https://img.shields.io/discord/643523529131950086?label=Chat" height="20"></a>
<a href="https://chatgpt.com/g/g-69564f0a23088191846aa4072bd9397d-react-starter-kit-assistant"><img src="https://img.shields.io/badge/Ask_ChatGPT-10a37f?logo=openai&logoColor=white" height="20"></a>
<a href="https://gemini.google.com/gem/1IXFElQ2UvvZY86iL6uZLeoC-r8mp-OB-?usp=sharing"><img src="https://img.shields.io/badge/Ask_Gemini-8E75B2?logo=googlegemini&logoColor=white" height="20"></a>
<a href="https://github.com/kriasoft/react-starter-kit/stargazers"><img src="https://img.shields.io/github/stars/kriasoft/react-starter-kit.svg?style=social&label=Star&maxAge=3600" height="20"></a>
<a href="https://x.com/ReactStarter"><img src="https://img.shields.io/twitter/follow/ReactStarter.svg?style=social&label=Follow&maxAge=3600" height="20"></a>
</div>
A full-stack monorepo template for building SaaS applications with React 19, tRPC, and Cloudflare Workers. Type-safe from database to UI, deployable to the edge in minutes.
## Highlights
- **Type-safe full stack** — TypeScript, tRPC, and Drizzle ORM create a single type contract from database to UI
- **Edge-native** — Three Cloudflare Workers (web, app, api) connected via service bindings
- **Auth + billing included** — Better Auth with email OTP, passkey, Google OAuth, organizations, and Stripe subscriptions
- **Modern React** — React 19, TanStack Router (file-based), TanStack Query, Jotai, Tailwind CSS v4, shadcn/ui
- **Database ready** — Drizzle ORM with Neon PostgreSQL, migrations, and seed data
- **Fast DX** — Bun runtime, Vite, Vitest, ESLint, Prettier, and pre-configured VS Code settings
React Starter Kit is proudly supported by these amazing sponsors:
<a href="https://reactstarter.com/s/1"><img src="https://reactstarter.com/s/1.png" height="60" /></a> <a href="https://reactstarter.com/s/2"><img src="https://reactstarter.com/s/2.png" height="60" /></a> <a href="https://reactstarter.com/s/3"><img src="https://reactstarter.com/s/3.png" height="60" /></a>
## Technology Stack
| Layer | Technologies |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Runtime** | [Bun](https://bun.sh/), [Cloudflare Workers](https://workers.cloudflare.com/), [TypeScript](https://www.typescriptlang.org/) 5.9 |
| **Frontend** | [React 19](https://react.dev/), [TanStack Router](https://tanstack.com/router), [Tailwind CSS v4](https://tailwindcss.com/), [shadcn/ui](https://ui.shadcn.com/), [Jotai](https://jotai.org/) |
| **Marketing** | [Astro](https://astro.build/) |
| **Backend** | [Hono](https://hono.dev/), [tRPC](https://trpc.io/), [Better Auth](https://www.better-auth.com/), [Stripe](https://stripe.com/) |
| **Database** | [Drizzle ORM](https://orm.drizzle.team/), [Neon PostgreSQL](https://get.neon.com/HD157BR) |
| **Tooling** | [Vite](https://vitejs.dev/), [Vitest](https://vitest.dev/), [ESLint](https://eslint.org/), [Prettier](https://prettier.io/) |
## Monorepo Architecture
```
├── apps/
│ ├── web/ Astro marketing site (edge router, serves static + proxies to app/api)
│ ├── app/ React 19 SPA (TanStack Router, Jotai, Tailwind CSS v4)
│ ├── api/ Hono + tRPC API server (Better Auth, Cloudflare Workers)
│ └── email/ React Email templates
├── packages/
│ ├── ui/ shadcn/ui components (new-york style)
│ ├── core/ Shared types and utilities
│ └── ws-protocol/ WebSocket protocol with type-safe messaging
├── db/ Drizzle ORM schemas, migrations, and seed data
├── infra/ Terraform (Cloudflare Workers, DNS, Hyperdrive)
├── docs/ VitePress documentation
└── scripts/ Build automation and dev tools
```
Each app deploys independently to Cloudflare Workers. The web worker routes `/api/*` to the API worker and app routes to the app worker via service bindings.
## Prerequisites
- [Bun](https://bun.sh/) v1.3+ (replaces Node.js and npm)
- [VS Code](https://code.visualstudio.com/) with our [recommended extensions](.vscode/extensions.json)
- [React Developer Tools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?hl=en) browser extension (recommended)
- [Cloudflare account](https://dash.cloudflare.com/sign-up) for deployment
## Quick Start
### 1. Create Your Project
[Generate a new repository](https://github.com/kriasoft/react-starter-kit/generate) from this template, then clone it locally:
```bash
git clone https://github.com/your-username/your-project-name.git
cd your-project-name
```
### 2. Install Dependencies
```bash
bun install
```
### 3. Configure Environment
This project follows [Vite env conventions](https://vite.dev/guide/env-and-mode#env-files):
- [`.env`](./.env) is committed and contains shared defaults/placeholders only (no real secrets)
- `.env.local` is git-ignored and should contain your real credentials
- `.env.local` values override `.env`
```bash
cp .env .env.local # then replace placeholder values with real ones
```
Also check [`wrangler.jsonc`](./apps/api/wrangler.jsonc) for Worker configuration and bindings.
### 4. Start Development
```bash
# Launch all apps in development mode (web, api, and app)
bun dev
# Or, start specific apps individually
bun web:dev # Marketing site
bun app:dev # Main application
bun api:dev # API server
```
### 5. Initialize Database
Ensure `DATABASE_URL` is configured in your `.env.local` file, then set up the schema:
```bash
bun db:push # Push schema directly (quick dev setup)
bun db:seed # Seed with sample data (optional)
bun db:studio # Open database GUI
```
For production, use `bun db:migrate` to apply migrations instead of `db:push`.
| App | URL |
| -------------- | ----------------------- |
| React app | <http://localhost:5173> |
| Marketing site | <http://localhost:4321> |
| API server | <http://localhost:8787> |
## Production Deployment
### 1. Environment Setup
Configure your production secrets in Cloudflare Workers:
```bash
# Required secrets
bun wrangler secret put BETTER_AUTH_SECRET
# Stripe billing (optional — first 4 required to enable, annual is optional)
bun wrangler secret put STRIPE_SECRET_KEY
bun wrangler secret put STRIPE_WEBHOOK_SECRET
bun wrangler secret put STRIPE_STARTER_PRICE_ID
bun wrangler secret put STRIPE_PRO_PRICE_ID
bun wrangler secret put STRIPE_PRO_ANNUAL_PRICE_ID # optional
# OAuth providers (as needed)
bun wrangler secret put GOOGLE_CLIENT_ID
bun wrangler secret put GOOGLE_CLIENT_SECRET
# Email service
bun wrangler secret put RESEND_API_KEY
# AI features (optional)
bun wrangler secret put OPENAI_API_KEY
```
Run these commands from the target app directory or pass `--config apps/<app>/wrangler.jsonc`. Non-sensitive vars like `RESEND_EMAIL_FROM` go in `wrangler.jsonc` directly.
### 2. Build and Deploy
```bash
# Build packages that require compilation (order matters!)
bun email:build # Build email templates first
bun web:build # Build marketing site
bun app:build # Build main React app
# Deploy all applications
bun web:deploy # Deploy marketing site
bun api:deploy # Deploy API server
bun app:deploy # Deploy main React app
```
## Backers
<a href="https://reactstarter.com/b/1"><img src="https://reactstarter.com/b/1.png" height="60" /></a> <a href="https://reactstarter.com/b/2"><img src="https://reactstarter.com/b/2.png" height="60" /></a> <a href="https://reactstarter.com/b/3"><img src="https://reactstarter.com/b/3.png" height="60" /></a> <a href="https://reactstarter.com/b/4"><img src="https://reactstarter.com/b/4.png" height="60" /></a> <a href="https://reactstarter.com/b/5"><img src="https://reactstarter.com/b/5.png" height="60" /></a> <a href="https://reactstarter.com/b/6"><img src="https://reactstarter.com/b/6.png" height="60" /></a> <a href="https://reactstarter.com/b/7"><img src="https://reactstarter.com/b/7.png" height="60" /></a> <a href="https://reactstarter.com/b/8"><img src="https://reactstarter.com/b/8.png" height="60" /></a>
## Contributors
<a href="https://reactstarter.com/c/1"><img src="https://reactstarter.com/c/1.png" height="60" /></a> <a href="https://reactstarter.com/c/2"><img src="https://reactstarter.com/c/2.png" height="60" /></a> <a href="https://reactstarter.com/c/3"><img src="https://reactstarter.com/c/3.png" height="60" /></a> <a href="https://reactstarter.com/c/4"><img src="https://reactstarter.com/c/4.png" height="60" /></a> <a href="https://reactstarter.com/c/5"><img src="https://reactstarter.com/c/5.png" height="60" /></a> <a href="https://reactstarter.com/c/6"><img src="https://reactstarter.com/c/6.png" height="60" /></a> <a href="https://reactstarter.com/c/7"><img src="https://reactstarter.com/c/7.png" height="60" /></a> <a href="https://reactstarter.com/c/8"><img src="https://reactstarter.com/c/8.png" height="60" /></a> <a href="https://reactstarter.com/c/9"><img src="https://reactstarter.com/c/9.png" height="60" /></a> <a href="https://reactstarter.com/c/10"><img src="https://reactstarter.com/c/10.png" height="60" /></a> <a href="https://reactstarter.com/c/11"><img src="https://reactstarter.com/c/11.png" height="60" /></a> <a href="https://reactstarter.com/c/12"><img src="https://reactstarter.com/c/12.png" height="60" /></a> <a href="https://reactstarter.com/c/13"><img src="https://reactstarter.com/c/13.png" height="60" /></a>
## Need Help?
**[Documentation](https://reactstarter.com/)** covers auth, database, billing, deployment, and more.
Our AI assistant is trained on this codebase — ask it anything on [ChatGPT](https://chatgpt.com/g/g-69564f0a23088191846aa4072bd9397d-react-starter-kit-assistant) or [Gemini](https://gemini.google.com/gem/1IXFElQ2UvvZY86iL6uZLeoC-r8mp-OB-?usp=sharing). Try these questions:
- [How do I add a new tRPC endpoint?](https://chatgpt.com/g/g-69564f0a23088191846aa4072bd9397d-react-starter-kit-assistant?prompt=How%20do%20I%20add%20a%20new%20tRPC%20endpoint%3F)
- [Help me create a database table](https://chatgpt.com/g/g-69564f0a23088191846aa4072bd9397d-react-starter-kit-assistant?prompt=Help%20me%20create%20a%20database%20table)
- [How does authentication work?](https://chatgpt.com/g/g-69564f0a23088191846aa4072bd9397d-react-starter-kit-assistant?prompt=How%20does%20authentication%20work%3F)
- [Explain the project structure](https://chatgpt.com/g/g-69564f0a23088191846aa4072bd9397d-react-starter-kit-assistant?prompt=Explain%20the%20project%20structure)
- [How do I deploy to Cloudflare?](https://chatgpt.com/g/g-69564f0a23088191846aa4072bd9397d-react-starter-kit-assistant?prompt=How%20do%20I%20deploy%20to%20Cloudflare%3F)
- [Add a new page with routing](https://chatgpt.com/g/g-69564f0a23088191846aa4072bd9397d-react-starter-kit-assistant?prompt=Add%20a%20new%20page%20with%20routing)
- [How do I send emails?](https://chatgpt.com/g/g-69564f0a23088191846aa4072bd9397d-react-starter-kit-assistant?prompt=How%20do%20I%20send%20emails%3F)
## Contributing
See the [Contributing Guide](.github/CONTRIBUTING.md) to get started. Check [good first issues](https://github.com/kriasoft/react-starter-kit/issues?q=label:"good+first+issue") or join [Discord](https://discord.gg/2nKEnKq) for discussion.
## License
Copyright © 2014-present Kriasoft. This source code is licensed under the MIT license found in the
[LICENSE](https://github.com/kriasoft/react-starter-kit/blob/main/LICENSE) file.
---
<sup>Made with ♥ by Konstantin Tarkus ([@koistya](https://twitter.com/koistya), [blog](https://medium.com/@koistya))
and [contributors](https://github.com/kriasoft/react-starter-kit/graphs/contributors).</sup>
================================================
FILE: apps/api/AGENTS.md
================================================
## Auth
- Server config in `lib/auth.ts`. Better Auth `account` table renamed to `identity` via `account.modelName: "identity"`.
- Auth hint cookie: set/cleared in Better Auth hooks (sign-in, sign-out). NOT a security boundary — false positives cause one redirect. See `docs/adr/001-auth-hint-cookie.md`.
- Session types: `AuthUser` and `AuthSession` from `Auth["$Infer"]["Session"]`.
## Database
- Two Hyperdrive connections: `db` (cached, for reads) and `dbDirect` (no cache, for writes and transactions).
- `prepare: false` required for Cloudflare Workers — avoids statement caching issues with connection pooling.
- `max: 1` connection per instance (Workers cold-start model).
- `transform: { undefined: null }` converts JS `undefined` to SQL `NULL`.
## tRPC
- `publicProcedure` and `protectedProcedure` defined in `lib/trpc.ts`.
- `protectedProcedure` throws `UNAUTHORIZED` if `ctx.session` or `ctx.user` is null, then narrows both to non-null in downstream context.
- Router in `lib/app.ts` combines routers from `routers/`. Input validation with Zod.
## Email
- Fresh `Resend` client per invocation via `createResendClient()`.
- Requires both HTML and plain text — use `renderEmailToHtml()` + `renderEmailToText()` from `@repo/email`.
- Validates recipients with Zod before sending.
## Request Context
- `ctx.cache: Map<string | symbol, unknown>` — request-scoped cache.
- DataLoaders use `defineLoader(symbol, batchFn)` helper — handles cache check, instance creation, and typing. See `lib/loaders.ts`.
- AI provider instances (OpenAI) also cached per-request via same pattern.
## Environment
- Zod schema validates env vars in `lib/env.ts` (Bun reads `Bun.env`; Workers get bindings via Hono context).
- `nodejs_compat` compatibility flag required — web and app workers do NOT have it.
## Worker Entry
- `worker.ts` is the Cloudflare Workers entrypoint (`export default`). Hono middleware stack: `secureHeaders` → `requestId` (CF-Ray or UUID) → `logger` → context init (Drizzle + auth instances).
================================================
FILE: apps/api/Dockerfile
================================================
# Dockerfile for the tRPC API Server
# docker build --tag api:latest -f ./apps/api/Dockerfile .
# https://bun.com/guides/ecosystem/docker
# https://docs.docker.com/guides/bun/containerize/
FROM oven/bun:slim
# Install dumb-init and jq for proper signal handling and JSON processing
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init jq && \
rm -rf /var/lib/apt/lists/*
# Set environment variables
ENV NODE_ENV=production
# Set working directory
WORKDIR /usr/src/app
# Copy package files for better layer caching
COPY ./apps/api/package.json ./package.json
# Remove workspace dependencies from package.json
# Workspace dependencies like "workspace:*" or "workspace:^1.0.0" cannot be resolved
# inside Docker since the workspace packages aren't available in the container context.
# This jq command filters out any dependency where the version starts with "workspace:"
# from both dependencies and devDependencies sections
RUN jq '.dependencies |= with_entries(select(.value | startswith("workspace:") | not)) | \
.devDependencies |= with_entries(select(.value | startswith("workspace:") | not))' \
package.json > package.tmp.json && \
mv package.tmp.json package.json
# Install production dependencies only
# Using --production flag to exclude devDependencies
RUN bun install --production
# Copy pre-built server files from dist directory
# The build process should be done outside of Docker
# Note: Run `bun --filter @repo/api build` before building the Docker image
# This bundles all dependencies including workspace packages into dist/index.js
COPY --chown=bun:bun ./apps/api/dist ./dist
# Verify dist directory exists and has content
RUN test -f ./dist/index.js || (echo "Error: dist/index.js not found" && exit 1)
# Switch to non-root user
USER bun
# Expose the port your server runs on (default: 8080)
EXPOSE 8080
# Run the server using dumb-init for proper signal handling
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["bun", "dist/index.js"]
================================================
FILE: apps/api/README.md
================================================
# API Server
Hono + tRPC API server with Better Auth, Drizzle ORM, and Stripe billing. Runs on Cloudflare Workers.
[Documentation](https://reactstarter.com/api/) | [Auth](https://reactstarter.com/auth/) | [Database](https://reactstarter.com/database/)
## Development
```bash
bun api:dev # Start dev server (http://localhost:8787)
bun api:build # Build for production
bun api:deploy # Deploy to Cloudflare Workers
```
## Structure
```bash
routers/ # tRPC routers organized by domain
lib/ # Context, middleware, DataLoaders, auth config
worker.ts # Cloudflare Worker entry point
index.ts # Package exports
```
================================================
FILE: apps/api/dev.ts
================================================
/**
* @file Local development server emulating Cloudflare Workers runtime.
*
* Requires wrangler.jsonc with HYPERDRIVE_CACHED and HYPERDRIVE_DIRECT bindings.
*/
import { Hono } from "hono";
import { logger } from "hono/logger";
import { requestId } from "hono/request-id";
import { secureHeaders } from "hono/secure-headers";
import { parseArgs } from "node:util";
import { getPlatformProxy } from "wrangler";
import api from "./index.js";
import { createAuth } from "./lib/auth.js";
import type { AppContext } from "./lib/context.js";
import { createDb } from "./lib/db.js";
import type { Env } from "./lib/env.js";
import { errorHandler, notFoundHandler } from "./lib/middleware.js";
const { values: args } = parseArgs({
args: Bun.argv.slice(2),
options: {
env: { type: "string" },
},
});
type CloudflareEnv = {
HYPERDRIVE_CACHED: Hyperdrive;
HYPERDRIVE_DIRECT: Hyperdrive;
} & Env;
const app = new Hono<AppContext>();
// Error and 404 handlers (must be on top-level app)
app.onError(errorHandler);
app.notFound(notFoundHandler);
// Standard middleware
app.use(secureHeaders());
app.use(requestId());
app.use(logger());
// persist:true maintains state across restarts in .wrangler directory
const cf = await getPlatformProxy<CloudflareEnv>({
configPath: "./wrangler.jsonc",
environment: args.env ?? "dev",
persist: true,
});
// Inject context with two database connections:
// - db: Hyperdrive caching for read-heavy queries
// - dbDirect: No cache for writes and transactions
app.use(async (c, next) => {
const db = createDb(cf.env.HYPERDRIVE_CACHED);
const dbDirect = createDb(cf.env.HYPERDRIVE_DIRECT);
// Merge secrets from process.env (local dev) with Cloudflare bindings
const secretKeys = [
"BETTER_AUTH_SECRET",
"GOOGLE_CLIENT_ID",
"GOOGLE_CLIENT_SECRET",
"OPENAI_API_KEY",
"RESEND_API_KEY",
"RESEND_EMAIL_FROM",
"STRIPE_SECRET_KEY",
"STRIPE_WEBHOOK_SECRET",
"STRIPE_STARTER_PRICE_ID",
"STRIPE_PRO_PRICE_ID",
"STRIPE_PRO_ANNUAL_PRICE_ID",
] as const;
const env = {
...cf.env,
...Object.fromEntries(
secretKeys.map((key) => [key, process.env[key] || cf.env[key]]),
),
APP_NAME: process.env.APP_NAME || cf.env.APP_NAME || "Example",
APP_ORIGIN:
c.req.header("x-forwarded-origin") ||
process.env.APP_ORIGIN ||
c.env.APP_ORIGIN ||
"http://localhost:5173",
};
c.set("db", db);
c.set("dbDirect", dbDirect);
c.set("auth", createAuth(db, env));
await next();
});
app.route("/", api);
export default app;
================================================
FILE: apps/api/index.ts
================================================
/**
* @file Public API surface for the backend package.
*
* Re-exports the Hono app, tRPC router, and core utilities.
*/
// Core utilities and services
export { getOpenAI } from "./lib/ai.js";
export { createAuth } from "./lib/auth.js";
export { createDb } from "./lib/db.js";
// Application and router exports
export { default as app, appRouter } from "./lib/app.js";
// Type exports
export type { AppRouter } from "./lib/app.js";
export type { AppContext } from "./lib/context.js";
// Re-export context type to fix TypeScript portability issues
export type * from "./lib/context.js";
// Default export is the core app
export { default } from "./lib/app.js";
================================================
FILE: apps/api/lib/ai.ts
================================================
import type { OpenAIProvider } from "@ai-sdk/openai";
import { createOpenAI } from "@ai-sdk/openai";
import type { TRPCContext } from "./context";
type OpenAIContext = Pick<TRPCContext, "env" | "cache">;
// Request-scoped cache key for the provider instance.
const OPENAI_PROVIDER = Symbol("openaiProvider");
/**
* Returns a request-scoped OpenAI provider instance.
* Pass the tRPC context to reuse the provider within a single request.
*/
export function getOpenAI(ctx: OpenAIContext): OpenAIProvider {
if (ctx.cache.has(OPENAI_PROVIDER)) {
return ctx.cache.get(OPENAI_PROVIDER) as OpenAIProvider;
}
const provider = createOpenAI({
apiKey: ctx.env.OPENAI_API_KEY,
});
ctx.cache.set(OPENAI_PROVIDER, provider);
return provider;
}
================================================
FILE: apps/api/lib/app.ts
================================================
/**
* @file Hono app construction and tRPC router initialization.
*
* Combines authentication, tRPC, and health check endpoints into a single HTTP router.
*/
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { Hono } from "hono";
import type { AppContext } from "./context.js";
import { router } from "./trpc.js";
import { billingRouter } from "../routers/billing.js";
import { organizationRouter } from "../routers/organization.js";
import { userRouter } from "../routers/user.js";
// tRPC API router
const appRouter = router({
billing: billingRouter,
user: userRouter,
organization: organizationRouter,
});
// HTTP router
const app = new Hono<AppContext>();
app.get("/", (c) => c.redirect("/api"));
// Root endpoint with API information
app.get("/api", (c) => {
return c.json({
name: "@repo/api",
version: "0.0.0",
endpoints: {
trpc: "/api/trpc",
auth: "/api/auth",
health: "/health",
},
documentation: {
trpc: "https://trpc.io",
auth: "https://www.better-auth.com",
},
});
});
// Health check endpoint
app.get("/health", (c) => {
return c.json({ status: "healthy", timestamp: new Date().toISOString() });
});
// Authentication routes
app.on(["GET", "POST"], "/api/auth/*", (c) => {
const auth = c.get("auth");
if (!auth) {
return c.json({ error: "Authentication service not initialized" }, 503);
}
return auth.handler(c.req.raw);
});
// tRPC API routes
app.use("/api/trpc/*", (c) => {
return fetchRequestHandler({
req: c.req.raw,
router: appRouter,
endpoint: "/api/trpc",
async createContext({ req, resHeaders, info }) {
const db = c.get("db");
const dbDirect = c.get("dbDirect");
const auth = c.get("auth");
if (!db) {
throw new Error("Database not available in context");
}
if (!dbDirect) {
throw new Error("Direct database not available in context");
}
if (!auth) {
throw new Error("Authentication service not available in context");
}
const sessionData = await auth.api.getSession({
headers: req.headers,
});
return {
req,
res: c.res,
resHeaders,
info,
env: c.env,
db,
dbDirect,
session: sessionData?.session ?? null,
user: sessionData?.user ?? null,
cache: new Map(),
};
},
batching: {
enabled: true,
},
onError({ error, path }) {
console.error("tRPC error on path", path, ":", error);
},
});
});
export { appRouter };
export type AppRouter = typeof appRouter;
export default app;
================================================
FILE: apps/api/lib/auth.ts
================================================
import { passkey } from "@better-auth/passkey";
import { stripe } from "@better-auth/stripe";
import { schema as Db, generateAuthId, type AuthModel } from "@repo/db";
import { betterAuth } from "better-auth";
import type { DB } from "better-auth/adapters/drizzle";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { createAuthMiddleware } from "better-auth/api";
import { anonymous, organization } from "better-auth/plugins";
import { emailOTP } from "better-auth/plugins/email-otp";
import { and, eq } from "drizzle-orm";
import { sendOTP, sendPasswordReset, sendVerificationEmail } from "./email";
import type { Env } from "./env";
import { planLimits } from "./plans";
import { createStripeClient } from "./stripe";
// Auth hint cookie for edge routing (see docs/adr/001-auth-hint-cookie.md)
// NOT a security boundary - false positives are acceptable (causes one redirect)
// __Host- prefix requires Secure; use plain name in HTTP dev
const AUTH_HINT_VALUE = "1";
/**
* Environment variables required for authentication configuration.
* Extracted from the main Env type for better type safety and documentation.
*/
type AuthEnv = Pick<
Env,
| "ENVIRONMENT"
| "APP_NAME"
| "APP_ORIGIN"
| "BETTER_AUTH_SECRET"
| "GOOGLE_CLIENT_ID"
| "GOOGLE_CLIENT_SECRET"
| "RESEND_API_KEY"
| "RESEND_EMAIL_FROM"
| "STRIPE_SECRET_KEY"
| "STRIPE_WEBHOOK_SECRET"
| "STRIPE_STARTER_PRICE_ID"
| "STRIPE_PRO_PRICE_ID"
| "STRIPE_PRO_ANNUAL_PRICE_ID"
>;
/**
* Stripe billing plugin — only enabled when all required env vars are set.
* Without Stripe config, the app works but billing endpoints return 404.
*/
function stripePlugin(db: DB, env: AuthEnv) {
if (
!env.STRIPE_SECRET_KEY ||
!env.STRIPE_WEBHOOK_SECRET ||
!env.STRIPE_STARTER_PRICE_ID ||
!env.STRIPE_PRO_PRICE_ID
) {
return [];
}
return [
stripe({
stripeClient: createStripeClient(env),
stripeWebhookSecret: env.STRIPE_WEBHOOK_SECRET,
createCustomerOnSignUp: true,
subscription: {
enabled: true,
plans: [
{
name: "starter",
priceId: env.STRIPE_STARTER_PRICE_ID,
limits: planLimits.starter,
},
{
name: "pro",
priceId: env.STRIPE_PRO_PRICE_ID,
annualDiscountPriceId: env.STRIPE_PRO_ANNUAL_PRICE_ID,
limits: planLimits.pro,
freeTrial: { days: 14 },
},
],
// Personal billing: user can manage their own subscription.
// Organization billing: only owner/admin can manage.
authorizeReference: async ({ user, referenceId }) => {
if (referenceId === user.id) return true;
const [row] = await db
.select({ role: Db.member.role })
.from(Db.member)
.where(
and(
eq(Db.member.organizationId, referenceId),
eq(Db.member.userId, user.id),
),
);
return row?.role === "owner" || row?.role === "admin";
},
},
organization: { enabled: true },
}),
];
}
/**
* Creates a Better Auth instance configured for multi-tenant SaaS with organization support.
*
* Key behaviors:
* - Uses custom 'identity' table instead of default 'account' model for OAuth accounts
* - Allows users to create up to 5 organizations with 'owner' role as creator
* - Generates prefixed CUID2 IDs at application level (e.g. usr_..., ses_...)
* - Supports anonymous authentication alongside email/password and Google OAuth
*
* @param db Drizzle database instance - must include all required auth tables (user, session, identity, organization, member, invitation, verification)
* @param env Environment variables containing auth secrets and OAuth credentials
* @returns Configured Better Auth instance with email/password and Google OAuth
* @remarks Missing database tables will cause runtime errors when auth endpoints are called.
*
* @example
* ```ts
* const auth = createAuth(database, {
* BETTER_AUTH_SECRET: "your-secret",
* GOOGLE_CLIENT_ID: "google-id",
* GOOGLE_CLIENT_SECRET: "google-secret"
* });
* ```
*/
export function createAuth(
db: DB,
env: AuthEnv,
): ReturnType<typeof betterAuth> {
// Extract domain from APP_ORIGIN for passkey rpID
const appUrl = new URL(env.APP_ORIGIN);
const rpID = appUrl.hostname;
return betterAuth({
baseURL: `${env.APP_ORIGIN}/api/auth`,
trustedOrigins: [env.APP_ORIGIN],
secret: env.BETTER_AUTH_SECRET,
database: drizzleAdapter(db, {
provider: "pg",
schema: {
identity: Db.identity,
invitation: Db.invitation,
member: Db.member,
organization: Db.organization,
passkey: Db.passkey,
session: Db.session,
subscription: Db.subscription,
user: Db.user,
verification: Db.verification,
},
}),
account: {
modelName: "identity",
},
// Email and password authentication
emailAndPassword: {
enabled: true,
sendResetPassword: async ({ user, url }) => {
await sendPasswordReset(env, { user, url });
},
},
// Email verification
emailVerification: {
sendVerificationEmail: async ({ user, url }) => {
await sendVerificationEmail(env, { user, url });
},
},
// OAuth providers
socialProviders: {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
},
},
plugins: [
anonymous(),
organization({
allowUserToCreateOrganization: true,
organizationLimit: 5,
creatorRole: "owner",
}),
passkey({
// rpID: Relying Party ID - domain name in production, 'localhost' for dev
rpID,
// rpName: Human-readable name for your app
rpName: env.APP_NAME,
// origin: URL where auth occurs (no trailing slash)
origin: env.APP_ORIGIN,
}),
emailOTP({
async sendVerificationOTP({ email, otp, type }) {
await sendOTP(env, { email, otp, type });
},
otpLength: 6,
expiresIn: 300, // 5 minutes
allowedAttempts: 3,
}),
...stripePlugin(db, env),
],
advanced: {
database: {
generateId: ({ model }) => generateAuthId(model as AuthModel),
},
},
// Set/clear auth hint cookie for edge routing
hooks: {
after: createAuthMiddleware(async (ctx) => {
const isSecure = new URL(env.APP_ORIGIN).protocol === "https:";
// __Host- prefix requires Secure; browsers reject it over HTTP
const cookieName = isSecure ? "__Host-auth" : "auth";
const cookieOpts = {
path: "/",
secure: isSecure,
httpOnly: true,
sameSite: "lax" as const,
};
// Set hint cookie on session creation (sign-in, sign-up, OAuth callback)
if (ctx.context.newSession) {
ctx.setCookie(cookieName, AUTH_HINT_VALUE, cookieOpts);
return;
}
// Clear hint cookie on sign-out
// ctx.path is normalized (base path stripped) by better-call router
if (ctx.path.startsWith("/sign-out")) {
ctx.setCookie(cookieName, "", { ...cookieOpts, maxAge: 0 });
return;
}
// Clear stale hint cookie on session check when session is invalid
// Only run on /get-session where ctx.context.session is reliably populated
// This handles: expired sessions, revoked sessions, deleted users
if (ctx.path === "/get-session" && !ctx.context.session) {
const cookies = ctx.request?.headers.get("cookie") ?? "";
const hasHintCookie = cookies
.split(";")
.some((c) => c.trim().startsWith(`${cookieName}=`));
if (hasHintCookie) {
ctx.setCookie(cookieName, "", { ...cookieOpts, maxAge: 0 });
}
}
}),
},
});
}
export type Auth = ReturnType<typeof betterAuth>;
// Base session types from Better Auth - plugin-specific fields added at runtime
type SessionResponse = Auth["$Infer"]["Session"];
export type AuthUser = SessionResponse["user"];
// Organization plugin adds activeOrganizationId at runtime
export type AuthSession = SessionResponse["session"] & {
activeOrganizationId?: string;
};
================================================
FILE: apps/api/lib/context.ts
================================================
import type { DatabaseSchema } from "@repo/db";
import type { CreateHTTPContextOptions } from "@trpc/server/adapters/standalone";
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import type { Resend } from "resend";
import type { Auth, AuthSession, AuthUser } from "./auth.js";
import type { Env } from "./env.js";
/**
* Context object passed to all tRPC procedures.
*
* @remarks
* This context is created for each incoming request and provides access to:
* - Request-specific data (headers, session, etc.)
* - Shared resources (database, cache)
* - Environment configuration
*
* The context is immutable within a single request but can be extended
* by middleware functions before reaching the procedure.
*
* @example
* ```typescript
* // Access context in a tRPC procedure
* export const getUser = publicProcedure
* .input(z.object({ id: z.string() }))
* .query(async ({ ctx, input }) => {
* return await ctx.db.select().from(user).where(eq(user.id, input.id));
* });
* ```
*/
export type TRPCContext = {
/** The incoming HTTP request object */
req: Request;
/** tRPC request metadata (headers, connection info) */
info: CreateHTTPContextOptions["info"];
/** Drizzle ORM database instance (PostgreSQL via Hyperdrive cached connection) */
db: PostgresJsDatabase<DatabaseSchema>;
/** Drizzle ORM database instance (PostgreSQL via Hyperdrive direct connection) */
dbDirect: PostgresJsDatabase<DatabaseSchema>;
/** Authenticated user session (null if not authenticated) */
session: AuthSession | null;
/** Authenticated user data (null if not authenticated) */
user: AuthUser | null;
/** Request-scoped cache for storing computed values during request lifecycle */
cache: Map<string | symbol, unknown>;
/** Optional HTTP response object (available in Hono middleware) */
res?: Response;
/** Optional response headers (for setting cookies, CORS headers, etc.) */
resHeaders?: Headers;
/** Environment variables and secrets */
env: Env;
};
/**
* Hono application context.
*
* @example
* ```typescript
* app.get("/api/health", async (c) => {
* const db = c.get("db");
* const user = c.get("user");
* return c.json({ status: "ok", user: user?.email });
* });
* ```
*/
export type AppContext = {
Bindings: Env;
Variables: {
db: PostgresJsDatabase<DatabaseSchema>;
dbDirect: PostgresJsDatabase<DatabaseSchema>;
auth: Auth;
resend?: Resend;
session: AuthSession | null;
user: AuthUser | null;
};
};
================================================
FILE: apps/api/lib/db.ts
================================================
/**
* @file Database client using Neon PostgreSQL via Cloudflare Hyperdrive.
*
* Two bindings available: HYPERDRIVE_CACHED (60s cache) and HYPERDRIVE_DIRECT (no cache).
*/
import { schema } from "@repo/db";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
/**
* Creates a database client using Drizzle ORM and Cloudflare Hyperdrive.
*
* @param db - Cloudflare Hyperdrive binding providing connection string
*/
export function createDb(db: Hyperdrive) {
const client = postgres(db.connectionString, {
max: 1,
connect_timeout: 10,
prepare: false, // Avoids prepared statement caching issues in Workers
idle_timeout: 20,
max_lifetime: 60 * 30,
transform: {
undefined: null,
},
onnotice: () => {},
});
return drizzle(client, { schema, casing: "snake_case" });
}
export { schema as Db };
================================================
FILE: apps/api/lib/email.ts
================================================
import {
EmailVerification,
OTPEmail,
PasswordReset,
renderEmailToHtml,
renderEmailToText,
} from "@repo/email";
import { Resend } from "resend";
import { z } from "zod";
import type { Env } from "./env";
export interface EmailOptions {
to: string | string[];
subject: string;
html?: string;
text?: string;
from?: string;
}
export function createResendClient(apiKey: string): Resend {
if (!apiKey) {
throw new Error("RESEND_API_KEY is required");
}
return new Resend(apiKey);
}
/**
* Send an email using the Resend client.
*
* @param env Environment variables containing Resend configuration
* @param options Email configuration
*/
export async function sendEmail(
env: Pick<Env, "RESEND_API_KEY" | "RESEND_EMAIL_FROM">,
options: EmailOptions,
) {
const emailSchema = z.email();
// Validate all recipients before sending
const recipients = Array.isArray(options.to) ? options.to : [options.to];
for (const email of recipients) {
const result = emailSchema.safeParse(email);
if (!result.success) {
throw new Error(`Invalid email address: ${email}`);
}
}
if (!env.RESEND_EMAIL_FROM) {
throw new Error("RESEND_EMAIL_FROM environment variable is required");
}
const resend = createResendClient(env.RESEND_API_KEY);
if (!options.text && !options.html) {
throw new Error("Either text or html content is required");
}
if (options.html && !options.text) {
throw new Error(
"Plain text version required when sending HTML email. Use renderEmailToText() from @repo/email.",
);
}
try {
const result = await resend.emails.send({
from: options.from || env.RESEND_EMAIL_FROM,
to: options.to,
subject: options.subject,
html: options.html,
text: options.text as string,
});
if (result.error) {
throw new Error(
`Resend API error: ${result.error.message || result.error.name || "Unknown error"}`,
);
}
return result;
} catch (error) {
throw new Error(
`Failed to send email: ${error instanceof Error ? error.message : "Unknown error"}`,
{ cause: error },
);
}
}
/**
* Send email verification message.
*
* @param env Environment variables
* @param options User and verification URL (should be time-limited, signed token)
*/
export async function sendVerificationEmail(
env: Pick<
Env,
"RESEND_API_KEY" | "RESEND_EMAIL_FROM" | "APP_NAME" | "APP_ORIGIN"
>,
options: {
user: { email: string; name?: string };
url: string;
},
) {
const component = EmailVerification({
userName: options.user.name,
verificationUrl: options.url,
appName: env.APP_NAME,
appUrl: env.APP_ORIGIN,
});
const html = await renderEmailToHtml(component);
const text = await renderEmailToText(component);
return sendEmail(env, {
to: options.user.email,
subject: "Verify your email address",
html,
text,
});
}
/**
* Send password reset email.
*
* @param env Environment variables
* @param options User and reset URL (must be single-use token with short expiration)
*/
export async function sendPasswordReset(
env: Pick<
Env,
"RESEND_API_KEY" | "RESEND_EMAIL_FROM" | "APP_NAME" | "APP_ORIGIN"
>,
options: {
user: { email: string; name?: string };
url: string;
},
) {
const component = PasswordReset({
userName: options.user.name,
resetUrl: options.url,
appName: env.APP_NAME,
appUrl: env.APP_ORIGIN,
});
const html = await renderEmailToHtml(component);
const text = await renderEmailToText(component);
return sendEmail(env, {
to: options.user.email,
subject: "Reset your password",
html,
text,
});
}
/**
* Send OTP email for authentication.
*
* @param env Environment variables
* @param options Email, OTP code (must be rate-limited, time-bound, single-use), and type
*/
export async function sendOTP(
env: Pick<
Env,
| "ENVIRONMENT"
| "RESEND_API_KEY"
| "RESEND_EMAIL_FROM"
| "APP_NAME"
| "APP_ORIGIN"
>,
options: {
email: string;
otp: string;
type: "sign-in" | "email-verification" | "forget-password";
},
) {
if (env.ENVIRONMENT === "development") {
console.log(`OTP code for ${options.email}: ${options.otp}`);
}
const component = OTPEmail({
otp: options.otp,
type: options.type,
appName: env.APP_NAME,
appUrl: env.APP_ORIGIN,
});
const html = await renderEmailToHtml(component);
const text = await renderEmailToText(component);
const typeLabels = {
"sign-in": "Sign In",
"email-verification": "Email Verification",
"forget-password": "Password Reset",
};
return sendEmail(env, {
to: options.email,
subject: `Your ${typeLabels[options.type]} code`,
html,
text,
});
}
================================================
FILE: apps/api/lib/env.ts
================================================
import { z } from "zod";
/**
* Zod schema for validating environment variables.
* Ensures all required configuration values are present and correctly formatted.
*
* @throws {ZodError} When environment variables don't match the schema
*/
export const envSchema = z.object({
ENVIRONMENT: z.enum(["production", "staging", "preview", "development"]),
APP_NAME: z.string().default("Example"),
APP_ORIGIN: z.url(),
DATABASE_URL: z.url(),
BETTER_AUTH_SECRET: z.string().min(32),
GOOGLE_CLIENT_ID: z.string(),
GOOGLE_CLIENT_SECRET: z.string(),
OPENAI_API_KEY: z.string(),
RESEND_API_KEY: z.string(),
RESEND_EMAIL_FROM: z.email(),
// Stripe billing (optional — app works without these, billing features disabled)
STRIPE_SECRET_KEY: z.string().startsWith("sk_").optional(),
STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_").optional(),
STRIPE_STARTER_PRICE_ID: z.string().startsWith("price_").optional(),
STRIPE_PRO_PRICE_ID: z.string().startsWith("price_").optional(),
STRIPE_PRO_ANNUAL_PRICE_ID: z.string().startsWith("price_").optional(),
});
/**
* Runtime environment variables accessor.
*
* @remarks
* - In Bun runtime: Variables are accessed via `Bun.env`
* - In Cloudflare Workers: Variables must be accessed via request context
* - Falls back to empty object when Bun global is unavailable
*
* @example
* // In Bun runtime
* const dbUrl = env.DATABASE_URL;
*
* // In Cloudflare Workers (must use context)
* const dbUrl = context.env.DATABASE_URL;
*/
export const env =
typeof Bun === "undefined" ? ({} as Env) : envSchema.parse(Bun.env);
/**
* Type-safe environment variables interface.
* Inferred from the Zod schema to ensure type safety.
*/
export type Env = z.infer<typeof envSchema>;
================================================
FILE: apps/api/lib/loaders.ts
================================================
/**
* Request-scoped DataLoaders for batching and N+1 prevention.
*
* @example
* ```ts
* protectedProcedure
* .query(async ({ ctx }) => {
* const user = await userById(ctx).load(ctx.session.userId);
* })
* ```
*/
import DataLoader from "dataloader";
import { inArray } from "drizzle-orm";
import { user } from "@repo/db/schema/user.js";
import type { TRPCContext } from "./context";
/** Map fetched items by key, preserving input order (nulls for missing). */
function mapByKey<T, K extends keyof T>(
items: T[],
keyField: K,
keys: readonly T[K][],
): (T | null)[] {
const map = new Map(items.map((item) => [item[keyField], item]));
return keys.map((k) => map.get(k) ?? null);
}
/** Create a request-scoped DataLoader (one instance per request via ctx.cache). */
function defineLoader<K, V>(
key: symbol,
batchFn: (ctx: TRPCContext, keys: readonly K[]) => Promise<(V | null)[]>,
): (ctx: TRPCContext) => DataLoader<K, V | null> {
return (ctx) => {
let loader = ctx.cache.get(key) as DataLoader<K, V | null> | undefined;
if (!loader) {
loader = new DataLoader((keys: readonly K[]) => batchFn(ctx, keys));
ctx.cache.set(key, loader);
}
return loader;
};
}
export const userById = defineLoader(
Symbol("userById"),
async (ctx, ids: readonly string[]) => {
const users = await ctx.db
.select()
.from(user)
.where(inArray(user.id, [...ids]));
return mapByKey(users, "id", ids);
},
);
export const userByEmail = defineLoader(
Symbol("userByEmail"),
async (ctx, emails: readonly string[]) => {
const users = await ctx.db
.select()
.from(user)
.where(inArray(user.email, [...emails]));
return mapByKey(users, "email", emails);
},
);
================================================
FILE: apps/api/lib/middleware.ts
================================================
/**
* @file Shared Hono middleware for both production and development entrypoints.
*/
import type { Context, ErrorHandler, NotFoundHandler } from "hono";
import { HTTPException } from "hono/http-exception";
/**
* Global error handler for top-level Hono apps.
*
* Handles HTTPException specially (returns its response),
* logs unexpected errors and returns a generic 500.
*/
export const errorHandler: ErrorHandler = (err, c) => {
if (err instanceof HTTPException) {
// getResponse() is not context-aware; merge headers from middleware
const res = err.getResponse();
const headers = new Headers(res.headers);
c.res.headers.forEach((v, k) => headers.set(k, v));
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers,
});
}
console.error(`[${c.req.method}] ${c.req.path}:`, err);
return c.json({ error: "Internal Server Error" }, 500);
};
/**
* 404 handler for unmatched routes.
*
* Must be registered on top-level app (notFound on mounted sub-apps is ignored).
*/
export const notFoundHandler: NotFoundHandler = (c) => {
return c.json({ error: "Not Found", path: c.req.path }, 404);
};
/**
* Request ID generator using Cloudflare Ray ID when available.
*/
export function requestIdGenerator(c: Context): string {
return c.req.header("cf-ray") ?? crypto.randomUUID();
}
================================================
FILE: apps/api/lib/plans.ts
================================================
// Single source of truth for plan limits.
// Referenced by auth plugin config (plan definitions) and tRPC router (query responses).
export const planLimits = {
free: { members: 1 },
starter: { members: 5 },
pro: { members: 50 },
} as const;
export type PlanName = keyof typeof planLimits;
================================================
FILE: apps/api/lib/stripe.ts
================================================
import Stripe from "stripe";
import type { Env } from "./env";
// Only called when STRIPE_SECRET_KEY is verified present (see auth.ts conditional)
export function createStripeClient(env: Pick<Env, "STRIPE_SECRET_KEY">) {
return new Stripe(env.STRIPE_SECRET_KEY!, {
appInfo: { name: "React Starter Kit" },
});
}
================================================
FILE: apps/api/lib/trpc.ts
================================================
import { initTRPC, TRPCError, type TRPCProcedureBuilder } from "@trpc/server";
import { flattenError, ZodError } from "zod";
import type { TRPCContext } from "./context.js";
const t = initTRPC.context<TRPCContext>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? flattenError(error.cause) : null,
},
};
},
});
export const router = t.router;
export const publicProcedure = t.procedure;
export const createCallerFactory = t.createCallerFactory;
// Derive type from publicProcedure to stay in sync with initTRPC config.
// Explicit annotation required to avoid TS2742 (non-portable inferred type).
type ProtectedProcedure =
typeof publicProcedure extends TRPCProcedureBuilder<
infer TContext,
infer TMeta,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
infer TContextOverrides,
infer TInputIn,
infer TInputOut,
infer TOutputIn,
infer TOutputOut,
infer TCaller
>
? TRPCProcedureBuilder<
TContext,
TMeta,
{
session: NonNullable<TRPCContext["session"]>;
user: NonNullable<TRPCContext["user"]>;
},
TInputIn,
TInputOut,
TOutputIn,
TOutputOut,
TCaller
>
: never;
export const protectedProcedure: ProtectedProcedure = t.procedure.use(
({ ctx, next }) => {
if (!ctx.session || !ctx.user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Authentication required",
});
}
return next({
ctx: {
...ctx,
session: ctx.session,
user: ctx.user,
},
});
},
);
================================================
FILE: apps/api/package.json
================================================
{
"name": "@repo/api",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./index.ts",
"./auth": "./lib/auth.ts",
"./package.json": "./package.json"
},
"scripts": {
"predev": "bun --filter @repo/email build",
"dev": "bun run --watch --env-file ../../.env --env-file ../../.env.local ./dev.ts",
"build": "bun build index.ts --outdir dist --target bun",
"test": "vitest",
"typecheck": "tsc --noEmit",
"deploy": "wrangler deploy --env-file ../../.env --env-file ../../.env.local",
"logs": "wrangler tail --env-file ../../.env --env-file ../../.env.local"
},
"dependencies": {
"@ai-sdk/openai": "^3.0.29",
"@better-auth/passkey": "^1.4.18",
"@better-auth/stripe": "^1.4.18",
"@repo/core": "workspace:*",
"@repo/db": "workspace:*",
"@repo/email": "workspace:*",
"@trpc/server": "^11.10.0",
"ai": "^6.0.91",
"better-auth": "^1.4.18",
"dataloader": "^2.2.3",
"drizzle-orm": "^0.45.1",
"postgres": "^3.4.8",
"resend": "^6.9.2",
"stripe": "^20.3.1"
},
"peerDependencies": {
"hono": "^4.11.10",
"zod": "^4.3.6"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260218.0",
"@repo/typescript-config": "workspace:*",
"@types/bun": "^1.3.9",
"hono": "^4.11.10",
"typescript": "~5.9.3",
"vitest": "~4.0.18",
"wrangler": "^4.66.0",
"zod": "^4.3.6"
}
}
================================================
FILE: apps/api/routers/billing.test.ts
================================================
import { describe, expect, it, vi } from "vitest";
import type { TRPCContext } from "../lib/context";
import { createCallerFactory } from "../lib/trpc";
import { billingRouter } from "./billing";
const createCaller = createCallerFactory(billingRouter);
// Minimal context mock — only fields the billing procedure accesses.
function testCtx({
userId = "user-1",
activeOrgId = undefined as string | undefined,
subscription = undefined as Record<string, unknown> | undefined,
} = {}) {
const ctx: TRPCContext = {
req: new Request("http://localhost"),
info: {} as TRPCContext["info"],
session: {
id: "s-1",
createdAt: new Date(),
updatedAt: new Date(),
userId,
expiresAt: new Date(Date.now() + 60_000),
token: "token",
activeOrganizationId: activeOrgId,
},
user: {
id: userId,
createdAt: new Date(),
updatedAt: new Date(),
email: "test@example.com",
emailVerified: true,
name: "Test User",
},
db: {
query: {
subscription: {
findFirst: vi.fn().mockResolvedValue(subscription),
},
},
} as unknown as TRPCContext["db"],
dbDirect: {} as TRPCContext["dbDirect"],
cache: new Map(),
env: {} as TRPCContext["env"],
};
return ctx;
}
describe("billing.subscription", () => {
it("returns free plan defaults when no subscription exists", async () => {
const result = await createCaller(testCtx()).subscription();
expect(result).toEqual({
plan: "free",
status: null,
periodEnd: null,
cancelAtPeriodEnd: false,
limits: { members: 1 },
});
});
it("returns active subscription with plan limits", async () => {
const periodEnd = new Date("2025-03-01");
const result = await createCaller(
testCtx({
subscription: {
plan: "pro",
status: "active",
periodEnd,
cancelAtPeriodEnd: false,
},
}),
).subscription();
expect(result).toEqual({
plan: "pro",
status: "active",
periodEnd,
cancelAtPeriodEnd: false,
limits: { members: 50 },
});
});
it("returns trialing subscription", async () => {
const result = await createCaller(
testCtx({
subscription: {
plan: "starter",
status: "trialing",
periodEnd: null,
cancelAtPeriodEnd: false,
},
}),
).subscription();
expect(result.plan).toBe("starter");
expect(result.status).toBe("trialing");
expect(result.limits).toEqual({ members: 5 });
});
it("maps cancelAtPeriodEnd flag", async () => {
const result = await createCaller(
testCtx({
subscription: {
plan: "pro",
status: "active",
periodEnd: new Date(),
cancelAtPeriodEnd: true,
},
}),
).subscription();
expect(result.cancelAtPeriodEnd).toBe(true);
});
it("throws on unknown plan name", async () => {
await expect(
createCaller(
testCtx({
subscription: { plan: "enterprise", status: "active" },
}),
).subscription(),
).rejects.toThrow('Unknown plan "enterprise"');
});
});
================================================
FILE: apps/api/routers/billing.ts
================================================
import { planLimits, type PlanName } from "../lib/plans.js";
import { protectedProcedure, router } from "../lib/trpc.js";
export const billingRouter = router({
// Active subscription + limits for the current billing reference.
// referenceId is derived from session — org billing when an org is active,
// personal billing otherwise. No client-side param needed.
subscription: protectedProcedure.query(async ({ ctx }) => {
const referenceId = ctx.session.activeOrganizationId ?? ctx.user.id;
const sub = await ctx.db.query.subscription.findFirst({
where: (s, { eq, and, inArray }) =>
and(
eq(s.referenceId, referenceId),
inArray(s.status, ["active", "trialing"]),
),
});
const plan = sub?.plan ?? "free";
if (!(plan in planLimits)) {
throw new Error(`Unknown plan "${plan}"`);
}
return {
plan,
status: sub?.status ?? null,
periodEnd: sub?.periodEnd ?? null,
cancelAtPeriodEnd: sub?.cancelAtPeriodEnd ?? false,
limits: planLimits[plan as PlanName],
};
}),
});
================================================
FILE: apps/api/routers/organization.ts
================================================
import { z } from "zod";
import { protectedProcedure, router } from "../lib/trpc.js";
export const organizationRouter = router({
list: protectedProcedure.query(() => {
// TODO: Implement organization listing logic
return {
organizations: [],
};
}),
create: protectedProcedure
.input(
z.object({
name: z.string().min(1),
description: z.string().optional(),
}),
)
.mutation(({ input, ctx }) => {
// TODO: Implement organization creation logic
return {
id: "org_" + Date.now(),
name: input.name,
description: input.description,
ownerId: ctx.user.id,
};
}),
update: protectedProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1).optional(),
description: z.string().optional(),
}),
)
.mutation(({ input }) => {
// TODO: Implement organization update logic
return {
...input,
};
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(({ input }) => {
// TODO: Implement organization deletion logic
return { success: true, id: input.id };
}),
members: protectedProcedure
.input(z.object({ organizationId: z.string() }))
.query(() => {
// TODO: Implement organization members listing
return {
members: [],
};
}),
invite: protectedProcedure
.input(
z.object({
organizationId: z.string(),
email: z.email({ error: "Invalid email address" }),
role: z.enum(["admin", "member"]).default("member"),
}),
)
.mutation(() => {
// TODO: Implement organization invite logic
return {
success: true,
inviteId: "invite_" + Date.now(),
};
}),
});
================================================
FILE: apps/api/routers/user.ts
================================================
import { z } from "zod";
import { protectedProcedure, router } from "../lib/trpc.js";
export const userRouter = router({
me: protectedProcedure.query(async ({ ctx }) => {
// User is now directly available in context from Better Auth
return {
id: ctx.user.id,
email: ctx.user.email,
name: ctx.user.name,
};
}),
updateProfile: protectedProcedure
.input(
z.object({
name: z.string().min(1).optional(),
email: z.email({ error: "Invalid email address" }).optional(),
}),
)
.mutation(({ input, ctx }) => {
// TODO: Implement user profile update logic
return {
id: ctx.user.id,
...input,
};
}),
list: protectedProcedure
.input(
z.object({
limit: z.number().min(1).max(100).default(10),
cursor: z.string().optional(),
}),
)
.query(() => {
// TODO: Implement user listing logic
return {
users: [],
nextCursor: null,
};
}),
});
================================================
FILE: apps/api/tsconfig.json
================================================
{
"extends": "../../packages/typescript-config/node.jsonc",
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"outDir": "./dist",
"tsBuildInfoFile": "../../.cache/tsconfig/api.tsbuildinfo",
"types": ["@cloudflare/workers-types", "bun"]
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["**/dist/**/*", "**/node_modules/**/*"],
"references": [{ "path": "../../packages/core" }, { "path": "../../db" }]
}
================================================
FILE: apps/api/vitest.config.ts
================================================
import { defineProject } from "vitest/config";
export default defineProject({});
================================================
FILE: apps/api/worker.ts
================================================
/**
* @file Cloudflare Workers entrypoint.
*
* Initializes database and auth context, then mounts the core Hono app.
*/
import { Hono } from "hono";
import { logger } from "hono/logger";
import { requestId } from "hono/request-id";
import { secureHeaders } from "hono/secure-headers";
import app from "./lib/app.js";
import { createAuth } from "./lib/auth.js";
import type { AppContext } from "./lib/context.js";
import { createDb } from "./lib/db.js";
import type { Env } from "./lib/env.js";
import {
errorHandler,
notFoundHandler,
requestIdGenerator,
} from "./lib/middleware.js";
type CloudflareEnv = {
HYPERDRIVE_CACHED: Hyperdrive;
HYPERDRIVE_DIRECT: Hyperdrive;
} & Env;
const worker = new Hono<{
Bindings: CloudflareEnv;
Variables: AppContext["Variables"];
}>();
// Error and 404 handlers (must be on top-level app)
worker.onError(errorHandler);
worker.notFound(notFoundHandler);
// Standard middleware
worker.use(secureHeaders());
worker.use(requestId({ generator: requestIdGenerator }));
worker.use(logger());
// Initialize shared context for all requests
worker.use(async (c, next) => {
const db = createDb(c.env.HYPERDRIVE_CACHED);
const dbDirect = createDb(c.env.HYPERDRIVE_DIRECT);
const auth = createAuth(db, c.env);
c.set("db", db);
c.set("dbDirect", dbDirect);
c.set("auth", auth);
await next();
});
// Mount the core API app
worker.route("/", app);
export default worker;
================================================
FILE: apps/api/wrangler.jsonc
================================================
{
"$schema": "../../node_modules/wrangler/config-schema.json",
// [METADATA]
// API worker accessed via service binding from web worker (no routes needed).
"name": "example-api",
"main": "./worker.ts",
"compatibility_date": "2025-08-15",
"compatibility_flags": ["nodejs_compat"],
"workers_dev": false,
"upload_source_maps": true,
// [ENV:PRODUCTION]
// Command: bun wrangler deploy
"vars": {
"ENVIRONMENT": "production",
"APP_NAME": "Example",
"APP_ORIGIN": "https://example.com",
"ALLOWED_ORIGINS": "https://example.com",
"RESEND_EMAIL_FROM": "onboarding@resend.dev"
},
// prettier-ignore
"hyperdrive": [
{ "binding": "HYPERDRIVE_CACHED", "id": "your-hyperdrive-cached-id-here" },
{ "binding": "HYPERDRIVE_DIRECT", "id": "your-hyperdrive-direct-id-here" }
],
"kv_namespaces": [],
"env": {
// [ENV:DEVELOPMENT]
// Command: bun wrangler dev
"dev": {
"vars": {
"ENVIRONMENT": "development",
"APP_NAME": "Example",
"APP_ORIGIN": "http://localhost:5173",
"ALLOWED_ORIGINS": "http://localhost:5173,http://127.0.0.1:5173",
"RESEND_EMAIL_FROM": "onboarding@resend.dev"
},
// prettier-ignore
"hyperdrive": [
{ "binding": "HYPERDRIVE_CACHED", "id": "your-dev-hyperdrive-cached-id-here" },
{ "binding": "HYPERDRIVE_DIRECT", "id": "your-dev-hyperdrive-direct-id-here" }
],
"kv_namespaces": []
},
// [ENV:STAGING]
// Command: bun wrangler deploy --env staging
"staging": {
"vars": {
"ENVIRONMENT": "staging",
"APP_NAME": "Example",
"APP_ORIGIN": "https://staging.example.com",
"ALLOWED_ORIGINS": "https://staging.example.com",
"RESEND_EMAIL_FROM": "onboarding@resend.dev"
},
// prettier-ignore
"hyperdrive": [
{ "binding": "HYPERDRIVE_CACHED", "id": "your-staging-hyperdrive-cached-id-here" },
{ "binding": "HYPERDRIVE_DIRECT", "id": "your-staging-hyperdrive-direct-id-here" }
],
"kv_namespaces": []
},
// [ENV:PREVIEW]
// Command: bun wrangler deploy --env preview
"preview": {
"vars": {
"ENVIRONMENT": "preview",
"APP_NAME": "Example",
"APP_ORIGIN": "https://preview.example.com",
"ALLOWED_ORIGINS": "https://preview.example.com",
"RESEND_EMAIL_FROM": "onboarding@resend.dev"
},
// prettier-ignore
"hyperdrive": [
{ "binding": "HYPERDRIVE_CACHED", "id": "your-preview-hyperdrive-cached-id-here" },
{ "binding": "HYPERDRIVE_DIRECT", "id": "your-preview-hyperdrive-direct-id-here" }
],
"kv_namespaces": []
}
}
}
================================================
FILE: apps/app/AGENTS.md
================================================
Client-side SPA — no SSR. All rendering happens in the browser.
## Routing
- File-based routing in `routes/`. `lib/routeTree.gen.ts` is auto-generated — never edit it.
- Route groups: `(app)/` = protected, `(auth)/` = public. Parentheses don't affect URLs.
- `route.tsx` in a group = layout with shared `beforeLoad`; individual files for pages.
## Authentication
- Session state via `useSessionQuery()` from `lib/queries/session.ts`. NEVER use `auth.useSession()` — TanStack Query provides caching, multi-tab sync, and consistency.
- Auth guard in `beforeLoad`, not in components. Uses cache-first (`getCachedSession()`), then `fetchQuery()`.
- Must validate both `user` AND `session` (not just one).
- After login: call `revalidateSession(queryClient, router)` — removes cache + invalidates router so `beforeLoad` fetches fresh data, then navigate.
- Safe redirects: use `getSafeRedirectUrl()` for `returnTo` search params (prevents open redirects).
- `signOut(queryClient)` clears server session, invalidates cache, redirects to `/login`.
## tRPC Client
- `credentials: "include"` for cookie-based auth, batched via `httpBatchLink`.
- API URL: `${import.meta.env.VITE_API_URL || "/api"}/trpc`.
- Uses `createTRPCOptionsProxy()` for TanStack Query integration.
## Components
- Named exports, functional only. shadcn/ui from `@repo/ui`.
- Navigation: `<Link>` from TanStack Router with `activeProps` for active styling. Never use `<a>` for internal routes.
- Route context: `Route.useSearch()` for search params, `Route.useRouteContext()` for route data.
- Jotai store available for cross-route UI state (modals, sidebar).
## Error Handling
- `AppErrorBoundary` (root) shows generic error UI. `AuthErrorBoundary` (protected routes) catches 401/UNAUTHORIZED and shows sign-in recovery UI; 403 falls through to generic handler.
- Utilities in `lib/errors.ts`: `getErrorStatus()`, `isUnauthenticatedError()`, `getErrorMessage()`.
================================================
FILE: apps/app/README.md
================================================
# React Application
Single-page application built with React 19, TanStack Router, Jotai, shadcn/ui, and Tailwind CSS v4.
[Documentation](https://reactstarter.com/frontend/routing) | [Getting Started](https://reactstarter.com/getting-started/quick-start)
## Development
```bash
bun app:dev # Start dev server (http://localhost:5173)
bun app:build # Build for production
bun app:deploy # Deploy to Cloudflare Workers
```
## Structure
```bash
routes/ # File-based routes (TanStack Router)
components/ # Shared app components
lib/ # Auth client, tRPC client, Jotai atoms, utilities
styles/ # Global CSS and theme variables
```
Route tree is auto-generated in `lib/routeTree.gen.ts` -- do not edit manually.
================================================
FILE: apps/app/components/auth/auth-error-boundary.tsx
================================================
import { getErrorMessage, isUnauthenticatedError } from "@/lib/errors";
import { sessionQueryKey } from "@/lib/queries/session";
import { Button } from "@repo/ui";
import {
useQueryClient,
useQueryErrorResetBoundary,
} from "@tanstack/react-query";
import { AlertCircle } from "lucide-react";
import { ErrorBoundary } from "react-error-boundary";
interface ResetProps {
resetErrorBoundary: () => void;
}
// Fallback for auth errors in protected routes
function AuthErrorFallback({ resetErrorBoundary }: ResetProps) {
const queryClient = useQueryClient();
const handleRetry = () => {
queryClient.resetQueries({ queryKey: sessionQueryKey });
resetErrorBoundary();
};
const handleSignIn = () => {
queryClient.removeQueries({ queryKey: sessionQueryKey });
const { pathname, search, hash } = window.location;
const returnTo = encodeURIComponent(pathname + search + hash);
window.location.href = `/login?returnTo=${returnTo}`;
};
return (
<div className="flex min-h-svh flex-col items-center justify-center p-6">
<div className="mx-auto max-w-md text-center">
<AlertCircle className="mx-auto mb-4 h-12 w-12 text-destructive" />
<h1 className="mb-2 text-2xl font-bold">Authentication Required</h1>
<p className="mb-6 text-muted-foreground">
Please sign in to access this page.
</p>
<div className="flex justify-center gap-3">
<Button variant="outline" onClick={handleRetry}>
Try Again
</Button>
<Button onClick={handleSignIn}>Sign In</Button>
</div>
</div>
</div>
);
}
interface ErrorFallbackProps {
error: unknown;
resetErrorBoundary: () => void;
}
// Generic error fallback for non-auth errors
function GenericErrorFallback({
error,
resetErrorBoundary,
}: ErrorFallbackProps) {
return (
<div className="flex min-h-svh flex-col items-center justify-center p-6">
<div className="mx-auto max-w-md text-center">
<AlertCircle className="mx-auto mb-4 h-12 w-12 text-destructive" />
<h1 className="mb-2 text-2xl font-bold">Something went wrong</h1>
<p className="mb-6 text-muted-foreground">{getErrorMessage(error)}</p>
<Button onClick={resetErrorBoundary}>Try Again</Button>
</div>
</div>
);
}
interface ErrorBoundaryProps {
children: React.ReactNode;
}
// Routes auth errors to AuthErrorFallback, others to GenericErrorFallback
function AuthAwareErrorFallback({
error,
resetErrorBoundary,
}: ErrorFallbackProps) {
return isUnauthenticatedError(error) ? (
<AuthErrorFallback resetErrorBoundary={resetErrorBoundary} />
) : (
<GenericErrorFallback
error={error}
resetErrorBoundary={resetErrorBoundary}
/>
);
}
// Auth error boundary for protected routes only.
// Catches auth errors (tRPC UNAUTHORIZED or HTTP 401) and shows recovery UI.
// 403 (forbidden) falls through to generic handler since user IS authenticated.
export function AuthErrorBoundary({ children }: ErrorBoundaryProps) {
const queryClient = useQueryClient();
const { reset } = useQueryErrorResetBoundary();
return (
<ErrorBoundary
FallbackComponent={AuthAwareErrorFallback}
onReset={reset}
onError={(error) => {
console.error("Error caught by boundary:", error);
if (isUnauthenticatedError(error)) {
queryClient.removeQueries({ queryKey: sessionQueryKey });
}
}}
>
{children}
</ErrorBoundary>
);
}
// Generic error boundary for app root - no auth-specific handling
export function AppErrorBoundary({ children }: ErrorBoundaryProps) {
const { reset } = useQueryErrorResetBoundary();
return (
<ErrorBoundary
FallbackComponent={GenericErrorFallback}
onReset={reset}
onError={(error) => console.error("Uncaught error:", error)}
>
{children}
</ErrorBoundary>
);
}
================================================
FILE: apps/app/components/auth/auth-form.tsx
================================================
import { Button, Input, cn } from "@repo/ui";
import { Link } from "@tanstack/react-router";
import { ArrowLeft, Mail } from "lucide-react";
import type { ComponentProps, FormEvent } from "react";
import { GoogleLogin } from "./google-login";
import { OtpVerification } from "./otp-verification";
import { PasskeyLogin } from "./passkey-login";
import { useAuthForm } from "./use-auth-form";
const APP_NAME = import.meta.env.VITE_APP_NAME || "your account";
function SignupTerms() {
return (
<p className="text-xs text-muted-foreground text-center text-balance">
By signing up, you agree to our{" "}
<a
href="/terms"
className="underline underline-offset-4 hover:text-primary"
>
Terms of Service
</a>{" "}
and{" "}
<a
href="/privacy"
className="underline underline-offset-4 hover:text-primary"
>
Privacy Policy
</a>
.
</p>
);
}
interface AuthFormProps extends ComponentProps<"div"> {
/**
* UI mode affecting copy, ToS display, and available methods.
* Both modes use the same passwordless OTP flow that auto-creates accounts.
*/
mode?: "login" | "signup";
/** Called after successful auth. Awaited before UI progresses. Caller handles cache invalidation and navigation. */
onSuccess: () => Promise<void>;
isLoading?: boolean;
/** Post-auth redirect destination. Must be a safe relative path (validated by caller). */
returnTo?: string;
}
export function AuthForm({
className,
onSuccess,
isLoading,
mode = "login",
returnTo,
...props
}: AuthFormProps) {
const {
step,
email,
isDisabled,
error,
changeEmail,
onAuthSuccess,
setError,
sendOtp,
goToEmailStep,
goToMethodStep,
resetToEmail,
setChildBusy,
mode: formMode,
} = useAuthForm({
onSuccess,
isExternallyLoading: isLoading,
mode,
});
// Clear error when user changes email
const handleEmailChange = (value: string) => {
if (error) setError(null);
changeEmail(value);
};
// Voluntary back from OTP clears error; forced back (via onCancel) preserves it
const handleOtpBack = () => {
setError(null);
resetToEmail();
};
const isSignup = formMode === "signup";
return (
<div className={cn("flex flex-col gap-6 w-full", className)} {...props}>
{/* Logo */}
<div className="flex justify-center">
<Link to="/" aria-label="Go to homepage">
<img src="/logo512.png" alt="" className="h-10 w-10" />
</Link>
</div>
{/* Error message - role="alert" ensures screen readers announce it */}
{error && (
<div
role="alert"
className="rounded-md bg-destructive/10 p-3 text-sm text-destructive"
>
{error}
</div>
)}
{/* Step: Method Selection */}
{step === "method" && (
<MethodSelection
isSignup={isSignup}
isDisabled={isDisabled}
onEmailClick={goToEmailStep}
onSuccess={onAuthSuccess}
onError={setError}
onLoadingChange={setChildBusy}
returnTo={returnTo}
/>
)}
{/* Step: Email Input */}
{step === "email" && (
<EmailInput
email={email}
isSignup={isSignup}
isDisabled={isDisabled}
onEmailChange={handleEmailChange}
onSubmit={sendOtp}
onBack={goToMethodStep}
/>
)}
{/* Step: OTP Verification */}
{step === "otp" && (
<OtpStep
email={email}
isDisabled={isDisabled}
onSuccess={onAuthSuccess}
onError={setError}
onLoadingChange={setChildBusy}
onBack={handleOtpBack}
onCancel={resetToEmail}
/>
)}
</div>
);
}
// Step 1: Method Selection
interface MethodSelectionProps {
isSignup: boolean;
isDisabled: boolean;
onEmailClick: () => void;
onSuccess: () => void;
onError: (error: string | null) => void;
onLoadingChange: (loading: boolean) => void;
returnTo?: string;
}
function MethodSelection({
isSignup,
isDisabled,
onEmailClick,
onSuccess,
onError,
onLoadingChange,
returnTo,
}: MethodSelectionProps) {
const heading = isSignup ? "Create your account" : `Log in to ${APP_NAME}`;
return (
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-bold text-center">{heading}</h1>
<div className="flex flex-col gap-3">
<GoogleLogin
onError={onError}
isDisabled={isDisabled}
onLoadingChange={onLoadingChange}
returnTo={returnTo}
/>
<Button
type="button"
variant="outline"
className="w-full"
onClick={onEmailClick}
disabled={isDisabled}
>
<Mail className="mr-2 h-4 w-4" />
Continue with email
</Button>
{/* Passkey only available for login (requires existing account) */}
{!isSignup && (
<PasskeyLogin
onSuccess={onSuccess}
onError={onError}
onLoadingChange={onLoadingChange}
isDisabled={isDisabled}
/>
)}
</div>
{isSignup && <SignupTerms />}
{/* Account switch link */}
<p className="text-sm text-muted-foreground text-center">
{isSignup ? (
<>
Already have an account?{" "}
<Link
to="/login"
className="font-medium text-primary underline-offset-4 hover:underline"
>
Log in
</Link>
</>
) : (
<>
Don't have an account?{" "}
<Link
to="/signup"
className="font-medium text-primary underline-offset-4 hover:underline"
>
Sign up
</Link>
</>
)}
</p>
</div>
);
}
// Step 2: Email Input
interface EmailInputProps {
email: string;
isSignup: boolean;
isDisabled: boolean;
onEmailChange: (email: string) => void;
onSubmit: (e?: FormEvent) => void;
onBack: () => void;
}
function EmailInput({
email,
isSignup,
isDisabled,
onEmailChange,
onSubmit,
onBack,
}: EmailInputProps) {
return (
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-bold text-center">
What's your email address?
</h1>
<form onSubmit={onSubmit} className="flex flex-col gap-3">
<Input
type="email"
placeholder="Enter your email address..."
value={email}
onChange={(e) => onEmailChange(e.target.value)}
disabled={isDisabled}
autoComplete="email"
autoFocus
required
/>
<Button
type="submit"
variant="default"
className="w-full"
disabled={isDisabled || !email.trim()}
>
Continue with email
</Button>
</form>
{isSignup && <SignupTerms />}
{/* Back link */}
<button
type="button"
onClick={onBack}
disabled={isDisabled}
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
>
<ArrowLeft className="h-4 w-4" />
Back to {isSignup ? "sign up" : "login"}
</button>
</div>
);
}
// Step 3: OTP Verification
interface OtpStepProps {
email: string;
isDisabled: boolean;
onSuccess: () => void;
onError: (error: string | null) => void;
onLoadingChange: (loading: boolean) => void;
onBack: () => void;
onCancel: () => void;
}
function OtpStep({
email,
isDisabled,
onSuccess,
onError,
onLoadingChange,
onBack,
onCancel,
}: OtpStepProps) {
return (
<div className="flex flex-col gap-6">
<div className="text-center">
<h1 className="text-2xl font-bold">Check your email</h1>
<p className="text-muted-foreground mt-1">
We sent a code to <strong>{email}</strong>
</p>
</div>
<OtpVerification
email={email}
onSuccess={onSuccess}
onError={onError}
onLoadingChange={onLoadingChange}
onCancel={onCancel}
isDisabled={isDisabled}
/>
{/* Back link */}
<button
type="button"
onClick={onBack}
disabled={isDisabled}
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
>
<ArrowLeft className="h-4 w-4" />
Back to email
</button>
</div>
);
}
================================================
FILE: apps/app/components/auth/google-login.tsx
================================================
import { auth } from "@/lib/auth";
import { sessionQueryKey } from "@/lib/queries/session";
import { Button } from "@repo/ui";
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useState } from "react";
interface GoogleLoginProps {
onError: (error: string | null) => void;
isDisabled?: boolean;
onLoadingChange?: (loading: boolean) => void;
/** Post-auth redirect destination (already validated by caller). */
returnTo?: string;
}
export function GoogleLogin({
onError,
isDisabled,
onLoadingChange,
returnTo,
}: GoogleLoginProps) {
const queryClient = useQueryClient();
const [isLoading, setIsLoading] = useState(false);
const setLoading = useCallback(
(loading: boolean) => {
setIsLoading(loading);
onLoadingChange?.(loading);
},
[onLoadingChange],
);
const handleGoogleLogin = async () => {
setLoading(true);
onError(null);
try {
// Clear stale session before OAuth redirect
queryClient.removeQueries({ queryKey: sessionQueryKey });
// OAuth redirects to /login which validates session and redirects to returnTo
const callbackURL = returnTo
? `/login?returnTo=${encodeURIComponent(returnTo)}`
: "/login";
const result = await auth.signIn.social({
provider: "google",
callbackURL,
});
if (result?.error) {
onError(result.error.message || "Failed to sign in with Google");
setLoading(false);
} else if (!result?.data?.redirect) {
// No redirect (popup blocked, misconfigured provider, etc.) - reset loading
setLoading(false);
}
// On redirect, page navigates away - component unmounts, no cleanup needed
} catch (err) {
console.error("Google login error:", err);
onError("Failed to sign in with Google");
setLoading(false);
}
};
return (
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleGoogleLogin}
disabled={isDisabled || isLoading}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
className="mr-2 h-4 w-4"
>
<path
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
fill="currentColor"
/>
</svg>
Continue with Google
</Button>
);
}
================================================
FILE: apps/app/components/auth/index.ts
================================================
export { AppErrorBoundary, AuthErrorBoundary } from "./auth-error-boundary";
export { AuthForm } from "./auth-form";
export { LoginDialog, useLoginDialog } from "./login-dialog";
export { OtpVerification } from "./otp-verification";
export { PasskeyLogin } from "./passkey-login";
export { GoogleLogin } from "./google-login";
export { useAuthForm, type AuthStep } from "./use-auth-form";
================================================
FILE: apps/app/components/auth/login-dialog.tsx
================================================
import { getSafeRedirectUrl } from "@/lib/auth-config";
import { revalidateSession } from "@/lib/queries/session";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@repo/ui";
import { useQueryClient } from "@tanstack/react-query";
import { useRouter, useRouterState } from "@tanstack/react-router";
import { useState } from "react";
import { AuthForm } from "./auth-form";
interface LoginDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* Login dialog component for modal authentication.
* Use with useLoginDialog hook for programmatic control.
*/
export function LoginDialog({ open, onOpenChange }: LoginDialogProps) {
const router = useRouter();
const queryClient = useQueryClient();
// Preserve full URL (pathname + search + hash) for OAuth redirect
const returnTo = useRouterState({
select: (s) => {
const { pathname, search, hash } = s.location;
return getSafeRedirectUrl(pathname + search + hash);
},
});
async function handleSuccess() {
await revalidateSession(queryClient, router);
onOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader className="sr-only">
<DialogTitle>Sign in to your account</DialogTitle>
<DialogDescription>
Choose your preferred sign in method
</DialogDescription>
</DialogHeader>
<AuthForm mode="login" onSuccess={handleSuccess} returnTo={returnTo} />
</DialogContent>
</Dialog>
);
}
/**
* Hook for programmatically controlling the login dialog.
*
* @example
* ```tsx
* function App() {
* const loginDialog = useLoginDialog();
*
* return (
* <>
* <button onClick={loginDialog.open}>Sign In</button>
* <LoginDialog {...loginDialog.props} />
* </>
* );
* }
* ```
*/
export function useLoginDialog() {
const [isOpen, setIsOpen] = useState(false);
return {
isOpen,
open: () => setIsOpen(true),
close: () => setIsOpen(false),
props: {
open: isOpen,
onOpenChange: setIsOpen,
},
};
}
================================================
FILE: apps/app/components/auth/otp-verification.tsx
================================================
import { auth } from "@/lib/auth";
import { Button, Input } from "@repo/ui";
import type { FormEvent } from "react";
import { useCallback, useEffect, useState } from "react";
const RESEND_COOLDOWN_SECONDS = 30;
// Better Auth email-otp plugin error codes (matches server-side ERROR_CODES)
const OTP_ERROR_CODES = {
TOO_MANY_ATTEMPTS: "TOO_MANY_ATTEMPTS",
OTP_EXPIRED: "OTP_EXPIRED",
INVALID_OTP: "INVALID_OTP",
} as const;
interface OtpVerificationProps {
email: string;
onSuccess: () => void;
onError: (error: string | null) => void;
onLoadingChange?: (loading: boolean) => void;
onCancel: () => void;
isDisabled?: boolean;
}
export function OtpVerification({
email,
onSuccess,
onError,
onLoadingChange,
onCancel,
isDisabled,
}: OtpVerificationProps) {
const [otp, setOtp] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [resendCooldown, setResendCooldown] = useState(0);
const setLoading = useCallback(
(loading: boolean) => {
setIsLoading(loading);
onLoadingChange?.(loading);
},
[onLoadingChange],
);
// Countdown timer for resend cooldown
useEffect(() => {
if (resendCooldown <= 0) return;
const timer = setTimeout(() => setResendCooldown((c) => c - 1), 1000);
return () => clearTimeout(timer);
}, [resendCooldown]);
const handleOtpVerification = async (e: FormEvent) => {
e.preventDefault();
e.stopPropagation();
if (!email || !otp) return;
try {
setLoading(true);
onError(null);
const result = await auth.signIn.emailOtp({
email,
otp,
});
if (result.data) {
onSuccess();
} else if (result.error) {
const code = "code" in result.error ? result.error.code : undefined;
if (code === OTP_ERROR_CODES.TOO_MANY_ATTEMPTS) {
onError("Too many failed attempts. Please request a new code.");
onCancel();
} else if (code === OTP_ERROR_CODES.OTP_EXPIRED) {
onError("Code has expired. Please request a new one.");
onCancel();
} else {
onError(result.error.message || "Invalid verification code");
}
}
} catch (err) {
console.error("OTP verification error:", err);
onError("Failed to verify code");
} finally {
setLoading(false);
}
};
const handleResendOtp = async () => {
if (resendCooldown > 0) return;
setOtp("");
onError(null);
try {
setLoading(true);
// "sign-in" type handles both login and signup (creates user if needed)
const result = await auth.emailOtp.sendVerificationOtp({
email,
type: "sign-in",
});
if (result.error) {
onError(result.error.message || "Failed to send OTP");
} else {
setResendCooldown(RESEND_COOLDOWN_SECONDS);
}
} catch (err) {
console.error("Email OTP error:", err);
onError("Failed to send verification code");
} finally {
setLoading(false);
}
};
const disabled = isDisabled || isLoading;
return (
<form onSubmit={handleOtpVerification} className="flex flex-col gap-3">
<Input
type="text"
placeholder="Enter 6-digit code"
value={otp}
onChange={(e) => setOtp(e.target.value.replace(/\D/g, "").slice(0, 6))}
disabled={disabled}
autoComplete="one-time-code"
required
autoFocus
maxLength={6}
pattern="[0-9]{6}"
inputMode="numeric"
className="text-center text-lg tracking-widest"
/>
<Button
type="submit"
variant="default"
className="w-full"
disabled={disabled || otp.length !== 6}
>
Verify code
</Button>
<Button
type="button"
variant="ghost"
className="w-full text-sm"
onClick={handleResendOtp}
disabled={disabled || resendCooldown > 0}
>
{resendCooldown > 0
? `Resend code in ${resendCooldown}s`
: "Resend code"}
</Button>
</form>
);
}
================================================
FILE: apps/app/components/auth/passkey-login.tsx
================================================
import { auth } from "@/lib/auth";
import { authConfig } from "@/lib/auth-config";
import { Button } from "@repo/ui";
import { KeyRound } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
interface PasskeyLoginProps {
onSuccess: () => void;
onError: (error: string | null) => void;
onLoadingChange?: (loading: boolean) => void;
isDisabled?: boolean;
}
/**
* Passkey sign-in component using WebAuthn.
*
* WebAuthn handles credential discovery - no email input needed.
* The browser prompts the user to select from their available passkeys.
*/
export function PasskeyLogin({
onSuccess,
onError,
onLoadingChange,
isDisabled,
}: PasskeyLoginProps) {
const [isLoading, setIsLoading] = useState(false);
const setLoading = useCallback(
(loading: boolean) => {
setIsLoading(loading);
onLoadingChange?.(loading);
},
[onLoadingChange],
);
const onSuccessRef = useRef(onSuccess);
onSuccessRef.current = onSuccess;
// Set up conditional UI for passkey autofill (gated by config)
useEffect(() => {
if (!authConfig.passkey.enableConditionalUI) return;
let aborted = false;
const setupConditionalUI = async () => {
try {
if (!window.PublicKeyCredential?.isConditionalMediationAvailable)
return;
const isAvailable =
await window.PublicKeyCredential.isConditionalMediationAvailable();
if (!isAvailable) return;
// Enable autofill for passkeys on input fields with autocomplete="webauthn"
const result = await auth.signIn.passkey({ autoFill: true });
if (result.data && !aborted) {
onSuccessRef.current();
}
} catch {
// Silently ignore errors from conditional UI (user hasn't explicitly requested auth)
}
};
setupConditionalUI();
return () => {
aborted = true;
};
}, []);
const handlePasskeyLogin = async () => {
// Check WebAuthn support before attempting
if (!window.PublicKeyCredential) {
onError(authConfig.errors.passkeyNotSupported);
return;
}
setLoading(true);
onError(null);
try {
// Better Auth passkey client returns errors via result.error for HTTP/WebAuthn errors,
// but network failures (offline, DNS) can still reject
const result = await auth.signIn.passkey();
if (result.data) {
onSuccess();
} else if (result.error) {
// AUTH_CANCELLED: user dismissed prompt, timed out, or WebAuthn not supported
// Server errors (e.g., no passkey found) have different codes
const errorCode =
"code" in result.error ? result.error.code : undefined;
if (errorCode === "AUTH_CANCELLED") {
onError("Passkey authentication was cancelled.");
} else {
onError(result.error.message || authConfig.errors.genericError);
}
}
} catch {
// Network-level failures (offline, DNS, connection refused)
onError(authConfig.errors.networkError);
} finally {
setLoading(false);
}
};
return (
<Button
type="button"
variant="default"
className="w-full"
onClick={handlePasskeyLogin}
disabled={isDisabled || isLoading}
>
<KeyRound className="mr-2 h-4 w-4" />
Log in with passkey
</Button>
);
}
================================================
FILE: apps/app/components/auth/use-auth-form.ts
================================================
import { auth } from "@/lib/auth";
import type { FormEvent } from "react";
import { useCallback, useRef, useState } from "react";
export type AuthStep = "method" | "email" | "otp";
// Minimal state machine for passwordless OTP flow. Intentionally shallow:
// - Errors are orthogonal to steps (can occur at any step)
// - No terminal state (component unmounts on success)
// Revisit if adding password fallback or MFA steps.
const VALID_TRANSITIONS: Record<AuthStep, AuthStep[]> = {
method: ["email"],
email: ["method", "otp"],
otp: ["email"],
};
interface UseAuthFormOptions {
/**
* Called after successful authentication. Caller is responsible for
* cache invalidation and navigation. Awaited before form state resets.
*/
onSuccess: () => Promise<void>;
isExternallyLoading?: boolean;
/**
* UI mode affecting copy, ToS display, and available methods.
* Both modes use the same passwordless OTP flow that auto-creates accounts.
*/
mode?: "login" | "signup";
}
export function useAuthForm({
onSuccess,
isExternallyLoading,
mode = "login",
}: UseAuthFormOptions) {
const [step, setStep] = useState<AuthStep>("method");
const [email, setEmail] = useState("");
const [isLoading, setIsLoading] = useState(false);
// Counter-based to handle overlapping child operations (e.g., rapid double-click)
const [pendingOps, setPendingOps] = useState(0);
const [error, setError] = useState<string | null>(null);
// Guards against concurrent auth completion (e.g., passkey conditional UI + manual click).
// Conditional passkey autofill intentionally doesn't block UI - it's passive/background.
// Reset when returning to method step to allow retry after navigation back.
const hasSucceededRef = useRef(false);
// Ref provides current step to memoized transitionTo callback (avoids stale closure)
const stepRef = useRef(step);
stepRef.current = step;
// Track child loading via counter to correctly handle overlapping operations
const setChildBusy = useCallback((busy: boolean) => {
setPendingOps((c) => (busy ? c + 1 : Math.max(0, c - 1)));
}, []);
// Unified busy state: disables navigation and other auth methods while any flow is active
const isDisabled = isLoading || pendingOps > 0 || !!isExternallyLoading;
const onAuthSuccess = async () => {
if (hasSucceededRef.current) return;
hasSucceededRef.current = true;
try {
setIsLoading(true);
await onSuccess();
} catch (err) {
console.error("Post-auth error:", err);
setError("Something went wrong. Please try again.");
hasSucceededRef.current = false; // Allow retry on error
} finally {
setIsLoading(false);
}
};
// Validates transitions to prevent invalid step jumps.
// Returning to "method" resets the success guard to allow fresh auth attempts.
const transitionTo = useCallback((next: AuthStep, clearErr = true) => {
const current = stepRef.current;
if (!VALID_TRANSITIONS[current].includes(next)) {
return;
}
if (next === "method") {
hasSucceededRef.current = false;
}
setStep(next);
if (clearErr) setError(null);
}, []);
const goToEmailStep = () => transitionTo("email");
const goToMethodStep = () => transitionTo("method");
// Go back to email step, preserving error message
const resetToEmail = () => transitionTo("email", false);
const sendOtp = async (e?: FormEvent) => {
e?.preventDefault();
// Normalize before auth calls to prevent case/whitespace mismatches
const normalizedEmail = email.trim().toLowerCase();
if (!normalizedEmail) return;
setEmail(normalizedEmail);
try {
setIsLoading(true);
setError(null);
// "sign-in" type handles both login and signup (creates user if needed)
const result = await auth.emailOtp.sendVerificationOtp({
email: normalizedEmail,
type: "sign-in",
});
if (result.data) {
transitionTo("otp");
} else if (result.error) {
setError(result.error.message || "Failed to send OTP");
}
} catch (err) {
console.error("Email OTP error:", err);
setError("Failed to send verification code");
} finally {
setIsLoading(false);
}
};
const changeEmail = (value: string) => {
setEmail(value);
};
return {
// State
step,
email,
isLoading,
isDisabled,
error,
mode,
// Actions
changeEmail,
onAuthSuccess,
setError,
sendOtp,
goToEmailStep,
goToMethodStep,
resetToEmail,
setChildBusy,
};
}
================================================
FILE: apps/app/components/index.ts
================================================
export { NotFound } from "./not-found";
================================================
FILE: apps/app/components/layout/constants.ts
================================================
import { Activity, FileText, Home, Settings, Users } from "lucide-react";
export const sidebarItems = [
{ icon: Home, label: "Dashboard", to: "/" },
{ icon: Activity, label: "Analytics", to: "/analytics" },
{ icon: Users, label: "Users", to: "/users" },
{ icon: FileText, label: "Reports", to: "/reports" },
{ icon: Settings, label: "Settings", to: "/settings" },
] as const;
================================================
FILE: apps/app/components/layout/header.tsx
================================================
import { Button } from "@repo/ui";
import { Menu, Settings, X } from "lucide-react";
interface HeaderProps {
isSidebarOpen: boolean;
onMenuToggle: () => void;
}
export function Header({ isSidebarOpen, onMenuToggle }: HeaderProps) {
return (
<header className="h-14 border-b bg-background flex items-center px-4 gap-4">
<Button
variant="ghost"
size="icon"
onClick={onMenuToggle}
className="shrink-0"
>
{isSidebarOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
<div className="flex-1 flex items-center gap-4">
<h1 className="text-lg font-semibold">Application</h1>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon">
<Settings className="h-5 w-5" />
</Button>
</div>
</header>
);
}
================================================
FILE: apps/app/components/layout/index.tsx
================================================
import { useState } from "react";
import { Header } from "./header";
import { Sidebar } from "./sidebar";
interface LayoutProps {
children: React.ReactNode;
}
export function Layout({ children }: LayoutProps) {
const [sidebarOpen, setSidebarOpen] = useState(true);
return (
<div className="h-screen flex bg-background">
<Sidebar isOpen={sidebarOpen} />
<div className="flex-1 flex flex-col overflow-hidden">
<Header
isSidebarOpen={sidebarOpen}
onMenuToggle={() => setSidebarOpen(!sidebarOpen)}
/>
<main className="flex-1 overflow-auto">
<div className="h-full">{children}</div>
</main>
</div>
</div>
);
}
================================================
FILE: apps/app/components/layout/sidebar-nav.tsx
================================================
import type { FileRoutesByTo } from "@/lib/routeTree.gen";
import { Link } from "@tanstack/react-router";
import type { LucideIcon } from "lucide-react";
interface SidebarNavItem {
icon: LucideIcon;
label: string;
to: keyof FileRoutesByTo;
}
interface SidebarNavProps {
items: readonly SidebarNavItem[];
}
export function SidebarNav({ items }: SidebarNavProps) {
return (
<nav className="flex-1 p-4 space-y-1">
{items.map((item) => (
<Link
key={item.to}
to={item.to}
className="flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors"
activeProps={{
className: "bg-accent text-accent-foreground",
}}
>
<item.icon className="h-4 w-4" />
<span>{item.label}</span>
</Link>
))}
</nav>
);
}
================================================
FILE: apps/app/components/layout/sidebar.tsx
================================================
import { UserMenu } from "@/components/user-menu";
import { sidebarItems } from "./constants";
import { SidebarNav } from "./sidebar-nav";
interface SidebarProps {
isOpen: boolean;
}
export function Sidebar({ isOpen }: SidebarProps) {
return (
<aside
className={`${
isOpen ? "w-64" : "w-0"
} transition-all duration-300 ease-in-out bg-muted/50 border-r overflow-hidden`}
>
<div className="h-full flex flex-col">
<div className="h-14 flex items-center px-4 border-b">
<h2 className="font-semibold text-lg">Console</h2>
</div>
<SidebarNav items={sidebarItems} />
<UserMenu />
</div>
</aside>
);
}
================================================
FILE: apps/app/components/not-found.tsx
================================================
import { Button } from "@repo/ui";
import { Link } from "@tanstack/react-router";
export function NotFound() {
return (
<div className="flex min-h-svh flex-col items-center justify-center p-6">
<div className="mx-auto max-w-md text-center">
<h1 className="mb-2 text-4xl font-bold">404</h1>
<p className="mb-6 text-muted-foreground">
The page you're looking for doesn't exist.
</p>
<Button asChild>
<Link to="/">Go Home</Link>
</Button>
</div>
</div>
);
}
================================================
FILE: apps/app/components/user-menu.tsx
================================================
import { signOut, useSessionQuery } from "@/lib/queries/session";
import { Avatar, AvatarFallback, Button } from "@repo/ui";
import { useQueryClient } from "@tanstack/react-query";
import { LogOut, RefreshCw, User } from "lucide-react";
/** Displays current authenticated user and sign-out control. */
export function UserMenu() {
const queryClient = useQueryClient();
const { data: session, isPending, error, refetch } = useSessionQuery();
if (isPending) {
return (
<div className="flex items-center gap-2 px-3 py-2">
<div className="h-8 w-8 rounded-full bg-muted animate-pulse" />
<div className="flex-1">
<div className="h-4 w-20 bg-muted rounded animate-pulse" />
</div>
</div>
);
}
if (error) {
return (
<div className="px-3 py-2 text-sm text-destructive">
Failed to load session
<Button
variant="ghost"
size="sm"
onClick={() => refetch()}
className="ml-2"
>
<RefreshCw className="h-3 w-3" />
Retry
</Button>
</div>
);
}
const user = session?.user;
if (!user) {
return null;
}
return (
<div className="p-4 border-t">
<div className="flex items-center gap-3 px-3 py-2">
<Avatar className="h-8 w-8">
<AvatarFallback>
{user.name?.[0]?.toUpperCase() || <User className="h-4 w-4" />}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{user.name || "User"}</p>
<p className="text-xs text-muted-foreground truncate">{user.email}</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => signOut(queryClient)}
title="Sign out"
>
<LogOut className="h-4 w-4" />
</Button>
</div>
</div>
);
}
================================================
FILE: apps/app/components.json
================================================
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"css": "styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@repo/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
================================================
FILE: apps/app/global.d.ts
================================================
import * as React from "react";
import "vite/client";
interface Window {
dataLayer: unknown[];
}
interface ImportMetaEnv {
readonly VITE_APP_NAME: string;
readonly VITE_APP_ORIGIN: string;
readonly VITE_GOOGLE_CLOUD_PROJECT: string;
readonly VITE_GA_MEASUREMENT_ID: string;
}
declare module "relay-runtime" {
interface PayloadError {
errors?: Record<string, string[] | undefined>;
}
}
declare module "*.css";
declare module "*.svg" {
const content: React.FC<React.SVGProps<SVGElement>>;
export default content;
}
================================================
FILE: apps/app/index.html
================================================
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>%VITE_APP_NAME%</title>
<meta
name="description"
content="The web's most popular Jamstack React template"
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta property="og:title" content="" />
<meta property="og:type" content="" />
<meta property="og:url" content="" />
<meta property="og:image" content="" />
<meta name="theme-color" content="#fafafa" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/site.manifest" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
/>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>
================================================
FILE: apps/app/index.tsx
================================================
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { createRouter, RouterProvider } from "@tanstack/react-router";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { NotFound } from "./components/not-found";
import { queryClient } from "./lib/query";
import { routeTree } from "./lib/routeTree.gen";
import "./styles/globals.css";
const router = createRouter({
routeTree,
context: { queryClient },
defaultPreload: "intent",
defaultNotFoundComponent: NotFound,
});
const container = document.getElementById("root");
const root = createRoot(container!);
root.render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
{import.meta.env.DEV && (
<ReactQueryDevtools
initialIsOpen={false}
buttonPosition="bottom-right"
/>
)}
</QueryClientProvider>
</StrictMode>,
);
if (import.meta.hot) {
import.meta.hot.dispose(() => root.unmount());
}
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
================================================
FILE: apps/app/lib/auth-config.ts
================================================
// All durations in milliseconds. Providers must match server-side config.
// Changing api.basePath requires updating server routing.
export const authConfig = {
oauth: {
providers: ["google"] as const,
},
passkey: {
enableConditionalUI: true,
timeout: 60_000,
userVerification: "preferred" as const,
},
security: {
csrfTokenHeader: "x-csrf-token",
sessionCookieName: "better-auth.session",
},
api: {
basePath: "/api/auth",
requestTimeout: import.meta.env.DEV ? 60_000 : 30_000,
},
retry: {
attempts: 3,
initialDelay: 1000,
maxDelay: 5000,
backoffMultiplier: 2,
},
session: {
checkInterval: 5 * 60 * 1000,
refreshThreshold: 10 * 60 * 1000,
},
errors: {
sessionExpired: "Your session has expired. Please sign in again.",
unauthorized: "You need to sign in to access this page.",
networkError: "Network error. Please check your connection and try again.",
passkeyNotSupported: "Your browser doesn't support passkeys.",
passkeyNotFound:
"No passkey found for this account. Please sign in with Google first.",
genericError: "Something went wrong. Please try again.",
},
} as const;
// Only allows same-origin relative paths (starts with "/" but not "//")
export function isValidRedirectUrl(url: string): boolean {
return url.startsWith("/") && !url.startsWith("//");
}
// Returns "/" for invalid or missing URLs
export function getSafeRedirectUrl(url: unknown): string {
if (typeof url !== "string" || !url) {
return "/";
}
return isValidRedirectUrl(url) ? url : "/";
}
// Refresh when expiry is within threshold to prevent mid-operation failures.
// Returns false for already-expired sessions.
export function shouldRefreshSession(
expiresAt: Date | string | undefined,
): boolean {
if (!expiresAt) return false;
const expiryTime =
typeof expiresAt === "string"
? new Date(expiresAt).getTime()
: expiresAt.getTime();
const now = Date.now();
const timeUntilExpiry = expiryTime - now;
return (
timeUntilExpiry > 0 && timeUntilExpiry < authConfig.session.refreshThreshold
);
}
================================================
FILE: apps/app/lib/auth.ts
================================================
/**
* @file Better Auth client instance.
*
* Do not use auth.useSession() directly - use TanStack Query wrappers
* from lib/queries/session.ts to ensure proper caching and consistency.
*/
import { passkeyClient } from "@better-auth/passkey/client";
import { stripeClient } from "@better-auth/stripe/client";
import {
anonymousClient,
emailOTPClient,
organizationClient,
} from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
import { authConfig } from "./auth-config";
const baseURL =
typeof window !== "undefined"
? window.location.origin
: "http://localhost:5173";
export const auth = createAuthClient({
baseURL: baseURL + authConfig.api.basePath,
plugins: [
anonymousClient(),
emailOTPClient(),
organizationClient(),
passkeyClient(),
stripeClient({ subscription: true }),
],
});
export type AuthClient = typeof auth;
// Inferred types from configured instance - includes plugin extensions
// $Infer.Session is the full response shape { user, session }
type SessionResponse = typeof auth.$Infer.Session;
export type User = SessionResponse["user"];
export type Session = SessionResponse["session"];
================================================
FILE: apps/app/lib/errors.test.ts
================================================
import { describe, expect, it } from "vitest";
import {
getErrorMessage,
getErrorStatus,
isUnauthenticatedError,
} from "./errors";
describe("getErrorStatus", () => {
it("returns undefined for non-objects", () => {
expect(getErrorStatus(null)).toBeUndefined();
expect(getErrorStatus(undefined)).toBeUndefined();
expect(getErrorStatus("string")).toBeUndefined();
expect(getErrorStatus(123)).toBeUndefined();
});
it("extracts direct status property", () => {
expect(getErrorStatus({ status: 401 })).toBe(401);
expect(getErrorStatus({ status: 500 })).toBe(500);
});
it("ignores non-numeric status", () => {
expect(getErrorStatus({ status: "401" })).toBeUndefined();
expect(getErrorStatus({ status: null })).toBeUndefined();
});
it("extracts nested response.status (axios-style)", () => {
expect(getErrorStatus({ response: { status: 403 } })).toBe(403);
});
it("follows error cause chain", () => {
const nested = { status: 401 };
const wrapper = { cause: nested };
expect(getErrorStatus(wrapper)).toBe(401);
});
it("handles deep cause chains", () => {
const deep = { cause: { cause: { cause: { status: 500 } } } };
expect(getErrorStatus(deep)).toBe(500);
});
it("handles circular cause references without stack overflow", () => {
const circular: Record<string, unknown> = { status: undefined };
circular.cause = circular;
expect(getErrorStatus(circular)).toBeUndefined();
});
it("prefers direct status over nested", () => {
expect(getErrorStatus({ status: 401, response: { status: 500 } })).toBe(
401,
);
});
});
describe("getErrorMessage", () => {
it("extracts message from Error instances", () => {
expect(getErrorMessage(new Error("Something broke"))).toBe(
"Something broke",
);
});
it("returns string errors directly", () => {
expect(getErrorMessage("Direct error message")).toBe(
"Direct error message",
);
});
it("extracts statusText from Response-like objects", () => {
expect(getErrorMessage({ statusText: "Not Found" })).toBe("Not Found");
});
it("returns fallback for unknown error shapes", () => {
expect(getErrorMessage(null)).toBe("An unexpected error occurred");
expect(getErrorMessage(undefined)).toBe("An unexpected error occurred");
expect(getErrorMessage({})).toBe("An unexpected error occurred");
expect(getErrorMessage({ statusText: "" })).toBe(
"An unexpected error occurred",
);
});
});
describe("isUnauthenticatedError", () => {
it("returns true for 401 status", () => {
expect(isUnauthenticatedError({ status: 401 })).toBe(true);
});
it("returns false for 403 status (authorization, not authentication)", () => {
expect(isUnauthenticatedError({ status: 403 })).toBe(false);
});
it("returns false for other status codes", () => {
expect(isUnauthenticatedError({ status: 500 })).toBe(false);
expect(isUnauthenticatedError({ status: 404 })).toBe(false);
});
it("returns false for non-error values", () => {
expect(isUnauthenticatedError(null)).toBe(false);
expect(isUnauthenticatedError("error")).toBe(false);
expect(isUnauthenticatedError({})).toBe(false);
});
it("detects 401 in nested cause", () => {
expect(isUnauthenticatedError({ cause: { status: 401 } })).toBe(true);
});
it("returns true for tRPC UNAUTHORIZED code", () => {
expect(isUnauthenticatedError({ data: { code: "UNAUTHORIZED" } })).toBe(
true,
);
});
it("returns false for tRPC FORBIDDEN code", () => {
expect(isUnauthenticatedError({ data: { code: "FORBIDDEN" } })).toBe(false);
});
});
================================================
FILE: apps/app/lib/errors.ts
================================================
// Extract HTTP status from various error shapes (with cycle guard)
export function getErrorStatus(
error: unknown,
seen = new WeakSet<object>(),
): number | undefined {
if (!error || typeof error !== "object") return undefined;
if (seen.has(error)) return undefined;
seen.add(error);
const err = error as Record<string, unknown>;
// Direct status (tRPC, Better Auth)
if (typeof err.status === "number") return err.status;
// Nested in response (axios-style)
if (
err.response &&
typeof (err.response as Record<string, unknown>).status === "number"
) {
return (err.response as Record<string, unknown>).status as number;
}
// Error cause chain
if (err.cause) return getErrorStatus(err.cause, seen);
return undefined;
}
// Check if error indicates unauthenticated state (401).
// Maps tRPC UNAUTHORIZED code and HTTP 401 status to a semantic boolean.
// Does not match 403 (forbidden) - that means authenticated but lacking permission.
export function isUnauthenticatedError(error: unknown): boolean {
// tRPC errors expose typed code
if (error && typeof error === "object" && "data" in error) {
const data = (error as { data?: { code?: string } }).data;
if (data?.code === "UNAUTHORIZED") return true;
}
return getErrorStatus(error) === 401;
}
// Safely extract message from any thrown value
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
// Response objects (fetch API)
if (error && typeof error === "object" && "statusText" in error) {
const statusText = (error as { statusText?: string }).statusText;
if (statusText) return statusText;
}
return "An unexpected error occurred";
}
================================================
FILE: apps/app/lib/queries/README.md
================================================
# TanStack Queries
This folder contains TanStack Query implementations for managing server state and data fetching in the application.
## Overview
TanStack Query provides powerful asynchronous state management for TypeScript/JavaScript applications. All query definitions in this folder follow a consistent pattern that enables:
- **Automatic caching** - Data is cached and reused across components
- **Background refetching** - Stale data is automatically refreshed
- **Request deduplication** - Multiple components requesting the same data result in a single network request
- **Optimistic updates** - UI updates immediately while mutations are in-flight
- **Smart refetching** - Automatic refetch on window focus, network reconnect, and at configurable intervals
## File Structure
Each query module typically exports:
1. **Query Keys** - Unique identifiers for cache entries
2. **Query Options** - Factory functions returning query configurations
3. **Custom Hooks** - React hooks for consuming queries
4. **Utility Functions** - Helpers for prefetching, invalidation, and manual updates
## Pattern Example
```typescript
// user.ts - Example query module structure
import {
queryOptions,
useQuery,
useSuspenseQuery,
} from "@tanstack/react-query";
import type { QueryClient } from "@tanstack/react-query";
// 1. Define query keys with consistent naming
export const userQueryKey = ["users", "detail"] as const;
export const usersListQueryKey = ["users", "list"] as const;
// 2. Create query options factory functions
export function userQueryOptions(userId: string) {
return queryOptions({
queryKey: [...userQueryKey, userId],
queryFn: async () => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error("Failed to fetch user");
return response.json();
},
staleTime: 5 * 60_000, // Consider data fresh for 5 minutes
gcTime: 10 * 60_000, // Keep in cache for 10 minutes
});
}
// 3. Export convenient hooks
export function useUser(userId: string) {
return useQuery(userQueryOptions(userId));
}
export function useSuspenseUser(userId: string) {
return useSuspenseQuery(userQueryOptions(userId));
}
// 4. Provide utility functions
export async function prefetchUser(queryClient: QueryClient, userId: string) {
return queryClient.prefetchQuery(userQueryOptions(userId));
}
export function invalidateUser(queryClient: QueryClient, userId: string) {
return queryClient.invalidateQueries({
queryKey: [...userQueryKey, userId],
});
}
```
## Query Key Conventions
Query keys should follow a hierarchical structure:
```typescript
["resource"][("resource", "list")][("resource", "list", { filters })][ // All queries for a resource // List queries // List with filters
("resource", "detail", id)
][("resource", "detail", id, "related")]; // Single item queries // Nested resources
```
## Configuration Guidelines
### staleTime
- How long data is considered fresh
- During this time, no background refetches occur
- Set based on data volatility (user sessions: 30s, static content: hours)
### gcTime (garbage collection time)
- How long to keep unused data in cache
- Should be >= staleTime
- Prevents refetching when navigating back quickly
### refetchOnWindowFocus
- Ensures data is fresh when users return
- Disable for rarely-changing data
- Critical for authentication state
### retry
- Number of retry attempts for failed queries
- Use exponential backoff with retryDelay
- Consider disabling for 4xx errors
## Current Implementations
### `session.ts`
Manages authentication session state with Better Auth integration:
- Automatic session refresh before expiry
- Optimistic updates during auth state changes
- Cache invalidation on login/logout
- Prefetching for protected routes
## Best Practices
1. **Colocate queries with their domain** - Keep related queries in the same file
2. **Export query options** - Allows usage in loaders and prefetching
3. **Use TypeScript** - Define return types for better type safety
4. **Handle errors gracefully** - Queries should throw meaningful errors
5. **Optimize cache times** - Balance freshness with performance
6. **Leverage suspense** - Use `useSuspenseQuery` with error boundaries
7. **Prefetch critical data** - Load data before users need it
## Testing
When testing components that use queries:
```typescript
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: Infinity },
mutations: { retry: false },
},
});
// In your test
const queryClient = createTestQueryClient();
render(
<QueryClientProvider client={queryClient}>
<YourComponent />
</QueryClientProvider>
);
```
## Resources
- [TanStack Query Documentation](https://tanstack.com/query/latest)
- [Query Keys](https://tanstack.com/query/latest/docs/framework/react/guides/query-keys)
- [Query Functions](https://tanstack.com/query/latest/docs/framework/react/guides/query-functions)
- [Suspense](https://tanstack.com/query/latest/docs/framework/react/guides/suspense)
================================================
FILE: apps/app/lib/queries/billing.test.ts
================================================
import { describe, expect, it } from "vitest";
import { billingQueryKey, billingQueryOptions } from "./billing";
describe("billingQueryOptions", () => {
it("includes activeOrgId in query key", () => {
const { queryKey } = billingQueryOptions("org-123");
expect(queryKey).toEqual(["billing", "subscription", "org-123"]);
});
it("normalizes undefined to null in query key", () => {
const { queryKey } = billingQueryOptions(undefined);
expect(queryKey).toEqual(["billing", "subscription", null]);
});
it("normalizes missing arg to null in query key", () => {
const { queryKey } = billingQueryOptions();
expect(queryKey).toEqual(["billing", "subscription", null]);
});
it("preserves explicit null in query key", () => {
const { queryKey } = billingQueryOptions(null);
expect(queryKey).toEqual(["billing", "subscription", null]);
});
it("produces distinct keys for different orgs", () => {
expect(billingQueryOptions("org-1").queryKey).not.toEqual(
billingQueryOptions("org-2").queryKey,
);
});
});
describe("billingQueryKey", () => {
it("is a prefix of the full query key for bulk invalidation", () => {
const { queryKey } = billingQueryOptions("org-1");
expect(queryKey.slice(0, billingQueryKey.length)).toEqual([
...billingQueryKey,
]);
});
});
================================================
FILE: apps/app/lib/queries/billing.ts
================================================
// Billing subscription state via TanStack Query.
// Query key includes activeOrgId so switching organizations refetches automatically.
import {
queryOptions,
useQuery,
useSuspenseQuery,
} from "@tanstack/react-query";
import { trpcClient } from "../trpc";
// Partial key for bulk invalidation (e.g. after subscription change)
export const billingQueryKey = ["billing", "subscription"] as const;
export function billingQueryOptions(activeOrgId?: string | null) {
return queryOptions({
queryKey: [...billingQueryKey, activeOrgId ?? null] as const,
queryFn: () => trpcClient.billing.subscription.query(),
});
}
export function useBillingQuery(activeOrgId?: string | null) {
return useQuery(billingQueryOptions(activeOrgId));
}
export function useSuspenseBillingQuery(activeOrgId?: string | null) {
return useSuspenseQuery(billingQueryOptions(activeOrgId));
}
================================================
FILE: apps/app/lib/queries/session.test.ts
================================================
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { getCachedSession, isAuthenticated, sessionQueryKey } from "./session";
function createQueryClient() {
return new QueryClient({
defaultOptions: { queries: { retry: false } },
});
}
describe("isAuthenticated", () => {
it("returns false when no session data cached", () => {
const queryClient = createQueryClient();
expect(isAuthenticated(queryClient)).toBe(false);
});
it("returns false when session is null", () => {
const queryClient = createQueryClient();
queryClient.setQueryData(sessionQueryKey, null);
expect(isAuthenticated(queryClient)).toBe(false);
});
it("returns false when user is missing", () => {
const queryClient = createQueryClient();
queryClient.setQueryData(sessionQueryKey, {
session: { id: "1" },
user: null,
});
expect(isAuthenticated(queryClient)).toBe(false);
});
it("returns false when session is missing", () => {
const queryClient = createQueryClient();
queryClient.setQueryData(sessionQueryKey, {
user: { id: "1" },
session: null,
});
expect(isAuthenticated(queryClient)).toBe(false);
});
it("returns true when both user and session exist", () => {
const queryClient = createQueryClient();
queryClient.setQueryData(sessionQueryKey, {
user: { id: "user-1", email: "test@example.com" },
session: { id: "session-1", expiresAt: new Date() },
});
expect(isAuthenticated(queryClient)).toBe(true);
});
});
describe("getCachedSession", () => {
it("returns undefined when no data cached", () => {
const queryClient = createQueryClient();
expect(getCachedSession(queryClient)).toBeUndefined();
});
it("returns cached session data", () => {
const queryClient = createQueryClient();
const sessionData = {
user: { id: "user-1" },
session: { id: "session-1" },
};
queryClient.setQueryData(sessionQueryKey, sessionData);
expect(getCachedSession(queryClient)).toEqual(sessionData);
});
});
================================================
FILE: apps/app/lib/queries/session.ts
================================================
/**
* @file Session state managed exclusively via TanStack Query.
*
* Do not use direct auth.getSession() calls or local storage for sessions.
* TanStack Query handles caching, refresh, and consistency automatically.
*/
import { getErrorStatus } from "@/lib/errors";
import type { QueryClient } from "@tanstack/react-query";
import {
queryOptions,
useQuery,
useSuspenseQuery,
} from "@tanstack/react-query";
import { auth, type Session, type User } from "../auth";
// Both user and session must be present for valid auth state
export interface SessionData {
user: User;
session: Session;
}
export const sessionQueryKey = ["auth", "session"] as const;
// Returns null when unauthenticated (not an error condition).
// Only overrides staleTime and retry — inherits gcTime, refetchOnWindowFocus,
// refetchOnReconnect, and retryDelay from QueryClient defaults.
export function sessionQueryOptions() {
return queryOptions<SessionData | null>({
queryKey: sessionQueryKey,
queryFn: async () => {
const response = await auth.getSession();
if (response.error) {
throw response.error;
}
return response.data;
},
// Shorter freshness than global 2min — auth state should stay current
staleTime: 30_000,
// Don't retry 401/403 — retrying won't help for auth/permission errors
retry(failureCount, error) {
const status = getErrorStatus(error);
if (status === 401 || status === 403) return false;
return failureCount < 3;
},
});
}
export function useSessionQuery() {
return useQuery(sessionQueryOptions());
}
export function useSuspenseSessionQuery() {
return useSuspenseQuery(sessionQueryOptions());
}
export function getCachedSession(
queryClient: QueryClient,
): SessionData | null | undefined {
return queryClient.getQueryData(sessionQueryKey);
}
// Sync check of cached data only - does not trigger network request.
// Both user AND session must exist to handle partial data edge cases.
export function isAuthenticated(queryClient: QueryClient): boolean {
const session = getCachedSession(queryClient);
return session?.user != null && session?.session != null;
}
// Clears server session, then updates cache and redirects.
// Uses setQueryData(null) instead of invalidateQueries to avoid a wasted
// refetch — session is binary state, not partially stale data.
// Hard redirect resets all in-memory state (Jotai atoms, component state)
// for a clean slate between user sessions.
export async function signOut(
queryClient: QueryClient,
options?: { redirect?: boolean },
) {
try {
await auth.signOut();
} finally {
queryClient.setQueryData(sessionQueryKey, null);
if (options?.redirect !== false) {
window.location.href = "/login";
}
}
}
/**
* Clears session cache and revalidates router after auth state changes.
* Uses removeQueries (not invalidate) so beforeLoad sees undefined and fetches fresh.
*/
export async function revalidateSession(
queryClient: QueryClient,
router: { invalidate: () => Promise<void> },
) {
queryClient.removeQueries({ queryKey: sessionQueryKey });
await router.invalidate();
}
================================================
FILE: apps/app/lib/query.ts
================================================
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Data remains fresh for 2 minutes - prevents redundant API calls during
// typical user sessions while ensuring data updates within reasonable time
staleTime: 2 * 60 * 1000,
// Garbage collection after 5 minutes - balances memory usage with instant
// data availability when navigating back to recently viewed pages
gcTime: 5 * 60 * 1000,
// Retry strategy: 3 attempts with exponential backoff (1s, 2s, 4s) capped at 30s
// Handles transient network issues without overwhelming the server
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
// Auto-refetch when user returns to tab - ensures displayed data is current
// after context switches (critical for collaborative features)
refetchOnWindowFocus: true,
// Always refetch after network reconnection - prevents stale data after
// connectivity issues (overrides staleTime check)
refetchOnReconnect: "always",
},
mutations: {
// Single retry for mutations - prevents duplicate operations while handling
// momentary network blips (user can manually retry for persistent failures)
retry: 1,
retryDelay: 1000,
onError: (error) => console.error("Mutation failed:", error),
},
},
});
================================================
FILE: apps/app/lib/routeTree.gen.ts
================================================
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './../routes/__root'
import { Route as appRouteRouteImport } from './../routes/(app)/route'
import { Route as appIndexRouteImport } from './../routes/(app)/index'
import { Route as authSignupRouteImport } from './../routes/(auth)/signup'
import { Route as authLoginRouteImport } from './../routes/(auth)/login'
import { Route as appUsersRouteImport } from './../routes/(app)/users'
import { Route as appSettingsRouteImport } from './../routes/(app)/settings'
import { Route as appReportsRouteImport } from './../routes/(app)/reports'
import { Route as appDashboardRouteImport } from './../routes/(app)/dashboard'
import { Route as appAnalyticsRouteImport } from './../routes/(app)/analytics'
import { Route as appAboutRouteImport } from './../routes/(app)/about'
const appRouteRoute = appRouteRouteImport.update({
id: '/(app)',
getParentRoute: () => rootRouteImport,
} as any)
const appIndexRoute = appIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => appRouteRoute,
} as any)
const authSignupRoute = authSignupRouteImport.update({
id: '/(auth)/signup',
path: '/signup',
getParentRoute: () => rootRouteImport,
} as any)
const authLoginRoute = authLoginRouteImport.update({
id: '/(auth)/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const appUsersRoute = appUsersRouteImport.update({
id: '/users',
path: '/users',
getParentRoute: () => appRouteRoute,
} as any)
const appSettingsRoute = appSettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => appRouteRoute,
} as any)
const appReportsRoute = appReportsRouteImport.update({
id: '/reports',
path: '/reports',
getParentRoute: () => appRouteRoute,
} as any)
const appDashboardRoute = appDashboardRouteImport.update({
id: '/dashboard',
path: '/dashboard',
getParentRoute: () => appRouteRoute,
} as any)
const appAnalyticsRoute = appAnalyticsRouteImport.update({
id: '/analytics',
path: '/analytics',
getParentRoute: () => appRouteRoute,
} as any)
const appAboutRoute = appAboutRouteImport.update({
id: '/about',
path: '/about',
getParentRoute: () => appRouteRoute,
} as any)
export interface FileRoutesByFullPath {
'/about': typeof appAboutRoute
'/analytics': typeof appAnalyticsRoute
'/dashboard': typeof appDashboardRoute
'/reports': typeof appReportsRoute
'/settings': typeof appSettingsRoute
'/users': typeof appUsersRoute
'/login': typeof authLoginRoute
'/signup': typeof authSignupRoute
'/': typeof appIndexRoute
}
export interface FileRoutesByTo {
'/about': typeof appAboutRoute
'/analytics': typeof appAnalyticsRoute
'/dashboard': typeof appDashboardRoute
'/reports': typeof appReportsRoute
'/settings': typeof appSettingsRoute
'/users': typeof appUsersRoute
'/login': typeof authLoginRoute
'/signup': typeof authSignupRoute
'/': typeof appIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/(app)': typeof appRouteRouteWithChildren
'/(app)/about': typeof appAboutRoute
'/(app)/analytics': typeof appAnalyticsRoute
'/(app)/dashboard': typeof appDashboardRoute
'/(app)/reports': typeof appReportsRoute
'/(app)/settings': typeof appSettingsRoute
'/(app)/users': typeof appUsersRoute
'/(auth)/login': typeof authLoginRoute
'/(auth)/signup': typeof authSignupRoute
'/(app)/': typeof appIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/about'
| '/analytics'
| '/dashboard'
| '/reports'
| '/settings'
| '/users'
| '/login'
| '/signup'
| '/'
fileRoutesByTo: FileRoutesByTo
to:
| '/about'
| '/analytics'
| '/dashboard'
| '/reports'
| '/settings'
| '/users'
| '/login'
| '/signup'
| '/'
id:
| '__root__'
| '/(app)'
| '/(app)/about'
| '/(app)/analytics'
| '/(app)/dashboard'
| '/(app)/reports'
| '/(app)/settings'
| '/(app)/users'
| '/(auth)/login'
| '/(auth)/signup'
| '/(app)/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
appRouteRoute: typeof appRouteRouteWithChildren
authLoginRoute: typeof authLoginRoute
authSignupRoute: typeof authSignupRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/(app)': {
id: '/(app)'
path: ''
fullPath: ''
preLoaderRoute: typeof appRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/(app)/': {
id: '/(app)/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof appIndexRouteImport
parentRoute: typeof appRouteRoute
}
'/(auth)/signup': {
id: '/(auth)/signup'
path: '/signup'
fullPath: '/signup'
preLoaderRoute: typeof authSignupRouteImport
parentRoute: typeof rootRouteImport
}
'/(auth)/login': {
id: '/(auth)/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof authLoginRouteImport
parentRoute: typeof rootRouteImport
}
'/(app)/users': {
id: '/(app)/users'
path: '/users'
fullPath: '/users'
preLoaderRoute: typeof appUsersRouteImport
parentRoute: typeof appRouteRoute
}
'/(app)/settings': {
id: '/(app)/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof appSettingsRouteImport
parentRoute: typeof appRouteRoute
}
'/(app)/reports': {
id: '/(app)/reports'
path: '/reports'
fullPath: '/reports'
preLoaderRoute: typeof appReportsRouteImport
parentRoute: typeof appRouteRoute
}
'/(app)/dashboard': {
id: '/(app)/dashboard'
path: '/dashboard'
fullPath: '/dashboard'
preLoaderRoute: typeof appDashboardRouteImport
parentRoute: typeof appRouteRoute
}
'/(app)/analytics': {
id: '/(app)/analytics'
path: '/analytics'
fullPath: '/analytics'
preLoaderRoute: typeof appAnalyticsRouteImport
parentRoute: typeof appRouteRoute
}
'/(app)/about': {
id: '/(app)/about'
path: '/about'
fullPath: '/about'
preLoaderRoute: typeof appAboutRouteImport
parentRoute: typeof appRouteRoute
}
}
}
interface appRouteRouteChildren {
appAboutRoute: typeof appAboutRoute
appAnalyticsRoute: typeof appAnalyticsRoute
appDashboardRoute: typeof appDashboardRoute
appReportsRoute: typeof appReportsRoute
appSettingsRoute: typeof appSettingsRoute
appUsersRoute: typeof appUsersRoute
appIndexRoute: typeof appIndexRoute
}
const appRouteRouteChildren: appRouteRouteChildren = {
appAboutRoute: appAboutRoute,
appAnalyticsRoute: appAnalyticsRoute,
appDashboardRoute: appDashboardRoute,
appReportsRoute: appReportsRoute,
appSettingsRoute: appSettingsRoute,
appUsersRoute: appUsersRoute,
appIndexRoute: appIndexRoute,
}
const appRouteRouteWithChildren = appRouteRoute._addFileChildren(
appRouteRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
appRouteRoute: appRouteRouteWithChildren,
authLoginRoute: authLoginRoute,
authSignupRoute: authSignupRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
================================================
FILE: apps/app/lib/store.ts
================================================
import { createStore, Provider } from "jotai";
import type { ReactNode } from "react";
import { createElement } from "react";
/**
* Global state management powered by Jotai.
* @see https://jotai.org/
*/
export const store = createStore();
export function StoreProvider(props: StoreProviderProps) {
return createElement(Provider, { store, ...props });
}
export type StoreProviderProps = {
children: ReactNode;
};
================================================
FILE: apps/app/lib/trpc.ts
================================================
import type { AppRouter } from "@repo/api";
import {
createTRPCClient,
httpBatchLink,
type TRPCLink,
loggerLink,
} from "@trpc/client";
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
import { queryClient } from "./query";
// Build links array conditionally based on environment
const links: TRPCLink<AppRouter>[] = [];
// Add logger link in development for debugging
if (import.meta.env.DEV) {
links.push(
loggerLink({
enabled: (opts) =>
(import.meta.env.DEV && typeof window !== "undefined") ||
(opts.direction === "down" && opts.result instanceof Error),
}),
);
}
// Add HTTP batch link for actual requests
links.push(
httpBatchLink({
url: `${import.meta.env.VITE_API_URL || "/api"}/trpc`,
// Custom headers for request tracking
headers() {
return {
"x-trpc-source": "react-app",
};
},
// Include credentials for authentication
fetch(url, options) {
return fetch(url, {
...options,
credentials: "include",
});
},
}),
);
export const trpcClient = createTRPCClient<AppRouter>({ links });
export const api = createTRPCOptionsProxy<AppRouter>({
client: trpcClient,
queryClient,
});
================================================
FILE: apps/app/lib/utils.ts
================================================
export { cn } from "@repo/ui";
================================================
FILE: apps/app/package.json
================================================
{
"name": "@repo/app",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite serve",
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"coverage": "vitest --coverage",
"typecheck": "tsc --noEmit",
"deploy": "wrangler deploy --env-file ../../.env --env-file ../../.env.local",
"logs": "wrangler tail --env-file ../../.env --env-file ../../.env.local"
},
"dependencies": {
"@better-auth/passkey": "^1.4.18",
"@better-auth/stripe": "^1.4.18",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@repo/ui": "workspace:*",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.161.1",
"@trpc/client": "^11.10.0",
"@trpc/tanstack-react-query": "^11.10.0",
"better-auth": "^1.4.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"jotai": "^2.17.1",
"jotai-effect": "^2.2.3",
"localforage": "^1.10.0",
"lucide-react": "^0.574.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-error-boundary": "^6.1.1",
"tailwind-merge": "^3.4.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"@tailwindcss/postcss": "^4.2.0",
"@tanstack/react-query-devtools": "^5.91.3",
"@tanstack/react-router-devtools": "^1.161.1",
"@tanstack/router-plugin": "^1.161.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/bun": "^1.3.9",
"@types/node": "^25.2.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"@vitejs/plugin-react-swc": "^4.2.3",
"autoprefixer": "^10.4.24",
"envars": "^1.1.1",
"execa": "^9.6.1",
"globby": "^16.1.1",
"happy-dom": "^20.6.2",
"postcss": "^8.5.6",
"tailwindcss": "^4.2.0",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"vite": "~7.3.1",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "~4.0.18",
"wrangler": "^4.66.0"
}
}
================================================
FILE: apps/app/postcss.config.js
================================================
export default {
plugins: {
"@tailwindcss/postcss": {},
autoprefixer: {},
},
};
================================================
FILE: apps/app/public/robots.txt
================================================
# www.robotstxt.org/
# Allow crawling of all content
User-agent: *
Disallow:
================================================
FILE: apps/app/public/site.manifest
================================================
{
"short_name": "React App",
"name": "React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": "/?utm_source=homescreen",
"display": "standalone",
"background_color": "#fafafa",
"theme_color": "#fafafa"
}
================================================
FILE: apps/app/routes/(app)/about.tsx
================================================
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Separator,
} from "@repo/ui";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/(app)/about")({
component: About,
});
function About() {
return (
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-20">
{/* Hero Section */}
<div className="text-center mb-16">
<h1 className="text-4xl font-bold tracking-tight mb-6">
About React Starter Kit
</h1>
<p className="text-xl text-muted-foreground max-w-3xl mx-auto">
A production-ready, full-stack web application template that combines
modern development practices with cutting-edge technologies to deliver
exceptional performance and developer experience.
</p>
</div>
{/* Mission Section */}
<section className="mb-20">
<Card>
<CardHeader>
<CardTitle className="text-2xl">Our Mission</CardTitle>
<CardDescription>
Empowering developers to build faster, better web applications
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-muted-foreground">
React Starter Kit was created to bridge the gap between prototype
and production. We believe that developers should focus on
building great features, not wrestling with configuration and
setup.
</p>
<p className="text-muted-foreground">
Our template provides a solid foundation with best practices,
modern tooling, and optimized performance out of the box, so you
can ship your ideas faster and with confidence.
</p>
</CardContent>
</Card>
</section>
{/* Key Features */}
<section className="mb-20">
<h2 className="text-3xl font-bold tracking-tight mb-8 text-center">
What Makes Us Different
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<Card>
<CardHeader>
<CardTitle>🎯 Production-Ready</CardTitle>
<CardDescription>
Not just a demo, but a real foundation for your applications
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Every component, pattern, and configuration has been
battle-tested in production environments. Security, performance,
and maintainability are built-in from day one.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>⚡ Edge-First Architecture</CardTitle>
<CardDescription>
Optimized for global performance at CDN edge locations
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Built specifically for Cloudflare Workers and edge computing.
Your applications run closer to your users for lightning-fast
response times.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>🔧 Developer Experience</CardTitle>
<CardDescription>
Carefully crafted tooling for maximum productivity
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Hot reload, TypeScript support, comprehensive testing setup, and
intuitive project structure. Everything you need to stay in the
flow.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>🌐 Full-Stack Solution</CardTitle>
<CardDescription>
Complete backend and frontend in one cohesive package
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
tRPC for type-safe APIs, Better Auth for authentication and
database, and WebSocket support for real-time features.
</p>
</CardContent>
</Card>
</div>
</section>
{/* Technology Choices */}
<section className="mb-20">
<h2 className="text-3xl font-bold tracking-tight mb-8 text-center">
Technology Choices
</h2>
<Card>
<CardContent className="pt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<h3 className="font-semibold mb-4">Frontend Stack</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>
<strong>React 19:</strong> Latest React with concurrent
features
</li>
<li>
<strong>TypeScript:</strong> Type safety and better
developer experience
</li>
<li>
<strong>Vite:</strong> Lightning-fast build tool and dev
server
</li>
<li>
<strong>TanStack Router:</strong> Type-safe routing with
code splitting
</li>
<li>
<strong>shadcn/ui:</strong> Beautiful, accessible component
library
</li>
<li>
<strong>Tailwind CSS:</strong> Utility-first CSS framework
</li>
</ul>
</div>
<div>
<h3 className="font-semibold mb-4">Backend Stack</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
<li>
<strong>Bun:</strong> Fast JavaScript runtime and package
manager
</li>
<li>
<strong>Hono:</strong> Ultra-fast web framework for edge
computing
</li>
<li>
<strong>tRPC:</strong> End-to-end type safety for APIs
</li>
<li>
<strong>Better Auth:</strong> Authentication
</li>
<li>
<strong>Cloudflare Workers:</strong> Serverless edge
computing
</li>
<li>
<strong>WebSockets:</strong> Real-time communication support
</li>
</ul>
</div>
</div>
</CardContent>
</Card>
</section>
{/* Team Section */}
<section className="mb-20">
<h2 className="text-3xl font-bold tracking-tight mb-8 text-center">
Built by Kriasoft
</h2>
<Card>
<CardContent className="pt-6 text-center">
<p className="text-muted-foreground mb-6">
React Starter Kit is maintained by Kriasoft, a team of experienced
developers passionate about modern web technologies and developer
experience.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button asChild>
<a
href="https://github.com/kriasoft"
target="_blank"
rel="noopener noreferrer"
>
Visit Kriasoft on GitHub
</a>
</Button>
<Button variant="outline" asChild>
<a
href="https://kriasoft.com"
target="_blank"
rel="noopener noreferrer"
>
Learn More About Kriasoft
</a>
</Button>
</div>
</CardContent>
</Card>
</section>
<Separator className="my-12" />
{/* CTA Section */}
<section className="text-center">
<h2 className="text-3xl font-bold tracking-tight mb-4">
Ready to Get Started?
</h2>
<p className="text-muted-foreground text-lg mb-8 max-w-2xl mx-auto">
Join thousands of developers who have chosen React Starter Kit for
their next project.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button size="lg" asChild>
<a
href="https://github.com/kriasoft/react-starter-kit"
target="_blank"
rel="noopener noreferrer"
>
Get Started Now
</a>
</Button>
<Button variant="outline" size="lg" asChild>
<a
href="https://github.com/kriasoft/react-st
gitextract_ge1ncw1v/ ├── .claude/ │ └── commands/ │ ├── migrate-to-d1.md │ ├── review-better-auth.md │ ├── review-terraform.md │ └── validate-auth-schema.md ├── .editorconfig ├── .gemini/ │ └── settings.json ├── .gitattributes ├── .github/ │ ├── CODE_OF_CONDUCT.md │ ├── CONTRIBUTING.md │ ├── FUNDING.yml │ ├── SECURITY.md │ ├── copilot-instructions.md │ ├── dependabot.yml │ └── workflows/ │ ├── ci.yml │ ├── conventional-commits.yml │ └── deploy.yml ├── .gitignore ├── .husky/ │ ├── .gitignore │ └── pre-commit ├── .prettierignore ├── .vscode/ │ ├── extensions.json │ ├── mcp.json │ └── settings.json ├── AGENTS.md ├── CLAUDE.md ├── LICENSE ├── README.md ├── apps/ │ ├── api/ │ │ ├── AGENTS.md │ │ ├── Dockerfile │ │ ├── README.md │ │ ├── dev.ts │ │ ├── index.ts │ │ ├── lib/ │ │ │ ├── ai.ts │ │ │ ├── app.ts │ │ │ ├── auth.ts │ │ │ ├── context.ts │ │ │ ├── db.ts │ │ │ ├── email.ts │ │ │ ├── env.ts │ │ │ ├── loaders.ts │ │ │ ├── middleware.ts │ │ │ ├── plans.ts │ │ │ ├── stripe.ts │ │ │ └── trpc.ts │ │ ├── package.json │ │ ├── routers/ │ │ │ ├── billing.test.ts │ │ │ ├── billing.ts │ │ │ ├── organization.ts │ │ │ └── user.ts │ │ ├── tsconfig.json │ │ ├── vitest.config.ts │ │ ├── worker.ts │ │ └── wrangler.jsonc │ ├── app/ │ │ ├── AGENTS.md │ │ ├── README.md │ │ ├── components/ │ │ │ ├── auth/ │ │ │ │ ├── auth-error-boundary.tsx │ │ │ │ ├── auth-form.tsx │ │ │ │ ├── google-login.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── login-dialog.tsx │ │ │ │ ├── otp-verification.tsx │ │ │ │ ├── passkey-login.tsx │ │ │ │ └── use-auth-form.ts │ │ │ ├── index.ts │ │ │ ├── layout/ │ │ │ │ ├── constants.ts │ │ │ │ ├── header.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── sidebar-nav.tsx │ │ │ │ └── sidebar.tsx │ │ │ ├── not-found.tsx │ │ │ └── user-menu.tsx │ │ ├── components.json │ │ ├── global.d.ts │ │ ├── index.html │ │ ├── index.tsx │ │ ├── lib/ │ │ │ ├── auth-config.ts │ │ │ ├── auth.ts │ │ │ ├── errors.test.ts │ │ │ ├── errors.ts │ │ │ ├── queries/ │ │ │ │ ├── README.md │ │ │ │ ├── billing.test.ts │ │ │ │ ├── billing.ts │ │ │ │ ├── session.test.ts │ │ │ │ └── session.ts │ │ │ ├── query.ts │ │ │ ├── routeTree.gen.ts │ │ │ ├── store.ts │ │ │ ├── trpc.ts │ │ │ └── utils.ts │ │ ├── package.json │ │ ├── postcss.config.js │ │ ├── public/ │ │ │ ├── robots.txt │ │ │ └── site.manifest │ │ ├── routes/ │ │ │ ├── (app)/ │ │ │ │ ├── about.tsx │ │ │ │ ├── analytics.tsx │ │ │ │ ├── dashboard.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── reports.tsx │ │ │ │ ├── route.tsx │ │ │ │ ├── settings.tsx │ │ │ │ └── users.tsx │ │ │ ├── (auth)/ │ │ │ │ ├── login.tsx │ │ │ │ └── signup.tsx │ │ │ └── __root.tsx │ │ ├── styles/ │ │ │ └── globals.css │ │ ├── tailwind.config.css │ │ ├── tsconfig.json │ │ ├── vite.config.ts │ │ ├── vitest.setup.ts │ │ └── wrangler.jsonc │ ├── email/ │ │ ├── README.md │ │ ├── components/ │ │ │ └── BaseTemplate.tsx │ │ ├── emails/ │ │ │ ├── email-verification.tsx │ │ │ ├── otp-password-reset.tsx │ │ │ ├── otp-sign-in.tsx │ │ │ ├── otp-verification.tsx │ │ │ └── password-reset.tsx │ │ ├── index.ts │ │ ├── package.json │ │ ├── templates/ │ │ │ ├── email-verification.tsx │ │ │ ├── otp-email.tsx │ │ │ └── password-reset.tsx │ │ ├── tsconfig.json │ │ └── utils/ │ │ └── render.ts │ └── web/ │ ├── README.md │ ├── _headers │ ├── astro.config.mjs │ ├── layouts/ │ │ └── BaseLayout.astro │ ├── lib/ │ │ └── utils.ts │ ├── package.json │ ├── pages/ │ │ ├── about.astro │ │ ├── features.astro │ │ ├── index.astro │ │ └── pricing.astro │ ├── postcss.config.js │ ├── public/ │ │ ├── robots.txt │ │ └── site.manifest │ ├── styles/ │ │ └── globals.css │ ├── tailwind.config.css │ ├── tsconfig.json │ ├── worker.ts │ └── wrangler.jsonc ├── db/ │ ├── AGENTS.md │ ├── README.md │ ├── backups/ │ │ └── .gitignore │ ├── drizzle.config.ts │ ├── index.ts │ ├── migrations/ │ │ ├── 0000_init.sql │ │ └── meta/ │ │ ├── 0000_snapshot.json │ │ └── _journal.json │ ├── package.json │ ├── schema/ │ │ ├── id.ts │ │ ├── index.ts │ │ ├── invitation.ts │ │ ├── organization.ts │ │ ├── passkey.ts │ │ ├── subscription.ts │ │ └── user.ts │ ├── scripts/ │ │ ├── export.ts │ │ ├── generate-auth-schema.ts │ │ └── seed.ts │ ├── seeds/ │ │ └── users.ts │ └── tsconfig.json ├── docs/ │ ├── .vitepress/ │ │ ├── config.ts │ │ ├── public/ │ │ │ └── robots.txt │ │ └── theme/ │ │ ├── components/ │ │ │ ├── GitHubStats.vue │ │ │ └── Mermaid.vue │ │ ├── index.ts │ │ └── style.css │ ├── adr/ │ │ ├── 000-template.md │ │ └── 001-auth-hint-cookie.md │ ├── api/ │ │ ├── context.md │ │ ├── index.md │ │ ├── procedures.md │ │ └── validation-errors.md │ ├── architecture/ │ │ ├── edge.md │ │ └── index.md │ ├── auth/ │ │ ├── email-otp.md │ │ ├── index.md │ │ ├── organizations.md │ │ ├── passkeys.md │ │ ├── sessions.md │ │ └── social-providers.md │ ├── billing/ │ │ ├── checkout.md │ │ ├── index.md │ │ ├── plans.md │ │ └── webhooks.md │ ├── database/ │ │ ├── index.md │ │ ├── migrations.md │ │ ├── queries.md │ │ ├── schema.md │ │ └── seeding.md │ ├── deployment/ │ │ ├── ci-cd.md │ │ ├── cloudflare.md │ │ ├── index.md │ │ ├── monitoring.md │ │ └── production-database.md │ ├── email.md │ ├── frontend/ │ │ ├── forms.md │ │ ├── routing.md │ │ ├── state.md │ │ └── ui.md │ ├── getting-started/ │ │ ├── environment-variables.md │ │ ├── index.md │ │ ├── project-structure.md │ │ └── quick-start.md │ ├── index.md │ ├── public/ │ │ └── CNAME │ ├── recipes/ │ │ ├── file-uploads.md │ │ ├── new-page.md │ │ ├── new-procedure.md │ │ ├── new-table.md │ │ ├── teams.md │ │ └── websockets.md │ ├── security/ │ │ ├── checklist.md │ │ ├── incident-playbook.md │ │ └── policy-template.md │ ├── specs/ │ │ ├── auth-form.md │ │ ├── billing.md │ │ ├── infra-terraform.md │ │ └── prefixed-ids.md │ └── testing.md ├── eslint.config.ts ├── infra/ │ ├── .gitignore │ ├── README.md │ ├── envs/ │ │ ├── dev/ │ │ │ └── edge/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ ├── providers.tf │ │ │ ├── terraform.tfvars.example │ │ │ └── variables.tf │ │ ├── preview/ │ │ │ └── edge/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ ├── providers.tf │ │ │ ├── terraform.tfvars.example │ │ │ └── variables.tf │ │ ├── prod/ │ │ │ └── edge/ │ │ │ ├── .terraform.lock.hcl │ │ │ ├── main.tf │ │ │ ├── providers.tf │ │ │ ├── terraform.tfvars.example │ │ │ └── variables.tf │ │ └── staging/ │ │ └── edge/ │ │ ├── .terraform.lock.hcl │ │ ├── main.tf │ │ ├── providers.tf │ │ ├── terraform.tfvars.example │ │ └── variables.tf │ ├── modules/ │ │ ├── cloudflare/ │ │ │ ├── dns/ │ │ │ │ ├── main.tf │ │ │ │ ├── outputs.tf │ │ │ │ └── variables.tf │ │ │ ├── hyperdrive/ │ │ │ │ ├── main.tf │ │ │ │ ├── outputs.tf │ │ │ │ └── variables.tf │ │ │ ├── r2-bucket/ │ │ │ │ ├── main.tf │ │ │ │ ├── outputs.tf │ │ │ │ └── variables.tf │ │ │ └── worker/ │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ └── gcp/ │ │ ├── cloud-run/ │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ ├── cloud-sql/ │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ └── gcs/ │ │ ├── main.tf │ │ ├── outputs.tf │ │ └── variables.tf │ ├── stacks/ │ │ ├── edge/ │ │ │ ├── main.tf │ │ │ ├── outputs.tf │ │ │ └── variables.tf │ │ └── hybrid/ │ │ ├── main.tf │ │ ├── outputs.tf │ │ └── variables.tf │ └── templates/ │ ├── backend-gcs.example.hcl │ ├── backend-r2.example.hcl │ └── env-roots/ │ └── hybrid/ │ ├── .terraform.lock.hcl │ ├── README.md │ ├── main.tf │ ├── providers.tf │ ├── terraform.tfvars.example │ └── variables.tf ├── package.json ├── packages/ │ ├── core/ │ │ ├── README.md │ │ ├── index.ts │ │ ├── package.json │ │ └── tsconfig.json │ ├── typescript-config/ │ │ ├── README.md │ │ ├── base.jsonc │ │ ├── cloudflare.jsonc │ │ ├── node.jsonc │ │ ├── package.json │ │ └── react.jsonc │ ├── ui/ │ │ ├── README.md │ │ ├── components/ │ │ │ ├── avatar.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── dialog.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── switch.tsx │ │ │ └── textarea.tsx │ │ ├── components.json │ │ ├── hooks/ │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── lib/ │ │ │ └── utils.ts │ │ ├── package.json │ │ ├── scripts/ │ │ │ ├── add.ts │ │ │ ├── essentials.ts │ │ │ ├── format-utils.ts │ │ │ ├── list.ts │ │ │ └── update.ts │ │ ├── styles.css │ │ └── tsconfig.json │ └── ws-protocol/ │ ├── README.md │ ├── example.ts │ ├── index.ts │ ├── messages.ts │ ├── package.json │ ├── router.ts │ └── tsconfig.json ├── scripts/ │ ├── mcp.ts │ ├── package.json │ ├── post-install.ts │ └── tsconfig.json ├── tsconfig.json └── vitest.config.ts
SYMBOL INDEX (215 symbols across 83 files)
FILE: apps/api/dev.ts
type CloudflareEnv (line 27) | type CloudflareEnv = {
FILE: apps/api/lib/ai.ts
type OpenAIContext (line 5) | type OpenAIContext = Pick<TRPCContext, "env" | "cache">;
constant OPENAI_PROVIDER (line 8) | const OPENAI_PROVIDER = Symbol("openaiProvider");
function getOpenAI (line 14) | function getOpenAI(ctx: OpenAIContext): OpenAIProvider {
FILE: apps/api/lib/app.ts
method createContext (line 64) | async createContext({ req, resHeaders, info }) {
method onError (line 101) | onError({ error, path }) {
type AppRouter (line 108) | type AppRouter = typeof appRouter;
FILE: apps/api/lib/auth.ts
constant AUTH_HINT_VALUE (line 19) | const AUTH_HINT_VALUE = "1";
type AuthEnv (line 25) | type AuthEnv = Pick<
function stripePlugin (line 46) | function stripePlugin(db: DB, env: AuthEnv) {
function createAuth (line 121) | function createAuth(
type Auth (line 251) | type Auth = ReturnType<typeof betterAuth>;
type SessionResponse (line 254) | type SessionResponse = Auth["$Infer"]["Session"];
type AuthUser (line 255) | type AuthUser = SessionResponse["user"];
type AuthSession (line 257) | type AuthSession = SessionResponse["session"] & {
FILE: apps/api/lib/context.ts
type TRPCContext (line 30) | type TRPCContext = {
type AppContext (line 74) | type AppContext = {
FILE: apps/api/lib/db.ts
function createDb (line 16) | function createDb(db: Hyperdrive) {
FILE: apps/api/lib/email.ts
type EmailOptions (line 12) | interface EmailOptions {
function createResendClient (line 20) | function createResendClient(apiKey: string): Resend {
function sendEmail (line 33) | async function sendEmail(
function sendVerificationEmail (line 94) | async function sendVerificationEmail(
function sendPasswordReset (line 128) | async function sendPasswordReset(
function sendOTP (line 162) | async function sendOTP(
FILE: apps/api/lib/env.ts
type Env (line 50) | type Env = z.infer<typeof envSchema>;
FILE: apps/api/lib/loaders.ts
function mapByKey (line 19) | function mapByKey<T, K extends keyof T>(
function defineLoader (line 29) | function defineLoader<K, V>(
FILE: apps/api/lib/middleware.ts
function requestIdGenerator (line 42) | function requestIdGenerator(c: Context): string {
FILE: apps/api/lib/plans.ts
type PlanName (line 10) | type PlanName = keyof typeof planLimits;
FILE: apps/api/lib/stripe.ts
function createStripeClient (line 5) | function createStripeClient(env: Pick<Env, "STRIPE_SECRET_KEY">) {
FILE: apps/api/lib/trpc.ts
method errorFormatter (line 6) | errorFormatter({ shape, error }) {
type ProtectedProcedure (line 24) | type ProtectedProcedure =
FILE: apps/api/routers/billing.test.ts
function testCtx (line 9) | function testCtx({
FILE: apps/api/worker.ts
type CloudflareEnv (line 22) | type CloudflareEnv = {
FILE: apps/app/components/auth/auth-error-boundary.tsx
type ResetProps (line 11) | interface ResetProps {
function AuthErrorFallback (line 16) | function AuthErrorFallback({ resetErrorBoundary }: ResetProps) {
type ErrorFallbackProps (line 50) | interface ErrorFallbackProps {
function GenericErrorFallback (line 56) | function GenericErrorFallback({
type ErrorBoundaryProps (line 72) | interface ErrorBoundaryProps {
function AuthAwareErrorFallback (line 77) | function AuthAwareErrorFallback({
function AuthErrorBoundary (line 94) | function AuthErrorBoundary({ children }: ErrorBoundaryProps) {
function AppErrorBoundary (line 115) | function AppErrorBoundary({ children }: ErrorBoundaryProps) {
FILE: apps/app/components/auth/auth-form.tsx
constant APP_NAME (line 10) | const APP_NAME = import.meta.env.VITE_APP_NAME || "your account";
function SignupTerms (line 12) | function SignupTerms() {
type AuthFormProps (line 34) | interface AuthFormProps extends ComponentProps<"div"> {
function AuthForm (line 47) | function AuthForm({
type MethodSelectionProps (line 150) | interface MethodSelectionProps {
function MethodSelection (line 160) | function MethodSelection({
type EmailInputProps (line 236) | interface EmailInputProps {
function EmailInput (line 245) | function EmailInput({
type OtpStepProps (line 297) | interface OtpStepProps {
function OtpStep (line 307) | function OtpStep({
FILE: apps/app/components/auth/google-login.tsx
type GoogleLoginProps (line 7) | interface GoogleLoginProps {
function GoogleLogin (line 15) | function GoogleLogin({
FILE: apps/app/components/auth/login-dialog.tsx
type LoginDialogProps (line 15) | interface LoginDialogProps {
function LoginDialog (line 24) | function LoginDialog({ open, onOpenChange }: LoginDialogProps) {
function useLoginDialog (line 73) | function useLoginDialog() {
FILE: apps/app/components/auth/otp-verification.tsx
constant RESEND_COOLDOWN_SECONDS (line 6) | const RESEND_COOLDOWN_SECONDS = 30;
constant OTP_ERROR_CODES (line 9) | const OTP_ERROR_CODES = {
type OtpVerificationProps (line 15) | interface OtpVerificationProps {
function OtpVerification (line 24) | function OtpVerification({
FILE: apps/app/components/auth/passkey-login.tsx
type PasskeyLoginProps (line 7) | interface PasskeyLoginProps {
function PasskeyLogin (line 20) | function PasskeyLogin({
FILE: apps/app/components/auth/use-auth-form.ts
type AuthStep (line 5) | type AuthStep = "method" | "email" | "otp";
constant VALID_TRANSITIONS (line 11) | const VALID_TRANSITIONS: Record<AuthStep, AuthStep[]> = {
type UseAuthFormOptions (line 17) | interface UseAuthFormOptions {
function useAuthForm (line 31) | function useAuthForm({
FILE: apps/app/components/layout/header.tsx
type HeaderProps (line 4) | interface HeaderProps {
function Header (line 9) | function Header({ isSidebarOpen, onMenuToggle }: HeaderProps) {
FILE: apps/app/components/layout/index.tsx
type LayoutProps (line 5) | interface LayoutProps {
function Layout (line 9) | function Layout({ children }: LayoutProps) {
FILE: apps/app/components/layout/sidebar-nav.tsx
type SidebarNavItem (line 5) | interface SidebarNavItem {
type SidebarNavProps (line 11) | interface SidebarNavProps {
function SidebarNav (line 15) | function SidebarNav({ items }: SidebarNavProps) {
FILE: apps/app/components/layout/sidebar.tsx
type SidebarProps (line 5) | interface SidebarProps {
function Sidebar (line 9) | function Sidebar({ isOpen }: SidebarProps) {
FILE: apps/app/components/not-found.tsx
function NotFound (line 4) | function NotFound() {
FILE: apps/app/components/user-menu.tsx
function UserMenu (line 7) | function UserMenu() {
FILE: apps/app/global.d.ts
type Window (line 4) | interface Window {
type ImportMetaEnv (line 8) | interface ImportMetaEnv {
type PayloadError (line 16) | interface PayloadError {
FILE: apps/app/index.tsx
type Register (line 40) | interface Register {
FILE: apps/app/lib/auth-config.ts
function isValidRedirectUrl (line 48) | function isValidRedirectUrl(url: string): boolean {
function getSafeRedirectUrl (line 53) | function getSafeRedirectUrl(url: unknown): string {
function shouldRefreshSession (line 63) | function shouldRefreshSession(
FILE: apps/app/lib/auth.ts
type AuthClient (line 34) | type AuthClient = typeof auth;
type SessionResponse (line 38) | type SessionResponse = typeof auth.$Infer.Session;
type User (line 39) | type User = SessionResponse["user"];
type Session (line 40) | type Session = SessionResponse["session"];
FILE: apps/app/lib/errors.ts
function getErrorStatus (line 2) | function getErrorStatus(
function isUnauthenticatedError (line 28) | function isUnauthenticatedError(error: unknown): boolean {
function getErrorMessage (line 38) | function getErrorMessage(error: unknown): string {
FILE: apps/app/lib/queries/billing.ts
function billingQueryOptions (line 14) | function billingQueryOptions(activeOrgId?: string | null) {
function useBillingQuery (line 21) | function useBillingQuery(activeOrgId?: string | null) {
function useSuspenseBillingQuery (line 25) | function useSuspenseBillingQuery(activeOrgId?: string | null) {
FILE: apps/app/lib/queries/session.test.ts
function createQueryClient (line 5) | function createQueryClient() {
FILE: apps/app/lib/queries/session.ts
type SessionData (line 18) | interface SessionData {
function sessionQueryOptions (line 28) | function sessionQueryOptions() {
function useSessionQuery (line 49) | function useSessionQuery() {
function useSuspenseSessionQuery (line 53) | function useSuspenseSessionQuery() {
function getCachedSession (line 57) | function getCachedSession(
function isAuthenticated (line 65) | function isAuthenticated(queryClient: QueryClient): boolean {
function signOut (line 75) | async function signOut(
function revalidateSession (line 94) | async function revalidateSession(
FILE: apps/app/lib/routeTree.gen.ts
type FileRoutesByFullPath (line 73) | interface FileRoutesByFullPath {
type FileRoutesByTo (line 84) | interface FileRoutesByTo {
type FileRoutesById (line 95) | interface FileRoutesById {
type FileRouteTypes (line 108) | interface FileRouteTypes {
type RootRouteChildren (line 145) | interface RootRouteChildren {
type FileRoutesByPath (line 152) | interface FileRoutesByPath {
type appRouteRouteChildren (line 226) | interface appRouteRouteChildren {
FILE: apps/app/lib/store.ts
function StoreProvider (line 11) | function StoreProvider(props: StoreProviderProps) {
type StoreProviderProps (line 15) | type StoreProviderProps = {
FILE: apps/app/lib/trpc.ts
method headers (line 30) | headers() {
method fetch (line 36) | fetch(url, options) {
FILE: apps/app/routes/(app)/about.tsx
function About (line 16) | function About() {
FILE: apps/app/routes/(app)/analytics.tsx
function Analytics (line 15) | function Analytics() {
FILE: apps/app/routes/(app)/index.tsx
function Dashboard (line 15) | function Dashboard() {
FILE: apps/app/routes/(app)/reports.tsx
function Reports (line 21) | function Reports() {
FILE: apps/app/routes/(app)/route.tsx
function AppLayout (line 29) | function AppLayout() {
FILE: apps/app/routes/(app)/settings.tsx
function Settings (line 23) | function Settings() {
function BillingCard (line 146) | function BillingCard() {
FILE: apps/app/routes/(app)/users.tsx
function Users (line 24) | function Users() {
FILE: apps/app/routes/(auth)/login.tsx
function LoginPage (line 45) | function LoginPage() {
FILE: apps/app/routes/(auth)/signup.tsx
function SignupPage (line 45) | function SignupPage() {
FILE: apps/app/routes/__root.tsx
function Root (line 14) | function Root() {
FILE: apps/app/vite.config.ts
method configure (line 81) | configure(proxy) {
FILE: apps/email/components/BaseTemplate.tsx
type BaseTemplateProps (line 15) | interface BaseTemplateProps {
function BaseTemplate (line 36) | function BaseTemplate({
FILE: apps/email/emails/email-verification.tsx
function EmailVerificationPreview (line 3) | function EmailVerificationPreview() {
FILE: apps/email/emails/otp-password-reset.tsx
function OTPPasswordResetPreview (line 3) | function OTPPasswordResetPreview() {
FILE: apps/email/emails/otp-sign-in.tsx
function OTPSignInPreview (line 3) | function OTPSignInPreview() {
FILE: apps/email/emails/otp-verification.tsx
function OTPVerificationPreview (line 3) | function OTPVerificationPreview() {
FILE: apps/email/emails/password-reset.tsx
function PasswordResetPreview (line 3) | function PasswordResetPreview() {
FILE: apps/email/templates/email-verification.tsx
type EmailVerificationProps (line 4) | interface EmailVerificationProps {
function EmailVerification (line 11) | function EmailVerification({
FILE: apps/email/templates/otp-email.tsx
type OTPEmailProps (line 4) | interface OTPEmailProps {
function OTPEmail (line 12) | function OTPEmail({
FILE: apps/email/templates/password-reset.tsx
type PasswordResetProps (line 4) | interface PasswordResetProps {
function PasswordReset (line 11) | function PasswordReset({
FILE: apps/email/utils/render.ts
function renderEmailToHtml (line 9) | async function renderEmailToHtml(
function renderEmailToText (line 20) | async function renderEmailToText(
FILE: apps/web/worker.ts
type Env (line 14) | interface Env {
FILE: db/index.ts
type DatabaseSchema (line 11) | type DatabaseSchema = typeof schema;
FILE: db/migrations/0000_init.sql
type "invitation" (line 1) | CREATE TABLE "invitation" (
type "passkey" (line 16) | CREATE TABLE "passkey" (
type "member" (line 35) | CREATE TABLE "member" (
type "organization" (line 45) | CREATE TABLE "organization" (
type "identity" (line 57) | CREATE TABLE "identity" (
type "session" (line 74) | CREATE TABLE "session" (
type "subscription" (line 87) | CREATE TABLE "subscription" (
type "user" (line 110) | CREATE TABLE "user" (
type "verification" (line 123) | CREATE TABLE "verification" (
type "invitation" (line 140) | CREATE INDEX "invitation_email_idx" ON "invitation" USING btree ("email")
type "invitation" (line 141) | CREATE INDEX "invitation_inviter_id_idx" ON "invitation" USING btree ("i...
type "invitation" (line 142) | CREATE INDEX "invitation_organization_id_idx" ON "invitation" USING btre...
type "passkey" (line 143) | CREATE INDEX "passkey_user_id_idx" ON "passkey" USING btree ("user_id")
type "member" (line 144) | CREATE INDEX "member_user_id_idx" ON "member" USING btree ("user_id")
type "member" (line 145) | CREATE INDEX "member_organization_id_idx" ON "member" USING btree ("orga...
type "identity" (line 146) | CREATE INDEX "identity_user_id_idx" ON "identity" USING btree ("user_id")
type "session" (line 147) | CREATE INDEX "session_user_id_idx" ON "session" USING btree ("user_id")
type "session" (line 148) | CREATE INDEX "session_active_org_id_idx" ON "session" USING btree ("acti...
type "subscription" (line 149) | CREATE INDEX "subscription_reference_id_idx" ON "subscription" USING btr...
type "subscription" (line 150) | CREATE INDEX "subscription_stripe_customer_id_idx" ON "subscription" USI...
type "verification" (line 151) | CREATE INDEX "verification_identifier_idx" ON "verification" USING btree...
type "verification" (line 152) | CREATE INDEX "verification_value_idx" ON "verification" USING btree ("va...
type "verification" (line 153) | CREATE INDEX "verification_expires_at_idx" ON "verification" USING btree...
FILE: db/schema/id.ts
constant AUTH_PREFIX (line 9) | const AUTH_PREFIX = {
type AuthModel (line 21) | type AuthModel = keyof typeof AUTH_PREFIX;
constant ID_LENGTH (line 23) | const ID_LENGTH = 16;
function createId (line 26) | function createId(): string {
function generateAuthId (line 32) | function generateAuthId(model: AuthModel): string {
function generateId (line 43) | function generateId(prefix: string): string {
FILE: db/schema/invitation.ts
type Invitation (line 51) | type Invitation = typeof invitation.$inferSelect;
type NewInvitation (line 52) | type NewInvitation = typeof invitation.$inferInsert;
FILE: db/schema/organization.ts
type Organization (line 30) | type Organization = typeof organization.$inferSelect;
type NewOrganization (line 31) | type NewOrganization = typeof organization.$inferInsert;
type Member (line 72) | type Member = typeof member.$inferSelect;
type NewMember (line 73) | type NewMember = typeof member.$inferInsert;
FILE: db/schema/passkey.ts
type Passkey (line 55) | type Passkey = typeof passkey.$inferSelect;
type NewPasskey (line 56) | type NewPasskey = typeof passkey.$inferInsert;
FILE: db/schema/subscription.ts
type Subscription (line 51) | type Subscription = typeof subscription.$inferSelect;
type NewSubscription (line 52) | type NewSubscription = typeof subscription.$inferInsert;
FILE: db/schema/user.ts
type User (line 51) | type User = typeof user.$inferSelect;
type NewUser (line 52) | type NewUser = typeof user.$inferInsert;
type Session (line 86) | type Session = typeof session.$inferSelect;
type NewSession (line 87) | type NewSession = typeof session.$inferInsert;
type Identity (line 128) | type Identity = typeof identity.$inferSelect;
type NewIdentity (line 129) | type NewIdentity = typeof identity.$inferInsert;
type Verification (line 163) | type Verification = typeof verification.$inferSelect;
type NewVerification (line 164) | type NewVerification = typeof verification.$inferInsert;
FILE: db/scripts/generate-auth-schema.ts
function generateAuthSchema (line 10) | async function generateAuthSchema() {
function main (line 70) | async function main() {
FILE: db/seeds/users.ts
function seedUsers (line 8) | async function seedUsers(db: PostgresJsDatabase<typeof schema>) {
FILE: docs/.vitepress/config.ts
method config (line 13) | config(md) {
FILE: docs/.vitepress/theme/index.ts
method enhanceApp (line 21) | enhanceApp({ app }) {
FILE: packages/ui/components/button.tsx
type ButtonProps (line 37) | interface ButtonProps
FILE: packages/ui/components/skeleton.tsx
function Skeleton (line 3) | function Skeleton({
FILE: packages/ui/lib/utils.ts
function cn (line 4) | function cn(...inputs: ClassValue[]) {
FILE: packages/ui/scripts/add.ts
function addComponent (line 4) | async function addComponent(): Promise<void> {
FILE: packages/ui/scripts/essentials.ts
constant ESSENTIAL_COMPONENTS (line 5) | const ESSENTIAL_COMPONENTS = [
function installEssentials (line 38) | async function installEssentials(): Promise<void> {
FILE: packages/ui/scripts/format-utils.ts
function execCommand (line 8) | async function execCommand(
function formatGeneratedFiles (line 25) | async function formatGeneratedFiles(): Promise<void> {
FILE: packages/ui/scripts/list.ts
type ComponentInfo (line 6) | interface ComponentInfo {
function getComponentInfo (line 12) | async function getComponentInfo(filePath: string): Promise<ComponentInfo> {
function listComponents (line 23) | async function listComponents(): Promise<void> {
FILE: packages/ui/scripts/update.ts
function getInstalledComponents (line 7) | async function getInstalledComponents(): Promise<string[]> {
function updateComponents (line 20) | async function updateComponents(): Promise<void> {
function updateSpecificComponent (line 62) | async function updateSpecificComponent(component: string): Promise<void> {
function main (line 80) | async function main(): Promise<void> {
FILE: packages/ws-protocol/example.ts
method authenticate (line 19) | authenticate() {
method fetch (line 27) | fetch(req, server) {
FILE: packages/ws-protocol/router.ts
type AppData (line 34) | interface AppData extends Record<string, unknown> {
function createAppRouter (line 58) | function createAppRouter(): Router<AppData> {
Condensed preview — 331 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (809K chars).
[
{
"path": ".claude/commands/migrate-to-d1.md",
"chars": 3210,
"preview": "Migrate the project from Neon to Cloudflare D1 database following these steps:\n\n- [ ] Replace all mentions of \"Cloudflar"
},
{
"path": ".claude/commands/review-better-auth.md",
"chars": 1110,
"preview": "Verify Better Auth integration to ensure that it is properly configured, up-to-date and functioning as expected. This in"
},
{
"path": ".claude/commands/review-terraform.md",
"chars": 3829,
"preview": "# Terraform Infrastructure Review Checklist\n\n## Structure & Organization\n\n### Module Structure\n\n- [ ] Each module has se"
},
{
"path": ".claude/commands/validate-auth-schema.md",
"chars": 1677,
"preview": "# Validate Auth Schema\n\nValidate that the Drizzle ORM schema in `db/schema/` matches the Better Auth requirements.\n\n## S"
},
{
"path": ".editorconfig",
"chars": 325,
"preview": "# For more information about the properties used in\n# this file, please see the EditorConfig documentation:\n# https://ed"
},
{
"path": ".gemini/settings.json",
"chars": 185,
"preview": "{\n \"general\": {\n \"preferredEditor\": \"vscode\"\n },\n \"context\": {\n \"fileName\": [\"AGENTS.md\", \"AGENTS.local.md\"],\n "
},
{
"path": ".gitattributes",
"chars": 619,
"preview": "# Automatically normalize line endings for all text-based files\n# https://git-scm.com/docs/gitattributes#_end_of_line_co"
},
{
"path": ".github/CODE_OF_CONDUCT.md",
"chars": 5002,
"preview": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participa"
},
{
"path": ".github/CONTRIBUTING.md",
"chars": 4079,
"preview": "# Contributing to React Starter Kit\n\nThank you for your interest in contributing! Whether you're fixing bugs, improving "
},
{
"path": ".github/FUNDING.yml",
"chars": 166,
"preview": "# GitHub Sponsors configuration for React Starter Kit\n# Enables sponsorship options on the repository's main page\n\nopen_"
},
{
"path": ".github/SECURITY.md",
"chars": 5779,
"preview": "# Security Policy & Incident Response Plan\n\n## Our Security Commitment\n\nThe React Starter Kit team takes security seriou"
},
{
"path": ".github/copilot-instructions.md",
"chars": 157,
"preview": "Read AGENTS.md in the repository root and any subdirectory AGENTS.md files for project context, conventions, and archite"
},
{
"path": ".github/dependabot.yml",
"chars": 768,
"preview": "# Dependabot configuration for automated dependency updates\n# Creates weekly PRs with all dependency updates grouped tog"
},
{
"path": ".github/workflows/ci.yml",
"chars": 2570,
"preview": "name: CI\n\non:\n push:\n branches: [main]\n pull_request:\n branches: [main]\n workflow_dispatch:\n inputs:\n e"
},
{
"path": ".github/workflows/conventional-commits.yml",
"chars": 349,
"preview": "name: Conventional Commits\n\non:\n pull_request_target:\n types:\n - opened\n - edited\n - synchronize\n\nper"
},
{
"path": ".github/workflows/deploy.yml",
"chars": 1298,
"preview": "name: Deploy\n\non:\n workflow_call:\n inputs:\n name:\n description: \"Name of the deployment\"\n require"
},
{
"path": ".gitignore",
"chars": 1214,
"preview": "# Include your project-specific ignores in this file\n# Read about how to use .gitignore: https://help.github.com/article"
},
{
"path": ".husky/.gitignore",
"chars": 2,
"preview": "_\n"
},
{
"path": ".husky/pre-commit",
"chars": 43,
"preview": "#!/usr/bin/env sh\nset -e\n\nbunx lint-staged\n"
},
{
"path": ".prettierignore",
"chars": 234,
"preview": "# Compiled & generated output\n/app/queries/\n/*/dist/\n/**/*.generated.ts\n/**/*.gen.ts\n\n# Cache\n/.cache\n\n# Bun and Node.js"
},
{
"path": ".vscode/extensions.json",
"chars": 486,
"preview": "{\n \"recommendations\": [\n \"anthropic.claude-code\",\n \"astro-build.vscode-astro\",\n \"bradlc.vscode-tailwindcss\",\n "
},
{
"path": ".vscode/mcp.json",
"chars": 117,
"preview": "{\n \"servers\": {\n \"github\": {\n \"type\": \"http\",\n \"url\": \"https://api.githubcopilot.com/mcp/\"\n }\n }\n}\n"
},
{
"path": ".vscode/settings.json",
"chars": 2553,
"preview": "{\n \"editor.codeActionsOnSave\": {\n \"source.organizeImports\": \"explicit\"\n },\n \"editor.defaultFormatter\": \"esbenp.pre"
},
{
"path": "AGENTS.md",
"chars": 2727,
"preview": "## Monorepo Structure\n\n- `apps/web/` — Edge worker; routes traffic to app/api workers via service bindings\n- `apps/app/`"
},
{
"path": "CLAUDE.md",
"chars": 161,
"preview": "@AGENTS.md\n\n## Claude-Specific Guidance\n\n- Use `/plan` for multi-file or architectural changes.\n- Prefer slash commands "
},
{
"path": "LICENSE",
"chars": 1100,
"preview": "Copyright (c) 2014-present Konstantin Tarkus, Kriasoft (hello@kriasoft.com)\n\nPermission is hereby granted, free of charg"
},
{
"path": "README.md",
"chars": 13044,
"preview": "<div align=\"center\">\n\n# React Starter Kit\n\n<a href=\"https://reactstarter.com/getting-started/\"><img src=\"https://img.shi"
},
{
"path": "apps/api/AGENTS.md",
"chars": 2019,
"preview": "## Auth\n\n- Server config in `lib/auth.ts`. Better Auth `account` table renamed to `identity` via `account.modelName: \"id"
},
{
"path": "apps/api/Dockerfile",
"chars": 2001,
"preview": "# Dockerfile for the tRPC API Server\n# docker build --tag api:latest -f ./apps/api/Dockerfile .\n# https://bun.com/guides"
},
{
"path": "apps/api/README.md",
"chars": 666,
"preview": "# API Server\n\nHono + tRPC API server with Better Auth, Drizzle ORM, and Stripe billing. Runs on Cloudflare Workers.\n\n[Do"
},
{
"path": "apps/api/dev.ts",
"chars": 2559,
"preview": "/**\n * @file Local development server emulating Cloudflare Workers runtime.\n *\n * Requires wrangler.jsonc with HYPERDRIV"
},
{
"path": "apps/api/index.ts",
"chars": 668,
"preview": "/**\n * @file Public API surface for the backend package.\n *\n * Re-exports the Hono app, tRPC router, and core utilities."
},
{
"path": "apps/api/lib/ai.ts",
"chars": 759,
"preview": "import type { OpenAIProvider } from \"@ai-sdk/openai\";\nimport { createOpenAI } from \"@ai-sdk/openai\";\nimport type { TRPCC"
},
{
"path": "apps/api/lib/app.ts",
"chars": 2651,
"preview": "/**\n * @file Hono app construction and tRPC router initialization.\n *\n * Combines authentication, tRPC, and health check"
},
{
"path": "apps/api/lib/auth.ts",
"chars": 8396,
"preview": "import { passkey } from \"@better-auth/passkey\";\nimport { stripe } from \"@better-auth/stripe\";\nimport { schema as Db, gen"
},
{
"path": "apps/api/lib/context.ts",
"chars": 2538,
"preview": "import type { DatabaseSchema } from \"@repo/db\";\nimport type { CreateHTTPContextOptions } from \"@trpc/server/adapters/sta"
},
{
"path": "apps/api/lib/db.ts",
"chars": 874,
"preview": "/**\n * @file Database client using Neon PostgreSQL via Cloudflare Hyperdrive.\n *\n * Two bindings available: HYPERDRIVE_C"
},
{
"path": "apps/api/lib/email.ts",
"chars": 4801,
"preview": "import {\n EmailVerification,\n OTPEmail,\n PasswordReset,\n renderEmailToHtml,\n renderEmailToText,\n} from \"@repo/email"
},
{
"path": "apps/api/lib/env.ts",
"chars": 1751,
"preview": "import { z } from \"zod\";\n\n/**\n * Zod schema for validating environment variables.\n * Ensures all required configuration "
},
{
"path": "apps/api/lib/loaders.ts",
"chars": 1758,
"preview": "/**\n * Request-scoped DataLoaders for batching and N+1 prevention.\n *\n * @example\n * ```ts\n * protectedProcedure\n * .q"
},
{
"path": "apps/api/lib/middleware.ts",
"chars": 1373,
"preview": "/**\n * @file Shared Hono middleware for both production and development entrypoints.\n */\n\nimport type { Context, ErrorHa"
},
{
"path": "apps/api/lib/plans.ts",
"chars": 298,
"preview": "// Single source of truth for plan limits.\n// Referenced by auth plugin config (plan definitions) and tRPC router (query"
},
{
"path": "apps/api/lib/stripe.ts",
"chars": 320,
"preview": "import Stripe from \"stripe\";\nimport type { Env } from \"./env\";\n\n// Only called when STRIPE_SECRET_KEY is verified presen"
},
{
"path": "apps/api/lib/trpc.ts",
"chars": 1734,
"preview": "import { initTRPC, TRPCError, type TRPCProcedureBuilder } from \"@trpc/server\";\nimport { flattenError, ZodError } from \"z"
},
{
"path": "apps/api/package.json",
"chars": 1432,
"preview": "{\n \"name\": \"@repo/api\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"exports\": {\n \".\": \"./index.t"
},
{
"path": "apps/api/routers/billing.test.ts",
"chars": 3216,
"preview": "import { describe, expect, it, vi } from \"vitest\";\nimport type { TRPCContext } from \"../lib/context\";\nimport { createCal"
},
{
"path": "apps/api/routers/billing.ts",
"chars": 1084,
"preview": "import { planLimits, type PlanName } from \"../lib/plans.js\";\nimport { protectedProcedure, router } from \"../lib/trpc.js\""
},
{
"path": "apps/api/routers/organization.ts",
"chars": 1818,
"preview": "import { z } from \"zod\";\nimport { protectedProcedure, router } from \"../lib/trpc.js\";\n\nexport const organizationRouter ="
},
{
"path": "apps/api/routers/user.ts",
"chars": 1015,
"preview": "import { z } from \"zod\";\nimport { protectedProcedure, router } from \"../lib/trpc.js\";\n\nexport const userRouter = router("
},
{
"path": "apps/api/tsconfig.json",
"chars": 508,
"preview": "{\n \"extends\": \"../../packages/typescript-config/node.jsonc\",\n \"compilerOptions\": {\n \"composite\": true,\n \"declara"
},
{
"path": "apps/api/vitest.config.ts",
"chars": 82,
"preview": "import { defineProject } from \"vitest/config\";\n\nexport default defineProject({});\n"
},
{
"path": "apps/api/worker.ts",
"chars": 1432,
"preview": "/**\n * @file Cloudflare Workers entrypoint.\n *\n * Initializes database and auth context, then mounts the core Hono app.\n"
},
{
"path": "apps/api/wrangler.jsonc",
"chars": 2695,
"preview": "{\n \"$schema\": \"../../node_modules/wrangler/config-schema.json\",\n\n // [METADATA]\n // API worker accessed via service b"
},
{
"path": "apps/app/AGENTS.md",
"chars": 1937,
"preview": "Client-side SPA — no SSR. All rendering happens in the browser.\n\n## Routing\n\n- File-based routing in `routes/`. `lib/rou"
},
{
"path": "apps/app/README.md",
"chars": 762,
"preview": "# React Application\n\nSingle-page application built with React 19, TanStack Router, Jotai, shadcn/ui, and Tailwind CSS v4"
},
{
"path": "apps/app/components/auth/auth-error-boundary.tsx",
"chars": 3917,
"preview": "import { getErrorMessage, isUnauthenticatedError } from \"@/lib/errors\";\nimport { sessionQueryKey } from \"@/lib/queries/s"
},
{
"path": "apps/app/components/auth/auth-form.tsx",
"chars": 8672,
"preview": "import { Button, Input, cn } from \"@repo/ui\";\nimport { Link } from \"@tanstack/react-router\";\nimport { ArrowLeft, Mail } "
},
{
"path": "apps/app/components/auth/google-login.tsx",
"chars": 2655,
"preview": "import { auth } from \"@/lib/auth\";\nimport { sessionQueryKey } from \"@/lib/queries/session\";\nimport { Button } from \"@rep"
},
{
"path": "apps/app/components/auth/index.ts",
"chars": 389,
"preview": "export { AppErrorBoundary, AuthErrorBoundary } from \"./auth-error-boundary\";\nexport { AuthForm } from \"./auth-form\";\nexp"
},
{
"path": "apps/app/components/auth/login-dialog.tsx",
"chars": 2186,
"preview": "import { getSafeRedirectUrl } from \"@/lib/auth-config\";\nimport { revalidateSession } from \"@/lib/queries/session\";\nimpor"
},
{
"path": "apps/app/components/auth/otp-verification.tsx",
"chars": 4082,
"preview": "import { auth } from \"@/lib/auth\";\nimport { Button, Input } from \"@repo/ui\";\nimport type { FormEvent } from \"react\";\nimp"
},
{
"path": "apps/app/components/auth/passkey-login.tsx",
"chars": 3366,
"preview": "import { auth } from \"@/lib/auth\";\nimport { authConfig } from \"@/lib/auth-config\";\nimport { Button } from \"@repo/ui\";\nim"
},
{
"path": "apps/app/components/auth/use-auth-form.ts",
"chars": 4585,
"preview": "import { auth } from \"@/lib/auth\";\nimport type { FormEvent } from \"react\";\nimport { useCallback, useRef, useState } from"
},
{
"path": "apps/app/components/index.ts",
"chars": 40,
"preview": "export { NotFound } from \"./not-found\";\n"
},
{
"path": "apps/app/components/layout/constants.ts",
"chars": 387,
"preview": "import { Activity, FileText, Home, Settings, Users } from \"lucide-react\";\n\nexport const sidebarItems = [\n { icon: Home,"
},
{
"path": "apps/app/components/layout/header.tsx",
"chars": 921,
"preview": "import { Button } from \"@repo/ui\";\nimport { Menu, Settings, X } from \"lucide-react\";\n\ninterface HeaderProps {\n isSideba"
},
{
"path": "apps/app/components/layout/index.tsx",
"chars": 706,
"preview": "import { useState } from \"react\";\nimport { Header } from \"./header\";\nimport { Sidebar } from \"./sidebar\";\n\ninterface Lay"
},
{
"path": "apps/app/components/layout/sidebar-nav.tsx",
"chars": 899,
"preview": "import type { FileRoutesByTo } from \"@/lib/routeTree.gen\";\nimport { Link } from \"@tanstack/react-router\";\nimport type { "
},
{
"path": "apps/app/components/layout/sidebar.tsx",
"chars": 689,
"preview": "import { UserMenu } from \"@/components/user-menu\";\nimport { sidebarItems } from \"./constants\";\nimport { SidebarNav } fro"
},
{
"path": "apps/app/components/not-found.tsx",
"chars": 540,
"preview": "import { Button } from \"@repo/ui\";\nimport { Link } from \"@tanstack/react-router\";\n\nexport function NotFound() {\n return"
},
{
"path": "apps/app/components/user-menu.tsx",
"chars": 1921,
"preview": "import { signOut, useSessionQuery } from \"@/lib/queries/session\";\nimport { Avatar, AvatarFallback, Button } from \"@repo/"
},
{
"path": "apps/app/components.json",
"chars": 406,
"preview": "{\n \"$schema\": \"https://ui.shadcn.com/schema.json\",\n \"style\": \"new-york\",\n \"rsc\": false,\n \"tsx\": true,\n \"tailwind\": "
},
{
"path": "apps/app/global.d.ts",
"chars": 540,
"preview": "import * as React from \"react\";\nimport \"vite/client\";\n\ninterface Window {\n dataLayer: unknown[];\n}\n\ninterface ImportMet"
},
{
"path": "apps/app/index.html",
"chars": 1142,
"preview": "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <title>%VITE_APP_NAME%</title>\n <meta\n "
},
{
"path": "apps/app/index.tsx",
"chars": 1179,
"preview": "import { QueryClientProvider } from \"@tanstack/react-query\";\nimport { ReactQueryDevtools } from \"@tanstack/react-query-d"
},
{
"path": "apps/app/lib/auth-config.ts",
"chars": 2144,
"preview": "// All durations in milliseconds. Providers must match server-side config.\n// Changing api.basePath requires updating se"
},
{
"path": "apps/app/lib/auth.ts",
"chars": 1188,
"preview": "/**\n * @file Better Auth client instance.\n *\n * Do not use auth.useSession() directly - use TanStack Query wrappers\n * f"
},
{
"path": "apps/app/lib/errors.test.ts",
"chars": 3665,
"preview": "import { describe, expect, it } from \"vitest\";\nimport {\n getErrorMessage,\n getErrorStatus,\n isUnauthenticatedError,\n}"
},
{
"path": "apps/app/lib/errors.ts",
"chars": 1768,
"preview": "// Extract HTTP status from various error shapes (with cycle guard)\nexport function getErrorStatus(\n error: unknown,\n "
},
{
"path": "apps/app/lib/queries/README.md",
"chars": 5156,
"preview": "# TanStack Queries\n\nThis folder contains TanStack Query implementations for managing server state and data fetching in t"
},
{
"path": "apps/app/lib/queries/billing.test.ts",
"chars": 1337,
"preview": "import { describe, expect, it } from \"vitest\";\nimport { billingQueryKey, billingQueryOptions } from \"./billing\";\n\ndescri"
},
{
"path": "apps/app/lib/queries/billing.ts",
"chars": 884,
"preview": "// Billing subscription state via TanStack Query.\n// Query key includes activeOrgId so switching organizations refetches"
},
{
"path": "apps/app/lib/queries/session.test.ts",
"chars": 2097,
"preview": "import { QueryClient } from \"@tanstack/react-query\";\nimport { describe, expect, it } from \"vitest\";\nimport { getCachedSe"
},
{
"path": "apps/app/lib/queries/session.ts",
"chars": 3165,
"preview": "/**\n * @file Session state managed exclusively via TanStack Query.\n *\n * Do not use direct auth.getSession() calls or lo"
},
{
"path": "apps/app/lib/query.ts",
"chars": 1441,
"preview": "import { QueryClient } from \"@tanstack/react-query\";\n\nexport const queryClient = new QueryClient({\n defaultOptions: {\n "
},
{
"path": "apps/app/lib/routeTree.gen.ts",
"chars": 7630,
"preview": "/* eslint-disable */\n\n// @ts-nocheck\n\n// noinspection JSUnusedGlobalSymbols\n\n// This file was automatically generated by"
},
{
"path": "apps/app/lib/store.ts",
"chars": 422,
"preview": "import { createStore, Provider } from \"jotai\";\nimport type { ReactNode } from \"react\";\nimport { createElement } from \"re"
},
{
"path": "apps/app/lib/trpc.ts",
"chars": 1233,
"preview": "import type { AppRouter } from \"@repo/api\";\nimport {\n createTRPCClient,\n httpBatchLink,\n type TRPCLink,\n loggerLink,"
},
{
"path": "apps/app/lib/utils.ts",
"chars": 31,
"preview": "export { cn } from \"@repo/ui\";\n"
},
{
"path": "apps/app/package.json",
"chars": 2329,
"preview": "{\n \"name\": \"@repo/app\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"dev\": \"vite se"
},
{
"path": "apps/app/postcss.config.js",
"chars": 92,
"preview": "export default {\n plugins: {\n \"@tailwindcss/postcss\": {},\n autoprefixer: {},\n },\n};\n"
},
{
"path": "apps/app/public/robots.txt",
"chars": 78,
"preview": "# www.robotstxt.org/\n\n# Allow crawling of all content\nUser-agent: *\nDisallow:\n"
},
{
"path": "apps/app/public/site.manifest",
"chars": 507,
"preview": "{\n \"short_name\": \"React App\",\n \"name\": \"React App Sample\",\n \"icons\": [\n {\n \"src\": \"favicon.ico\",\n \"sizes"
},
{
"path": "apps/app/routes/(app)/about.tsx",
"chars": 9451,
"preview": "import {\n Button,\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n Separator,\n} from \"@repo/ui\";\n"
},
{
"path": "apps/app/routes/(app)/analytics.tsx",
"chars": 4828,
"preview": "import {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from \"@repo/ui\";\nimport { createFileRout"
},
{
"path": "apps/app/routes/(app)/dashboard.tsx",
"chars": 256,
"preview": "import { createFileRoute, redirect } from \"@tanstack/react-router\";\n\nexport const Route = createFileRoute(\"/(app)/dashbo"
},
{
"path": "apps/app/routes/(app)/index.tsx",
"chars": 4358,
"preview": "import {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from \"@repo/ui\";\nimport { createFileRout"
},
{
"path": "apps/app/routes/(app)/reports.tsx",
"chars": 5964,
"preview": "import {\n Button,\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n Select,\n SelectContent,\n Sel"
},
{
"path": "apps/app/routes/(app)/route.tsx",
"chars": 1091,
"preview": "import { AuthErrorBoundary } from \"@/components/auth\";\nimport { Layout } from \"@/components/layout\";\nimport { getCachedS"
},
{
"path": "apps/app/routes/(app)/settings.tsx",
"chars": 7735,
"preview": "import {\n Button,\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n Input,\n Label,\n Separator,\n "
},
{
"path": "apps/app/routes/(app)/users.tsx",
"chars": 6829,
"preview": "import {\n Avatar,\n AvatarFallback,\n Button,\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n In"
},
{
"path": "apps/app/routes/(auth)/login.tsx",
"chars": 1895,
"preview": "import { AuthForm } from \"@/components/auth\";\nimport { getSafeRedirectUrl } from \"@/lib/auth-config\";\nimport { revalidat"
},
{
"path": "apps/app/routes/(auth)/signup.tsx",
"chars": 1900,
"preview": "import { AuthForm } from \"@/components/auth\";\nimport { getSafeRedirectUrl } from \"@/lib/auth-config\";\nimport { revalidat"
},
{
"path": "apps/app/routes/__root.tsx",
"chars": 673,
"preview": "import { AppErrorBoundary } from \"@/components/auth\";\nimport type { QueryClient } from \"@tanstack/react-query\";\nimport {"
},
{
"path": "apps/app/styles/globals.css",
"chars": 2960,
"preview": "@import \"../tailwind.config.css\";\n\n/**\n * CSS Variables for ShadCN UI Theming\n *\n * These variables define the color sch"
},
{
"path": "apps/app/tailwind.config.css",
"chars": 2262,
"preview": "/**\n * Tailwind CSS v4 configuration for the main app.\n * @see https://tailwindcss.com/docs/v4-beta\n */\n\n@import \"tailwi"
},
{
"path": "apps/app/tsconfig.json",
"chars": 870,
"preview": "{\n \"extends\": \"../../packages/typescript-config/react.jsonc\",\n \"compilerOptions\": {\n \"noEmit\": true,\n \"tsBuildIn"
},
{
"path": "apps/app/vite.config.ts",
"chars": 3058,
"preview": "import { tanstackRouter } from \"@tanstack/router-plugin/vite\";\nimport react from \"@vitejs/plugin-react-swc\";\nimport { TL"
},
{
"path": "apps/app/vitest.setup.ts",
"chars": 43,
"preview": "import \"@testing-library/jest-dom/vitest\";\n"
},
{
"path": "apps/app/wrangler.jsonc",
"chars": 1266,
"preview": "{\n \"$schema\": \"../../node_modules/wrangler/config-schema.json\",\n\n // [METADATA]\n // App worker accessed via service b"
},
{
"path": "apps/email/README.md",
"chars": 1154,
"preview": "# Email Templates\n\nTransactional email templates built with React Email.\n\n[Documentation](https://reactstarter.com/email"
},
{
"path": "apps/email/components/BaseTemplate.tsx",
"chars": 3748,
"preview": "import {\n Body,\n Container,\n Head,\n Hr,\n Html,\n Img,\n Link,\n Preview,\n Section,\n Text,\n} from \"@react-email/co"
},
{
"path": "apps/email/emails/email-verification.tsx",
"chars": 330,
"preview": "import { EmailVerification } from \"../templates/email-verification\";\n\nexport default function EmailVerificationPreview()"
},
{
"path": "apps/email/emails/otp-password-reset.tsx",
"chars": 260,
"preview": "import { OTPEmail } from \"../templates/otp-email\";\n\nexport default function OTPPasswordResetPreview() {\n return (\n <"
},
{
"path": "apps/email/emails/otp-sign-in.tsx",
"chars": 245,
"preview": "import { OTPEmail } from \"../templates/otp-email\";\n\nexport default function OTPSignInPreview() {\n return (\n <OTPEmai"
},
{
"path": "apps/email/emails/otp-verification.tsx",
"chars": 262,
"preview": "import { OTPEmail } from \"../templates/otp-email\";\n\nexport default function OTPVerificationPreview() {\n return (\n <O"
},
{
"path": "apps/email/emails/password-reset.tsx",
"chars": 306,
"preview": "import { PasswordReset } from \"../templates/password-reset\";\n\nexport default function PasswordResetPreview() {\n return "
},
{
"path": "apps/email/index.ts",
"chars": 261,
"preview": "export { EmailVerification } from \"./templates/email-verification.js\";\nexport { PasswordReset } from \"./templates/passwo"
},
{
"path": "apps/email/package.json",
"chars": 840,
"preview": "{\n \"name\": \"@repo/email\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"exports\": {\n \".\": {\n "
},
{
"path": "apps/email/templates/email-verification.tsx",
"chars": 2277,
"preview": "import { Button, Heading, Section, Text } from \"@react-email/components\";\nimport { BaseTemplate, colors } from \"../compo"
},
{
"path": "apps/email/templates/otp-email.tsx",
"chars": 2799,
"preview": "import { Heading, Section, Text } from \"@react-email/components\";\nimport { BaseTemplate, colors } from \"../components/Ba"
},
{
"path": "apps/email/templates/password-reset.tsx",
"chars": 2868,
"preview": "import { Button, Heading, Section, Text } from \"@react-email/components\";\nimport { BaseTemplate, colors } from \"../compo"
},
{
"path": "apps/email/tsconfig.json",
"chars": 436,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"ESNext\",\n \"moduleResolution\": \"bundler\",\n \"allowSy"
},
{
"path": "apps/email/utils/render.ts",
"chars": 638,
"preview": "import { render } from \"@react-email/render\";\nimport type { ReactElement } from \"react\";\n\n/**\n * Render a React email co"
},
{
"path": "apps/web/README.md",
"chars": 522,
"preview": "# Edge Router\n\nAstro-based edge worker that routes traffic to the app and API workers via Cloudflare service bindings.\n\n"
},
{
"path": "apps/web/_headers",
"chars": 1046,
"preview": "/*\n Strict-Transport-Security: max-age=31536000; includeSubDomains; preload\n X-Content-Type-Options: nosniff\n Referre"
},
{
"path": "apps/web/astro.config.mjs",
"chars": 449,
"preview": "import react from \"@astrojs/react\";\nimport { defineConfig } from \"astro/config\";\nimport { loadEnv } from \"vite\";\n\n// Loa"
},
{
"path": "apps/web/layouts/BaseLayout.astro",
"chars": 7046,
"preview": "---\nimport '@/styles/globals.css';\n\nexport interface Props {\n title?: string;\n description?: string;\n image?: string;"
},
{
"path": "apps/web/lib/utils.ts",
"chars": 75,
"preview": "// Re-export utils from the UI package\nexport * from \"@repo/ui/lib/utils\";\n"
},
{
"path": "apps/web/package.json",
"chars": 937,
"preview": "{\n \"name\": \"@repo/web\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"dev\": \"astro d"
},
{
"path": "apps/web/pages/about.astro",
"chars": 8971,
"preview": "---\nimport BaseLayout from '@/layouts/BaseLayout.astro';\nimport { Button, Card, CardContent, CardDescription, CardHeader"
},
{
"path": "apps/web/pages/features.astro",
"chars": 8956,
"preview": "---\nimport BaseLayout from '@/layouts/BaseLayout.astro';\nimport { Card, CardContent, CardDescription, CardHeader, CardTi"
},
{
"path": "apps/web/pages/index.astro",
"chars": 5499,
"preview": "---\nimport BaseLayout from '@/layouts/BaseLayout.astro';\nimport { Button, Card, CardContent, CardDescription, CardHeader"
},
{
"path": "apps/web/pages/pricing.astro",
"chars": 7398,
"preview": "---\nimport BaseLayout from '@/layouts/BaseLayout.astro';\nimport { Button, Card, CardContent, CardDescription, CardHeader"
},
{
"path": "apps/web/postcss.config.js",
"chars": 70,
"preview": "export default {\n plugins: {\n \"@tailwindcss/postcss\": {},\n },\n};\n"
},
{
"path": "apps/web/public/robots.txt",
"chars": 78,
"preview": "# www.robotstxt.org/\n\n# Allow crawling of all content\nUser-agent: *\nDisallow:\n"
},
{
"path": "apps/web/public/site.manifest",
"chars": 507,
"preview": "{\n \"short_name\": \"React App\",\n \"name\": \"React App Sample\",\n \"icons\": [\n {\n \"src\": \"favicon.ico\",\n \"sizes"
},
{
"path": "apps/web/styles/globals.css",
"chars": 2960,
"preview": "@import \"../tailwind.config.css\";\n\n/**\n * CSS Variables for ShadCN UI Theming\n *\n * These variables define the color sch"
},
{
"path": "apps/web/tailwind.config.css",
"chars": 2141,
"preview": "/**\n * Tailwind CSS v4 configuration for the web app.\n * @see https://tailwindcss.com/docs/configuration\n */\n\n@import \"t"
},
{
"path": "apps/web/tsconfig.json",
"chars": 612,
"preview": "{\n \"extends\": \"astro/tsconfigs/strict\",\n \"compilerOptions\": {\n \"composite\": true,\n \"noEmit\": true,\n \"tsBuildI"
},
{
"path": "apps/web/worker.ts",
"chars": 1709,
"preview": "/**\n * Edge router for the marketing site.\n *\n * Routes \"/\" based on auth-hint cookie presence:\n * - Cookie present: pro"
},
{
"path": "apps/web/wrangler.jsonc",
"chars": 2239,
"preview": "{\n \"$schema\": \"../../node_modules/wrangler/config-schema.json\",\n\n // [METADATA]\n // Edge router that receives all tra"
},
{
"path": "db/AGENTS.md",
"chars": 2012,
"preview": "## Schema Conventions\n\n- Drizzle `casing: \"snake_case\"` — use camelCase in TypeScript, columns map to snake_case in DB.\n"
},
{
"path": "db/README.md",
"chars": 2372,
"preview": "# Database Layer\n\nDatabase layer using [Drizzle ORM](https://orm.drizzle.team/) and PostgreSQL ([Neon](https://neon.tech"
},
{
"path": "db/backups/.gitignore",
"chars": 73,
"preview": "# Ignore all backup files\n*.sql\n\n# Keep the directory in git\n!.gitignore\n"
},
{
"path": "db/drizzle.config.ts",
"chars": 1388,
"preview": "import { configDotenv } from \"dotenv\";\nimport { defineConfig } from \"drizzle-kit\";\nimport { resolve } from \"node:path\";\n"
},
{
"path": "db/index.ts",
"chars": 252,
"preview": "/**\n * @file Database schema exports.\n *\n * Re-exports Drizzle ORM schemas for users, organizations, and authentication."
},
{
"path": "db/migrations/0000_init.sql",
"chars": 7506,
"preview": "CREATE TABLE \"invitation\" (\n\t\"id\" text PRIMARY KEY NOT NULL,\n\t\"email\" text NOT NULL,\n\t\"inviter_id\" text NOT NULL,\n\t\"orga"
},
{
"path": "db/migrations/meta/0000_snapshot.json",
"chars": 28375,
"preview": "{\n \"id\": \"2f162304-a16e-4ba9-bf5b-dac3c1e4f6c0\",\n \"prevId\": \"00000000-0000-0000-0000-000000000000\",\n \"version\": \"1\",\n"
},
{
"path": "db/migrations/meta/_journal.json",
"chars": 199,
"preview": "{\n \"version\": \"7\",\n \"dialect\": \"postgresql\",\n \"entries\": [\n {\n \"idx\": 0,\n \"version\": \"1\",\n \"when\": "
},
{
"path": "db/package.json",
"chars": 1943,
"preview": "{\n \"name\": \"@repo/db\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"type\": \"module\",\n \"exports\": {\n \".\": \"./index.ts"
},
{
"path": "db/schema/id.ts",
"chars": 1546,
"preview": "// Prefixed CUID2 ID generation for all database entities.\n// Format: {prefix}_{body} e.g. \"usr_ght4k2jxm7pqbv01\" (20 ch"
},
{
"path": "db/schema/index.ts",
"chars": 167,
"preview": "export * from \"./id\";\nexport * from \"./invitation\";\nexport * from \"./organization\";\nexport * from \"./passkey\";\nexport * "
},
{
"path": "db/schema/invitation.ts",
"chars": 2342,
"preview": "// Better Auth invitation system for organization invites\n\nimport { relations } from \"drizzle-orm\";\nimport { index, pgTa"
},
{
"path": "db/schema/organization.ts",
"chars": 2966,
"preview": "// Multi-tenant organizations and memberships with role-based access control\n\nimport { relations } from \"drizzle-orm\";\ni"
},
{
"path": "db/schema/passkey.ts",
"chars": 1649,
"preview": "// WebAuthn passkey credentials for Better Auth\n// @see https://www.better-auth.com/docs/plugins/passkey\n\nimport {\n boo"
},
{
"path": "db/schema/subscription.ts",
"chars": 1800,
"preview": "// Stripe subscription state managed by the @better-auth/stripe plugin.\n// referenceId is polymorphic: points to user.id"
},
{
"path": "db/schema/user.ts",
"chars": 5520,
"preview": "/**\n * Database schema for Better Auth authentication system.\n *\n * This schema is designed to be fully compatible with "
},
{
"path": "db/scripts/export.ts",
"chars": 3855,
"preview": "#!/usr/bin/env bun\n/**\n * PostgreSQL database export utility with schema/data options\n *\n * Usage:\n * bun scripts/expo"
},
{
"path": "db/scripts/generate-auth-schema.ts",
"chars": 2621,
"preview": "import { getAuthTables } from \"better-auth/db\";\nimport type { BetterAuthOptions } from \"better-auth/types\";\nimport { cre"
},
{
"path": "db/scripts/seed.ts",
"chars": 748,
"preview": "#!/usr/bin/env bun\n// Usage: bun scripts/seed.ts [--env ENVIRONMENT=staging|prod]\n\nimport { drizzle } from \"drizzle-orm/"
},
{
"path": "db/seeds/users.ts",
"chars": 1356,
"preview": "import { PostgresJsDatabase } from \"drizzle-orm/postgres-js\";\nimport * as schema from \"../schema\";\nimport { type NewUser"
},
{
"path": "db/tsconfig.json",
"chars": 388,
"preview": "{\n \"extends\": \"../packages/typescript-config/node.jsonc\",\n \"compilerOptions\": {\n \"tsBuildInfoFile\": \"dist/.tsbuildi"
},
{
"path": "docs/.vitepress/config.ts",
"chars": 7936,
"preview": "import { defineConfig } from \"vitepress\";\nimport llmstxt from \"vitepress-plugin-llms\";\n\n/**\n * VitePress configuration.\n"
},
{
"path": "docs/.vitepress/public/robots.txt",
"chars": 113,
"preview": "User-agent: *\nAllow: /\n\nSitemap: https://reactstarter.com/sitemap.xml\nSitemap: https://reactstarter.com/llms.txt\n"
},
{
"path": "docs/.vitepress/theme/components/GitHubStats.vue",
"chars": 1845,
"preview": "<template>\n <div class=\"github-stats\">\n <a\n href=\"https://github.com/kriasoft/react-starter-kit/stargazers\"\n "
},
{
"path": "docs/.vitepress/theme/components/Mermaid.vue",
"chars": 2423,
"preview": "<script setup lang=\"ts\">\nimport { ref, computed, onMounted, watch } from \"vue\";\nimport { useData } from \"vitepress\";\n\nco"
},
{
"path": "docs/.vitepress/theme/index.ts",
"chars": 668,
"preview": "/**\n * Custom theme for VitePress documentation.\n * @see https://vitepress.dev/guide/custom-theme\n */\n\nimport type { The"
},
{
"path": "docs/.vitepress/theme/style.css",
"chars": 4284,
"preview": "/**\n * Customize default theme styling by overriding CSS variables:\n * https://github.com/vuejs/vitepress/blob/main/src/"
},
{
"path": "docs/adr/000-template.md",
"chars": 406,
"preview": "# ADR-NNN Title\n\n**Status:** Proposed | Accepted | Deprecated | Superseded \n**Date:** YYYY-MM-DD \n**Tags:** tag1, tag2"
},
{
"path": "docs/adr/001-auth-hint-cookie.md",
"chars": 1370,
"preview": "# ADR-001 Auth Hint Cookie For Edge Routing\n\n**Status:** Accepted\n**Date:** 2025-12-28\n**Tags:** auth, routing, edge\n\n##"
},
{
"path": "docs/api/context.md",
"chars": 6705,
"preview": "# Context & Middleware\n\nEvery tRPC procedure receives a context object (`ctx`) with request-scoped resources. The middle"
},
{
"path": "docs/api/index.md",
"chars": 5398,
"preview": "---\noutline: [2, 3]\n---\n\n# API Overview\n\nThe API server (`apps/api/`) runs as a Cloudflare Worker and handles all backen"
},
{
"path": "docs/api/procedures.md",
"chars": 4534,
"preview": "# Procedures\n\ntRPC procedures are the primary way the frontend communicates with the API. Each procedure is either a **q"
},
{
"path": "docs/api/validation-errors.md",
"chars": 5471,
"preview": "# Validation & Errors\n\nInput validation and error handling follow one flow: Zod schemas validate procedure inputs, valid"
},
{
"path": "docs/architecture/edge.md",
"chars": 7953,
"preview": "# Edge\n\nImplementation details for the Cloudflare Workers deployment. Read the [Architecture Overview](./) first for the"
},
{
"path": "docs/architecture/index.md",
"chars": 7855,
"preview": "# Architecture Overview\n\nReact Starter Kit runs on three Cloudflare Workers connected by [service bindings](https://deve"
},
{
"path": "docs/auth/email-otp.md",
"chars": 5225,
"preview": "---\noutline: [2, 3]\n---\n\n# Email & OTP\n\nThe primary sign-in method is passwordless email OTP. Users enter their email, r"
},
{
"path": "docs/auth/index.md",
"chars": 7331,
"preview": "---\noutline: [2, 3]\n---\n\n# Authentication Overview\n\nAuthentication is handled by [Better Auth](https://www.better-auth.c"
},
{
"path": "docs/auth/organizations.md",
"chars": 6527,
"preview": "---\noutline: [2, 3]\n---\n\n# Organizations & Roles\n\nOrganizations provide multi-tenant isolation. Each organization is a s"
},
{
"path": "docs/auth/passkeys.md",
"chars": 5514,
"preview": "---\noutline: [2, 3]\n---\n\n# Passkeys\n\nPasskey authentication uses the [WebAuthn](https://webauthn.io/) standard to let us"
},
{
"path": "docs/auth/sessions.md",
"chars": 6863,
"preview": "---\noutline: [2, 3]\n---\n\n# Sessions & Protected Routes\n\nSession state is managed exclusively through TanStack Query. The"
},
{
"path": "docs/auth/social-providers.md",
"chars": 3415,
"preview": "---\noutline: [2, 3]\n---\n\n# Social Providers\n\nGoogle OAuth is configured out of the box. The flow redirects users to Goog"
},
{
"path": "docs/billing/checkout.md",
"chars": 3371,
"preview": "---\noutline: [2, 3]\n---\n\n# Checkout Flow\n\nUpgrades and subscription management use Stripe's hosted pages – Stripe Checko"
},
{
"path": "docs/billing/index.md",
"chars": 5582,
"preview": "---\noutline: [2, 3]\n---\n\n# Billing\n\nStripe subscriptions are integrated via the [`@better-auth/stripe`](https://www.bett"
},
{
"path": "docs/billing/plans.md",
"chars": 3344,
"preview": "---\noutline: [2, 3]\n---\n\n# Plans & Pricing\n\nPlan limits are defined once in `apps/api/lib/plans.ts` and referenced by th"
},
{
"path": "docs/billing/webhooks.md",
"chars": 2681,
"preview": "---\noutline: [2, 3]\n---\n\n# Webhooks\n\nThe `@better-auth/stripe` plugin registers a webhook endpoint at `POST /api/auth/st"
},
{
"path": "docs/database/index.md",
"chars": 3751,
"preview": "# Database\n\nThe `db/` workspace manages the data layer with [Drizzle ORM](https://orm.drizzle.team/) and [Neon PostgreSQ"
},
{
"path": "docs/database/migrations.md",
"chars": 2438,
"preview": "# Migrations\n\nDrizzle Kit generates SQL migrations by diffing your TypeScript schema against the latest snapshot. Migrat"
},
{
"path": "docs/database/queries.md",
"chars": 3741,
"preview": "---\noutline: [2, 3]\n---\n\n# Query Patterns\n\nCommon patterns for querying the database in tRPC procedures. All examples us"
},
{
"path": "docs/database/schema.md",
"chars": 10400,
"preview": "---\noutline: [2, 3]\n---\n\n# Schema\n\nThe database schema lives in `db/schema/`, with one file per entity group. Drizzle OR"
},
{
"path": "docs/database/seeding.md",
"chars": 2025,
"preview": "# Seeding\n\nSeed scripts populate your database with test data for development. They live in `db/seeds/` and are orchestr"
},
{
"path": "docs/deployment/ci-cd.md",
"chars": 4030,
"preview": "# CI/CD\n\nGitHub Actions automates building, testing, and deploying. The pipeline uses two workflows: `ci.yml` for the bu"
},
{
"path": "docs/deployment/cloudflare.md",
"chars": 4352,
"preview": "# Cloudflare Workers\n\nEach app has its own `wrangler.jsonc` with per-environment configuration for variables, service bi"
},
{
"path": "docs/deployment/index.md",
"chars": 3512,
"preview": "# Deployment\n\nReact Starter Kit deploys as three Cloudflare Workers backed by a Neon PostgreSQL database. Infrastructure"
},
{
"path": "docs/deployment/monitoring.md",
"chars": 3539,
"preview": "# Monitoring\n\nMonitor your Workers in production using Cloudflare's built-in tools and roll back quickly when issues ari"
},
{
"path": "docs/deployment/production-database.md",
"chars": 2629,
"preview": "# Production Database\n\nThe production database runs on [Neon PostgreSQL](https://neon.tech/) with [Cloudflare Hyperdrive"
},
{
"path": "docs/email.md",
"chars": 6512,
"preview": "# Email\n\nTransactional emails are built with [React Email](https://react.email/) and delivered through [Resend](https://"
},
{
"path": "docs/frontend/forms.md",
"chars": 4722,
"preview": "# Forms & Validation\n\nForms use controlled React inputs with Zod for validation. There's no form library – the patterns "
},
{
"path": "docs/frontend/routing.md",
"chars": 5616,
"preview": "# Routing\n\nThe app uses [TanStack Router](https://tanstack.com/router/latest) with file-based routing. Routes are define"
}
]
// ... and 131 more files (download for full content)
About this extraction
This page contains the full source code of the kriasoft/react-starter-kit GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 331 files (734.2 KB), approximately 194.0k tokens, and a symbol index with 215 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.