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 usegetRawAdapter()instead. See D1 dev/prod parity. - Foreign keys reference
user(id)— the table is singular (user, notusers), a Better Auth convention. - Custom user fields are camelCase (
stripeCustomerId,subscriptionStatus). - With better-sqlite3 in dev, queries are synchronous — no
awaitongetRawDb()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.tsin a client component. It's server-only; importing it pulls Node/DB code into the browser bundle. Use@/lib/auth-clienton 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 withDOCKER_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. Editworker.jsandlib/scheduled.tsinstead (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.