User guide — nextjs-saas-boilerplate-ts

Hands-on reference for this Next.js SaaS stack: Better Auth, Prisma, Route Handlers + services, shadcn/ui, and optional R2 / Google OAuth. For full architecture rules, see AGENTS.md.

Stack: Next.js (App Router), React, Tailwind, TanStack Query, React Hook Form, Zod, PostgreSQL, Prisma, Better Auth (Argon2).


Quick start

Requirements: Node.js 20+ (see .nvmrc for the pinned major; CI uses Node 20 LTS), PostgreSQL, npm or pnpm.

cp .env.example .env
# Set DATABASE_URL and BETTER_AUTH_SECRET (min 32 chars).
# Generate secret: npx @better-auth/cli secret

npm install
npm run db:setup    # Prisma client + push + seed (or: db:generate, db:push, db:seed)
npm run dev         # http://localhost:3000
VariableRequiredNotes
DATABASE_URLYesPostgreSQL URL
BETTER_AUTH_SECRETYesSigning secret
APP_URLNoDefaults to http://localhost:3000
BETTER_AUTH_URLNoDefaults to APP_URL
SMTP_*NoWithout SMTP in dev, mail is logged
R2_*NoFile uploads — Cloudflare R2
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRETNoGoogle sign-in
NEXT_PUBLIC_APP_URLNoClient / OAuth base URL in production
ADMIN_EMAIL / ADMIN_PASSWORDNoSeed admin

Example: DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public"


Removing example features

The older /dashboard/example-features demo bundle is not part of this template anymore. If you are migrating from a fork that still had demo routes and models, delete those files and Prisma models, run npx prisma migrate dev (or db push), and regenerate the client.


Scope and product boundaries

This stack is a production-style starter: auth, dashboard, admin, CRUD patterns, optional R2 and OAuth. It is not a full commercial SaaS product out of the box.

AreaBoundary
BillingNo subscription, invoice, or payment-provider integration in the schema or routes. Add Stripe (or similar) and entitlements when you need paid plans.
Per-user isolationCRUD defaults to global APIs (any logged-in user can access a row by ID). To restrict rows to the owning user, add ownership fields and checks — see §17 Ownership and per-user data.

8. Quick reference

