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
| Variable | Required | Notes |
|---|---|---|
DATABASE_URL | Yes | PostgreSQL URL |
BETTER_AUTH_SECRET | Yes | Signing secret |
APP_URL | No | Defaults to http://localhost:3000 |
BETTER_AUTH_URL | No | Defaults to APP_URL |
SMTP_* | No | Without SMTP in dev, mail is logged |
R2_* | No | File uploads — Cloudflare R2 |
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | No | Google sign-in |
NEXT_PUBLIC_APP_URL | No | Client / OAuth base URL in production |
ADMIN_EMAIL / ADMIN_PASSWORD | No | Seed 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.
| Area | Boundary |
|---|---|
| Billing | No subscription, invoice, or payment-provider integration in the schema or routes. Add Stripe (or similar) and entitlements when you need paid plans. |
| Per-user isolation | CRUD 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
| Task | Where / pattern |
|---|---|
| Any logged-in user (page) | await requireAuth() — dashboard layout already enforces auth |
| Permission-gated page | await 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 limit | withAuthAndRateLimit((req) => …) from @/lib/server/api-helpers |
API: need user in handler | withAuthUserAndRateLimit((req, user) => …) |
| API: permission + rate limit | withPermissionAndRateLimit(["admin:users"], (req, user) => …) |
| API: permission (any of) | withPermissionAndRateLimit(["admin:users", "admin:roles"], (req, user) => …, { mode: "any" }) |
| API: role + rate limit | withRoleAndRateLimit(["admin"], (req, user) => …) |
| Validate body | parseBody(schema, body) |
Validate [id] | validateCuid(id) |
| List pagination query | parsePagination(searchParams) |
| Business logic | src/lib/server/services/*.service.ts — not in route files |
| Prisma | @/lib/server/prisma only; server code only |
| New CRUD | Add Prisma model, service, API routes, and pages manually |
| Nav / sidebar | src/config/nav.ts → dashboardNavItems |
| Permissions catalog | src/lib/rbac/permissions.ts — canonical list |
| Role → permission map | src/lib/rbac/role-permissions.ts — static defaults |
| DB-driven RBAC | Role, Permission, RolePermission tables (seeded with built-in roles) |
| Toasts | toast from sonner |
| Destructive confirm | ConfirmDialog from @/components/ui |
| Legacy demo cleanup | Removing 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 / area | Role |
|---|---|
src/lib/server/auth.ts | getAuthUser, requireAuth, requireRole |
src/lib/server/api-helpers.ts | withAuth*, withRole*, parseBody, parsePagination, validateCuid |
src/lib/server/prisma.ts | Singleton Prisma client |
src/lib/server/services/ | Services; route handlers stay thin |
src/app/dashboard/layout.tsx | Dashboard shell + auth |
5. Implementation examples
New page (permission-gated)
- Add
src/app/dashboard/my-feature/page.tsx. - Call
requireAllPermissions(...)at the top with required permissions. - 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>
);
}
| Need | Use |
|---|---|
| Any logged-in user | requireAuth() |
| All of these permissions | requireAllPermissions("admin:users", "admin:roles") |
| At least one permission | requireAnyPermission("admin:users", "admin:roles") |
| Legacy: admin only | requireRole(["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
- API
src/app/api/my-data/route.ts— return{ data }on success. - Client component with
"use client"+useQuery+apiFetch. - 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 / requireRole | useState, useQuery, browser APIs |
| No hooks | Clicks, 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) andsrc/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)
- Catalog: Add the permission string to
PERMISSIONSinsrc/lib/rbac/permissions.ts. - Defaults: Map it to built-in roles in
ROLE_PERMISSIONSinsrc/lib/rbac/role-permissions.ts(or leave it empty to require custom role assignment). - Page:
await requireAllPermissions("your:permission")in the RSC or layout. - API:
withPermissionAndRateLimit(["your:permission"], handler)on the route. - Nav:
requiredPermissions: ["your:permission"]on the item. - Custom roles: Admins can create roles with custom permission sets in the UI at
/dashboard/admin→ Roles tab (DB-driven).
| Layer | Action |
|---|---|
| Source of truth | src/lib/rbac/permissions.ts (catalog) + src/lib/rbac/role-permissions.ts (static defaults) + DB Role, Permission, RolePermission tables |
| Page | requireAllPermissions / requireAnyPermission |
| API | withPermissionAndRateLimit / withRoleAndRateLimit |
| Nav | requiredPermissions 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)
| Syntax | Meaning |
|---|---|
: Type | Annotated type |
as Type | Assertion |
<T> | Generic |
?. | Optional chaining |
string | null | Union |
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]
| Kind | Location |
|---|---|
| CRUD entity | src/lib/validation/{resource}.ts |
| Form / API input | Same file as Zod schema (z.infer) |
| Shared, not Prisma | src/lib/types.ts |
| API wrapper | src/lib/api-types.ts |
| Component props | Same file as component |
| Config shape | Same 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.
| Env | Purpose |
|---|---|
R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME | Required when using R2 |
R2_PUBLIC_URL | Optional; public base URL instead of presigned URLs |
| Helper | Use |
|---|---|
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.
- Google Cloud OAuth Web client; redirect URI like
{APP_URL}/api/auth/callback/google. .env:GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET; alignAPP_URL/NEXT_PUBLIC_APP_URLwith production.- Extend
src/lib/server/better-auth.tswithsocialProviders.googleper docs. - Client:
signIn.socialfromsrc/lib/auth-client.tson 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
- Model in
prisma/schema.prisma src/lib/validation/{resource}.ts(Zod +Prisma.*GetPayload)src/lib/server/services/{resource}.service.ts(business logic)src/app/api/{resources}/route.tsand[id]/route.ts(thin; usewithAuthAndRateLimit,parseBody,validateCuid)- Dashboard pages + form component
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 navrequiredPermissions: ["admin:users"]. New permission → permission checklist in Navigation and sidebar.
Reusable pieces
| Component | Use |
|---|---|
DataTable | Lists |
FormField | Label + control + error |
CrudPageLayout | Create/edit chrome |
Pagination | List paging |
File conventions
| What | Path pattern |
|---|---|
| Validation | src/lib/validation/{resource}.ts |
| Service | src/lib/server/services/{resource}.service.ts |
| API | src/app/api/{resources}/route.ts, [id]/route.ts |
| Pages | src/app/dashboard/... |
| Forms | src/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
| Code | Typical cause |
|---|---|
| 400 | Validation / invalid CUID |
| 401 | Not authenticated |
| 403 | Wrong role |
| 404 | Not found |
| 429 | Rate limited |
| 500 | Server error |
| 503 | R2 (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:
- Schema:
userId String+user User @relation(...)+@@index([userId]). - Service: Every
findMany/findFirst/update/deleteincludeswhere: { userId }(or another ownership field you introduce on the model). - API: Use
withAuthUserAndRateLimit, passuser.idinto service methods — avoid fetchinguseragain inside a genericwithAuthwrapper.
export const GET = withAuthUserAndRateLimit(async (_request, user) => {
const { items, total } = await listProjects(options, user.id);
return NextResponse.json({ data: items, total });
});
- 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. Validation — src/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
| Doc | Purpose |
|---|---|
| AGENTS.md | Full architecture and agent contract |
| CONTRIBUTING.md | Local dev and workflow |
| docs/ADDING_FEATURES.md | Short entry → Adding features |
| Prisma docs | Schema and queries |
| Next.js App Router | Routing and RSC |