Valcraven Docs
Conventions & Gotchas

Code conventions

Import paths, the lazy-init pattern, required API-route boilerplate, and the rules to never break when building on Valcraven.

These are the conventions the Valcraven codebase already follows. Sticking to them keeps your code consistent with the template, keeps it deployable to Cloudflare Workers, and avoids a set of failure modes the template has already hit. When in doubt, open the file you're modeling and copy its shape.

Import paths

The @/ alias points at the apps/web root (configured in tsconfig.json and vitest.config.ts). Prefer these canonical import paths over deep relative imports:

// Auth
import { auth } from "@/lib/auth";              // server only — never import in a client component
import { authClient } from "@/lib/auth-client"; // client + server

// Database
import { getDb } from "@/lib/db";               // Drizzle ORM instance (use for almost everything)
import { getRawDb } from "@/lib/db";            // raw better-sqlite3 — dev/SQLite only, throws on D1

// Errors
import { UnauthorizedError, BadRequestError, errorResponse } from "@/lib/errors";

// Admin check
import { isAdmin } from "@/lib/admin";

// UI + hooks
import { Button, Input } from "@/components/ui";
import { useToast } from "@/lib/use-toast";
import { useTheme } from "@/lib/theme";

See Data layer for the full picture of getDb() vs the raw adapters, and Project structure for where things live.

The API-route template

Every API route under app/api/** should start with these two exports and use the shared error handler. dynamic = "force-dynamic" prevents Next.js from trying to statically cache an authenticated route; runtime = "nodejs" selects the Node-compatible runtime the auth and DB layers need.

export const dynamic = "force-dynamic";
export const runtime = "nodejs";

export async function GET(request: Request) {
  try {
    const session = await auth.api.getSession({ headers: request.headers });
    if (!session) throw new UnauthorizedError();
    // ... handle the request
    return NextResponse.json({ data });
  } catch (error) {
    return errorResponse(error);
  }
}

errorResponse() maps UnauthorizedError → 401, BadRequestError → 400, and anything else → 500, so you throw typed errors instead of hand-building responses. Every route and query must scope data to the session's user — never a hard-coded user id. The walkthrough is in Add an API route.

The lazy-init pattern (no module-level new ServiceClient())

Services that depend on environment variables must be constructed lazily, on first use — never at module load. A module-level new Stripe(process.env.STRIPE_SECRET_KEY!) runs the moment the module is imported, which crashes the whole bundle when that env var is absent (and on Cloudflare Workers, some clients can't even be constructed at module-load time). The pattern:

let _client: StripeClient | null = null;

function getStripe(): StripeClient {
  if (!_client) {
    _client = new Stripe(process.env.STRIPE_SECRET_KEY!);
  }
  return _client;
}

This keeps a missing or optional integration from taking down unrelated routes, and it defers reading env until a request actually needs the client. The one exception in the template is Better Auth, which manages its own initialization — don't wrap it.

Auth patterns

Server code (API routes, server components) reads the session from request headers:

import { auth } from "@/lib/auth";
const session = await auth.api.getSession({ headers: request.headers });

Client components use the client SDK, which never pulls the server-only module into the browser bundle:

import { authClient } from "@/lib/auth-client";
const { data: session } = await authClient.getSession();

Admin gating goes through isAdmin():

import { isAdmin } from "@/lib/admin";
if (!(await isAdmin(session.user.id))) throw new UnauthorizedError();

See Authentication for the full auth surface.

Database conventions

  • Use getDb() (Drizzle ORM) for typed queries — this works identically on SQLite, Postgres, and D1.
  • Reach for getRawDb() only for raw SQL that Drizzle can't express (FTS5, PRAGMA). It is dev/SQLite-only and throws on D1 — for dialect-agnostic raw SQL use getRawAdapter() instead. See D1 dev/prod parity.
  • Foreign keys reference user(id) — the table is singular (user, not users), a Better Auth convention.
  • Custom user fields are camelCase (stripeCustomerId, subscriptionStatus).
  • With better-sqlite3 in dev, queries are synchronous — no await on getRawDb() calls.

UI conventions

  • Always set an explicit text color on <input> (and <textarea>/<select>) elements styled with Tailwind: className="... text-gray-900 dark:text-gray-100". Without it, typed text can render invisibly (e.g. white-on-white) in one theme. This is easy to miss because it looks fine in whichever theme you happened to test.
  • Prefer the shared components from @/components/ui (Button, Input, Modal, …) so styling and theme behavior stay consistent. See Theming.

useSearchParams() needs a Suspense boundary

Any client component that calls useSearchParams() must be wrapped in a <Suspense> boundary. In the Next.js App Router, useSearchParams() opts the subtree into client-side rendering, and without a Suspense boundary the production build fails (or the page de-opts to fully dynamic). Wrap the component:

import { Suspense } from "react";

<Suspense fallback={null}>
  <ThingThatReadsSearchParams />
</Suspense>

Things to never do

A distilled checklist. Each of these has bitten the template at least once:

  • Never import lib/auth.ts in a client component. It's server-only; importing it pulls Node/DB code into the browser bundle. Use @/lib/auth-client on the client.
  • Never construct an env-dependent client at module level (new ServiceClient() at the top of a file). Use the lazy-init pattern above.
  • Never omit export const dynamic = "force-dynamic" on an authenticated API route. A statically cached auth route serves stale/leaked data.
  • Never use <input> (Tailwind) without an explicit text color (text-gray-900 dark:text-gray-100).
  • Never call useSearchParams() without a <Suspense> boundary.
  • Never use docker buildx / BuildKit here — build with DOCKER_BUILDKIT=0 docker build (the template's Docker setup expects the legacy builder).
  • Never edit the OpenNext-generated .open-next/worker.js — it's overwritten on every build. Edit worker.js and lib/scheduled.ts instead (see Cloudflare Workers architecture).
  • Never scope a query or route to a hard-coded user id — always derive it from auth.api.getSession(...).

Keep the docs in sync

When you add or change a feature, update the relevant docs page in the same change. In the template, documentation is treated as part of "done," not an afterthought — a feature without its page is incomplete.

On this page