TaskWhere / pattern
Any logged-in user (page)await requireAuth() — dashboard layout already enforces auth
Permission-gated pageawait requireAllPermissions("admin:users", "admin:roles") from @/lib/server/auth
Permission (any of)await requireAnyPermission("app:read", "app:write") from @/lib/server/auth
Role-gated page (legacy)await requireRole(["admin"]) from @/lib/server/auth
API: auth + rate limitwithAuthAndRateLimit((req) => …) from @/lib/server/api-helpers
API: need user in handlerwithAuthUserAndRateLimit((req, user) => …)
API: permission + rate limitwithPermissionAndRateLimit(["admin:users"], (req, user) => …)
API: permission (any of)withPermissionAndRateLimit(["admin:users", "admin:roles"], (req, user) => …, { mode: "any" })
API: role + rate limitwithRoleAndRateLimit(["admin"], (req, user) => …)
Validate bodyparseBody(schema, body)
Validate [id]validateCuid(id)
List pagination queryparsePagination(searchParams)
Business logicsrc/lib/server/services/*.service.ts — not in route files
Prisma@/lib/server/prisma only; server code only
New CRUDAdd Prisma model, service, API routes, and pages manually
Nav / sidebarsrc/config/nav.tsdashboardNavItems
Permissions catalogsrc/lib/rbac/permissions.ts — canonical list
Role → permission mapsrc/lib/rbac/role-permissions.ts — static defaults
DB-driven RBACRole, Permission, RolePermission tables (seeded with built-in roles)
Toaststoast from sonner
Destructive confirmConfirmDialog from @/components/ui
Legacy demo cleanupRemoving example features

Project structure

src/
├── app/                 # Routes (App Router)
│   ├── api/             # Route handlers (health + Better Auth)
│   ├── dashboard/       # Protected app (layout uses auth)
│   ├── login/, signup/, home/, …
├── components/          # ui/, layout/, dashboard/, providers/
├── config/              # nav, env
├── generated/prisma/    # Prisma client (output from prisma/schema.prisma)
├── hooks/               # useCrudList, useCrudSingle, useCrudCreate, …
└── lib/
    ├── rbac/            # permissions, role → permission map
    ├── server/          # auth, prisma, better-auth, api-helpers, rate-limit, r2, services/
    ├── validation/      # Zod + entity types
    ├── api.ts, api-types.ts, types.ts
File / areaRole
src/lib/server/auth.tsgetAuthUser, requireAuth, requireRole
src/lib/server/api-helpers.tswithAuth*, withRole*, parseBody, parsePagination, validateCuid
src/lib/server/prisma.tsSingleton Prisma client
src/lib/server/services/Services; route handlers stay thin
src/app/dashboard/layout.tsxDashboard shell + auth

5. Implementation examples

New page (permission-gated)

  1. Add src/app/dashboard/my-feature/page.tsx.
  2. Call requireAllPermissions(...) at the top with required permissions.
  3. Add a nav row in src/config/nav.ts.
import { requireAllPermissions } from "@/lib/server/auth";

export default async function MyFeaturePage() {
  const user = await requireAllPermissions("app:dashboard");
  return (
    <div>
      <h1 className="text-2xl font-semibold">My Feature</h1>
      <p>Hello, {user.email}.</p>
    </div>
  );
}
NeedUse
Any logged-in userrequireAuth()
All of these permissionsrequireAllPermissions("admin:users", "admin:roles")
At least one permissionrequireAnyPermission("admin:users", "admin:roles")
Legacy: admin onlyrequireRole(["admin"])

Unauthorized → /login or /dashboard?error=forbidden.

Adding a new permission: Add the string to PERMISSIONS in src/lib/rbac/permissions.ts, then map it to roles in ROLE_PERMISSIONS in src/lib/rbac/role-permissions.ts. Admins can create custom roles and permissions in the UI at /dashboard/admin → Roles tab.

Page that fetches via TanStack Query

  1. API src/app/api/my-data/route.ts — return { data } on success.
  2. Client component with "use client" + useQuery + apiFetch.
  3. Page (RSC): requireAuth() and render the client.
import { NextResponse } from "next/server";
import { requireAuth } from "@/lib/server/auth";

// Success shape must be { data }
export async function GET() {
  try {
    await requireAuth();
    return NextResponse.json({ data: [{ id: 1, title: "Item 1" }] });
  } catch {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }
}
"use client";
import { useQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { apiFetch } from "@/lib/api";

async function fetchMyData() {
  return apiFetch<{ id: number; title: string }[]>("/api/my-data");
}

export function MyDataClient() {
  const { data, error, isLoading, isFetching } = useQuery({
    queryKey: ["my-data"],
    queryFn: fetchMyData,
  });
  if (isLoading || isFetching) {
    return (
      <div className="flex justify-center py-16">
        <Loader2 className="size-8 animate-spin text-muted-foreground" />
      </div>
    );
  }
  if (error) {
    return (
      <div className="rounded-xl border border-destructive/50 bg-destructive/10 p-6 text-sm">
        {error instanceof Error ? error.message : "Failed to fetch"}
      </div>
    );
  }
  return (
    <div className="space-y-4">
      {data?.map((item) => (
        <div key={item.id} className="rounded-lg border p-4">
          <p className="font-medium">{item.title}</p>
        </div>
      ))}
    </div>
  );
}

New CRUD resource

See Adding features (consolidated) for the full manual workflow.

Delete with confirmation (not window.confirm)

const deleteMutation = useCrudDelete("projects");
const [confirmOpen, setConfirmOpen] = useState(false);

<>
  <Button variant="ghost" size="sm" onClick={() => setConfirmOpen(true)}>
    <Trash2 className="size-4 text-destructive" />
  </Button>
  <ConfirmDialog
    open={confirmOpen}
    onOpenChange={setConfirmOpen}
    title="Delete project"
    description={`Remove ${item.name}? This cannot be undone.`}
    confirmLabel="Delete"
    variant="destructive"
    onConfirm={() => deleteMutation.mutate(customer.id)}
  />
</>

useCrudCreate / useCrudUpdate / useCrudDelete toast success/error via Sonner.

Search on list pages

const [search, setSearch] = useState("");
const { data, total, ...rest } = useCrudList<Project>("projects", {
  page: currentPage,
  pageSize: 10,
  sort: "createdAt",
  order: "desc",
  search: search || undefined,
});

<input
  type="search"
  placeholder="Search by name..."
  value={search}
  onChange={(e) => {
    setSearch(e.target.value);
    setCurrentPage(1);
  }}
/>

DX: Hooks useCrudList (supports search), useCrudSingle, useCrudCreate, useCrudUpdate, useCrudDelete. UI: DataTable, FormField, CrudPageLayout, Pagination, Skeleton, ConfirmDialog, ErrorBoundary.

Example in repo: /dashboard/admin (user management). Add your own lists under /dashboard/<feature>/ via the generator (§14).


6. Common patterns

Server vs client components

Server (default)Client ("use client")
Data fetch on server, requireAuth / requireRoleuseState, useQuery, browser APIs
No hooksClicks, forms, interactivity

API route handlers

export const GET = withAuthAndRateLimit(async (request: NextRequest) => {
  return NextResponse.json({ data: true });
});

When the handler needs user (ownership, user.id):

export const GET = withAuthUserAndRateLimit(async (_request, user) => {
  return NextResponse.json({ userId: user.id });
});

Permission-gated APIs (all permissions required):

export const GET = withPermissionAndRateLimit(["admin:users", "admin:roles"], async (_request, user) => {
  return NextResponse.json({ ok: true });
});

Permission-gated APIs (at least one permission):

export const GET = withPermissionAndRateLimit(
  ["admin:users", "admin:roles"],
  async (_request, user) => {
    return NextResponse.json({ ok: true });
  },
  { mode: "any" }
);

Role-based APIs (legacy):

export const GET = withRoleAndRateLimit(["admin"], async (_request, user) => {
  return NextResponse.json({ ok: true });
});
  • No session → 401. Wrong permissions/role → 403 { error: "Forbidden" }.
  • Permissions are defined in src/lib/rbac/permissions.ts (catalog) and src/lib/rbac/role-permissions.ts (static defaults). DB-driven roles are editable via /dashboard/admin.

IDOR: CRUD APIs are global (any authed user can access any row by id) by default. For per-user data, add userId and enforce in services on every query — see Ownership and per-user data.

Zod in handlers

const parsed = schema.safeParse(body);
if (!parsed.success) {
  return NextResponse.json(
    { error: parsed.error.errors[0]?.message ?? "Validation failed" },
    { status: 400 }
  );
}
// use parsed.data

Prefer parseBody from api-helpers where it fits.

Prisma

  • Import @/lib/server/prisma.
  • Only in server code (RSC, Server Actions, route handlers, services).

Dynamic route params (App Router)

params is a Promise — always await:

export async function GET(
  _request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
}

7. Adding navigation and sidebar items

Config: src/config/nav.ts (dashboardNavItems). Rendered by Sidebar.tsx.

{
  href?: string;
  label: string;
  icon: LucideIcon;
  requiredPermissions?: Permission[]; // optional; omit = all authenticated users (must have every listed permission)
  children?: NavItem[];
}

Add a link: import an icon from lucide-react, append an object to dashboardNavItems, create the page at href.

import { BarChart3 } from "lucide-react";
// …
{ href: "/dashboard/analytics", label: "Analytics", icon: BarChart3 },

Permission-only nav: requiredPermissions: ["admin:users"]. Canonical permissions: PERMISSIONS in src/lib/rbac/permissions.ts; role defaults: ROLE_PERMISSIONS in src/lib/rbac/role-permissions.ts.

Important: requiredPermissions on nav only hides the link. Always protect the route with requireAllPermissions, requireAnyPermission, or requireAuth.

Adding a new permission (end-to-end)

  1. Catalog: Add the permission string to PERMISSIONS in src/lib/rbac/permissions.ts.
  2. Defaults: Map it to built-in roles in ROLE_PERMISSIONS in src/lib/rbac/role-permissions.ts (or leave it empty to require custom role assignment).
  3. Page: await requireAllPermissions("your:permission") in the RSC or layout.
  4. API: withPermissionAndRateLimit(["your:permission"], handler) on the route.
  5. Nav: requiredPermissions: ["your:permission"] on the item.
  6. Custom roles: Admins can create roles with custom permission sets in the UI at /dashboard/admin → Roles tab (DB-driven).
LayerAction
Source of truthsrc/lib/rbac/permissions.ts (catalog) + src/lib/rbac/role-permissions.ts (static defaults) + DB Role, Permission, RolePermission tables
PagerequireAllPermissions / requireAnyPermission
APIwithPermissionAndRateLimit / withRoleAndRateLimit
NavrequiredPermissions on NavItem

9. TypeScript patterns (best practices)

Zod first: define schema, then z.infer<typeof schema>. App roles: import Role from @/lib/validation/user or @/lib/rbac — do not hand-roll role unions.

API shape: Success { data: T } or list { data: T[], total }. Error { error: string }. Use apiFetch / apiFetchList from @/lib/api.

Props: Prefer type Props = { … } over inline { x }: { x: X }.

Async: Return Promise<T> from async functions, not T.

Prisma types: Prisma.ModelGetPayload<{}> from @/generated/prisma instead of duplicating DB shapes. Dates in JSON responses are ISO strings at runtime.

TypeScript cheat sheet (JS → TS)

SyntaxMeaning
: TypeAnnotated type
as TypeAssertion
<T>Generic
?.Optional chaining
string | nullUnion

Common errors: Type 'X' is not assignable to 'Y' → wrong value or add narrow check; Object is possibly null?. or guard; async return → use Promise<…>.


10. Type definition placement guide

flowchart TD
    Start[New type needed] --> Q1{Backed by Prisma model?}
    Q1 -->|Yes| Validation[src/lib/validation/resource.ts]
    Q1 -->|No| Q2{Used in 2+ files?}
    Q2 -->|Yes| Q3{Cross-cutting?}
    Q2 -->|No| Local[Same file as component or config]
    Q3 -->|Yes| Shared[src/lib/types.ts]
    Q3 -->|No| Feature[src/lib/types/feature.ts or validation]
KindLocation
CRUD entitysrc/lib/validation/{resource}.ts
Form / API inputSame file as Zod schema (z.infer)
Shared, not Prismasrc/lib/types.ts
API wrappersrc/lib/api-types.ts
Component propsSame file as component
Config shapeSame file as config

Avoid: Duplicating Prisma models by hand; random type files; inline props types for complex components.


12. Adding Cloudflare R2 storage

Optional uploads (avatars, documents). Helpers: src/lib/server/r2.ts.

EnvPurpose
R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAMERequired when using R2
R2_PUBLIC_URLOptional; public base URL instead of presigned URLs
HelperUse
putObject(key, body, contentType?)Upload
deleteObject(key)Delete
getSignedUrlForKey(key, expiresIn?)Read URL

Example route snippet: use withAuthAndRateLimit, read FormData, putObject, return { data: { key, url } }. Add a handler at src/app/api/upload/route.ts (or your own path) when you implement uploads.

Security: Validate keys (no .., allowed prefixes). R2 code is server-only.


13. Adding Google sign-in

Not on by default. Use Better Auth social provider: Better Auth — Google.

  1. Google Cloud OAuth Web client; redirect URI like {APP_URL}/api/auth/callback/google.
  2. .env: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET; align APP_URL / NEXT_PUBLIC_APP_URL with production.
  3. Extend src/lib/server/better-auth.ts with socialProviders.google per docs.
  4. Client: signIn.social from src/lib/auth-client.ts on login/signup UI.

Keep the client secret server-only; use HTTPS in production.


<span id="14-consolidated-guide-adding-features"></span>

14. Consolidated guide: adding features

Generator (fastest)

Follow the manual checklist below.

Manual CRUD checklist

  1. Model in prisma/schema.prisma
  2. src/lib/validation/{resource}.ts (Zod + Prisma.*GetPayload)
  3. src/lib/server/services/{resource}.service.ts (business logic)
  4. src/app/api/{resources}/route.ts and [id]/route.ts (thin; use withAuthAndRateLimit, parseBody, validateCuid)
  5. Dashboard pages + form component
  6. src/config/nav.ts

Responses: { data } or { data, total }; errors { error } with appropriate status.

Hooks: useCrudList, useCrudSingle, useCrudCreate, useCrudUpdate, useCrudDelete — match API resource name string.

Protected pages

  • Any user: dashboard layout already requires auth; add src/app/dashboard/.../page.tsx + nav.
  • Permissions: await requireAllPermissions("admin:users") and nav requiredPermissions: ["admin:users"]. New permission → permission checklist in Navigation and sidebar.

Reusable pieces

ComponentUse
DataTableLists
FormFieldLabel + control + error
CrudPageLayoutCreate/edit chrome
PaginationList paging

File conventions

WhatPath pattern
Validationsrc/lib/validation/{resource}.ts
Servicesrc/lib/server/services/{resource}.service.ts
APIsrc/app/api/{resources}/route.ts, [id]/route.ts
Pagessrc/app/dashboard/...
Formssrc/components/dashboard/{Resource}Form.tsx

Also see docs/ADDING_FEATURES.md.


<span id="15-consolidated-guide-api-reference"></span>

15. Consolidated guide: API reference

JSON only. Auth: cookie session (Better Auth); same-origin requests include credentials.

Shape: Success { data: T } or { data: T[], total: number }. Error { error: string }.

Custom resources: Create /api/<plural> and /api/<plural>/[id] with the patterns below. Wrap with appropriate permission gates and tighten access for per-user data (§17).

GET /api/health

No auth. { status: "ok" }.

Better Auth (/api/auth/*)

Session and account endpoints are handled by Better Auth (see docs/API.md). Sign-out: POST /api/auth/sign-out.

Custom CRUD APIs

Authenticated + rate-limited (e.g. 100 req / 15 min per IP).

GET list query: _start, _end, _sort (e.g. createdAt, name), _order (asc/desc), _search (name contains, when enabled in service).

POST body: fields from your Zod schema. PUT/PATCH same. DELETE{ data: null } or 404.

Upload route (your implementation)

When you add R2 uploads, use a handler with multipart file, validate size/type, call putObject; 503 if R2 is not configured. Response shape { data: { key, url } }. See §12.

Error status codes

CodeTypical cause
400Validation / invalid CUID
401Not authenticated
403Wrong role
404Not found
429Rate limited
500Server error
503R2 (or similar) not configured

<span id="16-consolidated-guide-architecture-overview"></span>

16. Consolidated guide: architecture overview

flowchart TB
    subgraph client [Client]
        Browser[Browser]
    end
    subgraph next [Next.js]
        MW[Middleware]
        Layout[Layout]
        Page[Page]
        RH[Route Handler]
        SA[Server Action]
    end
    subgraph server [Server]
        Service[Services]
        Auth[Auth]
        Prisma[Prisma]
    end
    DB[(PostgreSQL)]
    Browser --> MW --> Layout --> Page
    Page --> RH
    Page --> SA
    RH --> Auth
    SA --> Auth
    Auth --> Service
    RH --> Service
    Service --> Prisma --> DB
flowchart TB
    Root[Root layout]
    Root --> Query[QueryProvider]
    Root --> Toaster[Toaster]
    Query --> Dash[Dashboard layout]
    Dash --> Sidebar[Sidebar]
    Dash --> Navbar[Navbar]
    Dash --> Content[Page]

CRUD flow: Client hooks → route handler (withAuthAndRateLimit) → service → Prisma.

Auth: Login/signup server actions validate input, rate limit, Argon2 verify / hash, Better Auth session in DB, cookie set.

Folder layout matches Project structure above.


<span id="17-ownership-and-scoped-data"></span>

17. Ownership and per-user data

Default generated CRUD is global (no per-user filter). For per-user rows:

  1. Schema: userId String + user User @relation(...) + @@index([userId]).
  2. Service: Every findMany / findFirst / update / delete includes where: { userId } (or another ownership field you introduce on the model).
  3. API: Use withAuthUserAndRateLimit, pass user.id into service methods — avoid fetching user again inside a generic withAuth wrapper.
export const GET = withAuthUserAndRateLimit(async (_request, user) => {
  const { items, total } = await listProjects(options, user.id);
  return NextResponse.json({ data: items, total });
});
  1. Create: data: { ...fields, userId: user.id }.

Optional: extend createCrudService with a where filter factory for generated CRUD.


<span id="18-consolidated-guide-prisma-relationships"></span>

18. Prisma one-to-many relationships

1. Schema — parent has children Child[], child has parentId + @relation.

2. Migrate / push schema changes.

3. Validationsrc/lib/validation/child.ts with Zod + Prisma.ChildGetPayload.

4. Service + APIs — list with include: { parent: { select: { … } } } as needed.

5. Forms — e.g. useCrudList on parents, <select> for foreign key.

6. Tables — column cell reads nested relation.field.


<span id="19-consolidated-guide-ai-prompting-guide"></span>

19. Consolidated guide: AI prompting guide

This repo includes .cursorrules, AGENTS.md, and .cursor/rules/*.mdc. Point assistants at those files; prefer small, single-area tasks.

Do not rip out Better Auth or replace the stack without a deliberate plan. Prefer extending src/lib/server/better-auth.ts and existing patterns.

After Prisma edits: run npx prisma generate and db push or migrate dev locally.

Prompt templates (copy and edit)

New CRUD resource

Add a CRUD resource [Name]. Follow .cursor/rules/create-crud.mdc.
- prisma/schema.prisma fields: [list]
- Add `userId` (or another ownership foreign key) unless this is intentionally global data.
- Zod in src/lib/validation, service in src/lib/server/services, thin routes with withAuthAndRateLimit and parseBody/validateCuid.
- Dashboard list + create/edit with shadcn/ui, DataTable, existing hooks.
- Protect with permission gates: withPermissionAndRateLimit(["resource:read"], ...) and nav requiredPermissions: ["resource:read"].

Non-CRUD feature

Build [description]. No new Prisma model unless necessary.
Use a server action or route handler calling code under src/lib/server/services; client UI in src/components/dashboard/...
Protect pages with requireAllPermissions(...) and APIs with withPermissionAndRateLimit(...). Match ui-components.mdc spacing and hierarchy.

UI polish

Refactor [path]: align with ui-components.mdc — spacing (gap-4/gap-6), border-border, muted secondary text, hover states. Fix layout bugs without changing behavior.

Debug

Error when [action]: [paste log]. Check troubleshooting.mdc and backend-security.mdc — await params in App Router, Zod errors, CUID validation, permission/role checks in guards.

20. Troubleshooting and deployment checks

npm ERESOLVE: use package.json overrides like the existing repo pattern; align peer versions.

Before push / deploy:

npm run check

(npm run ci:check is the same script.) Covers dependency dry-run, peer deps, Prisma generate, ESLint, TypeScript, Vitest (--run), Prisma validate, CI=true next build, and high/critical audit output—see scripts/test-deploy-clean.sh and AGENTS.md.


Where to look next

DocPurpose
AGENTS.mdFull architecture and agent contract
CONTRIBUTING.mdLocal dev and workflow
docs/ADDING_FEATURES.mdShort entry → Adding features
Prisma docsSchema and queries
Next.js App RouterRouting and RSC