Valcraven Docs
Building with Valcraven

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.
  • /app is the gated app. New authenticated features render here, at app/app/<feature>/page.tsx (URL /app/<feature>), or as a section inside the dashboard app/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 /app page 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 — the ItemsPanel and RealtimeDemoCard on 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, or users[0]. Every user-owned table has a user_id foreign 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 /home for 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 /app entry instead.
  • Replacing app/app/page.tsx with a fixed inset-0 overlay or a blank canvas → you've deleted the shell; render inside it.
  • Writing const userId = "..." in an API route → resolve it from auth.api.getSession and 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_id foreign key references user(id) (singular) — Better Auth names its table user, not users. 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 keysitems 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:

  1. Add a panel to the dashboard. The simplest first feature is a component on app/app/page.tsx, exactly like ItemsPanel (components/items-panel.tsx) and RealtimeDemoCard. It inherits the shell for free.
  2. 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, NotificationBell from @/components/notification-bell, ThemeToggle from @/components/ui/theme-toggle (it is a subpath import — it is not re-exported from the @/components/ui barrel), the Settings link, and sign-out via authClient.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_id FK to user(id), migration applied (Database migrations).
  • API route uses force-dynamic + nodejs, resolves the session, filters every query by session.user.id, and returns errors via errorResponse().
  • UI renders inside /app (a dashboard panel or an app/app/<feature>/page.tsx subroute) with the shell intact — you did not touch app/page.tsx or replace the dashboard with an overlay.
  • No hardcoded user ids anywhere.

On this page