Add a feature
The golden path for adding an authenticated feature — model the data, add a scoped API route, and render the UI inside the /app shell without breaking the conventions the template relies on.
Add a feature
This is the single most important recipe in the docs. Valcraven's value is the shell it ships with — auth, billing, settings, the header chrome, the command palette, onboarding, cookie consent — all wired up before you write a line. A feature that ignores that and stands up its own public route or clobbers the dashboard throws it all away. This page walks a feature end to end while staying on the golden path.
The example is a small Notes feature: a user can create and list notes. Swap "note" for your own model; the shape is identical.
The golden-path rule
Read this before you write anything. It is the rule the whole template is built around.
/is the public marketing landing. Do not redirect it, replace it, or gate it behind auth./appis the gated app. New authenticated features render here, atapp/app/<feature>/page.tsx(URL/app/<feature>), or as a section inside the dashboardapp/app/page.tsx.- Auth is already enforced. Middleware protects
/app/*and/settings/*— it redirects unauthenticated visitors to/auth. Don't add route-level auth checks to your page; rely on the middleware (and still resolve the session in every API route — see below). - Preserve the shell. Every
/apppage keeps the chrome the user expects: the header with the logo,NotificationBell,ThemeToggle, the Settings link, and sign-out; command-palette registration; and the<Onboarding />mount. The dashboard (app/app/page.tsx) is the reference implementation — theItemsPanelandRealtimeDemoCardon it show exactly how a feature slots into the shell. - Scope all data to the session user. Every API route resolves the user from the session and returns 401 without one. Never hardcode a user id, a
"demo"id, orusers[0]. Every user-owned table has auser_idforeign key, so every query filters by the session user id.
Smell tests — stop if you catch yourself doing any of these
- Creating a new top-level route like
/dashboard,/notes, or/homefor the main feature → it belongs under/app. - Editing
app/page.tsx(the marketing landing) to redirect to your feature → route the user through the existing/appentry instead. - Replacing
app/app/page.tsxwith afixed inset-0overlay or a blank canvas → you've deleted the shell; render inside it. - Writing
const userId = "..."in an API route → resolve it fromauth.api.getSessionand 401 without it.
Step 1 — Model the data
Add a table for your feature. Valcraven uses Drizzle ORM with a numbered SQL migration for each table. The example CRUD table items is the pattern to copy:
// lib/schema.sqlite.ts — the SQLite table definition
export const notes = sqliteTable("notes", {
id: text("id").primaryKey(),
user_id: text("user_id").notNull().references(() => user.id),
body: text("body").notNull(),
created_at: text("created_at").default(sql`CURRENT_TIMESTAMP`),
}, (table) => [
index("idx_notes_user_id").on(table.user_id),
]);Then write a numbered migration in migrations/ and apply it. The full workflow — the schema barrel, the user(id) foreign key, npm run db:migrate, and Cloudflare D1 — is its own recipe: Database migrations.
The
user_idforeign key referencesuser(id)(singular) — Better Auth names its tableuser, notusers. Getting this wrong is the most common schema mistake.
Step 2 — Add the API route
Create app/api/notes/route.ts. This is the standard handler shape: the force-dynamic + nodejs exports, resolve the session, and hand any error to errorResponse(). The list handler filters by the session user id:
import { NextResponse } from "next/server";
import { eq, asc } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { getDb } from "@/lib/db";
import { notes } from "@/lib/schema";
import { UnauthorizedError, errorResponse } from "@/lib/errors";
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();
const rows = await getDb()
.select()
.from(notes)
.where(eq(notes.user_id, session.user.id)) // scoped to the caller
.orderBy(asc(notes.created_at));
return NextResponse.json({ notes: rows });
} catch (error) {
return errorResponse(error);
}
}The full template — the POST handler, input validation with a Zod schema, and per-method error handling — is documented in Add an API route. The real, shippable reference is the example feature at app/api/items/route.ts and app/api/items/[id]/route.ts; read those two files, then rename them for your model.
If you want the route callable from scripts or CI with an API key (not just a browser session), see Protect a route with API keys — items is already opted in and is the copy-paste example.
Step 3 — Render the UI inside the shell
Add your feature UI inside /app. You have two good options, smallest first:
- Add a panel to the dashboard. The simplest first feature is a component on
app/app/page.tsx, exactly likeItemsPanel(components/items-panel.tsx) andRealtimeDemoCard. It inherits the shell for free. - Add a subroute. For something larger, create
app/app/notes/page.tsx(URL/app/notes). Keep the same header elements the dashboard uses so the chrome is consistent — the logo,NotificationBellfrom@/components/notification-bell,ThemeTogglefrom@/components/ui/theme-toggle(it is a subpath import — it is not re-exported from the@/components/uibarrel), the Settings link, and sign-out viaauthClient.signOut().
A client component fetches your route the same way the rest of the app does:
"use client";
import { useEffect, useState } from "react";
export function NotesPanel() {
const [notes, setNotes] = useState<Array<{ id: string; body: string }>>([]);
useEffect(() => {
fetch("/api/notes")
.then((r) => r.json())
.then((d) => setNotes(d.notes ?? []));
}, []);
// render inside a <Card> from @/components/ui …
}Use the shipped UI primitives (Button, Input, Card, Modal, …) from @/components/ui, and toast from @/lib/use-toast for feedback — don't rebuild them. See Theming for the semantic color tokens to use instead of hardcoded Tailwind colors.
Step 4 — Wire it into the command palette (optional)
If your feature has actions worth surfacing, register them with the command registry so they show up in the palette (Cmd/Ctrl-K). Register inside a useEffect and clean up on unmount — the same way the dashboard does:
import { commandRegistry } from "@/lib/commands";
useEffect(() => {
const commands = [
{
id: "notes-new",
label: "New Note",
category: "Actions",
action: () => openNewNoteModal(),
},
];
commandRegistry.registerAll(commands);
return () => commands.forEach((c) => commandRegistry.unregister(c.id));
}, []);A Command is { id, label, category, icon?, keywords?, action } (see lib/commands.ts).
You're done — the checklist
- Table added with a
user_idFK touser(id), migration applied (Database migrations). - API route uses
force-dynamic+nodejs, resolves the session, filters every query bysession.user.id, and returns errors viaerrorResponse(). - UI renders inside
/app(a dashboard panel or anapp/app/<feature>/page.tsxsubroute) with the shell intact — you did not touchapp/page.tsxor replace the dashboard with an overlay. - No hardcoded user ids anywhere.
Related
- Add an API route — the route handler template in full.
- Database migrations — the schema-to-D1 workflow.
- Data layer — how
getDb(), Drizzle, and the dialects fit together. - Code conventions and Gotchas — the house rules and sharp edges.
Building with Valcraven
Practical, copy-paste recipes for extending the template — features, API routes, admin pages, migrations, realtime channels, chat tools, and API-key auth.
Add an API route
The standard API route handler — force-dynamic + nodejs, resolve the Better Auth session, scope queries to the user, and return errors through errorResponse().