A minimal, production-ready Next.js starter with modern tooling and authentication wired up — no demo content, ready to build on.
- Next.js 16 (App Router, React 19, Turbopack)
- TypeScript + Tailwind CSS v4
- bun package manager
- @t3-oss/env-nextjs type-safe environment variables
- Biome + ultracite for linting/formatting
- shadcn/ui component library
- better-auth authentication (email/password, extendable)
- Drizzle ORM + PostgreSQL
- oRPC type-safe API layer
- TanStack Query v5 data fetching
- TanStack DB client-side collections
- Zustand global state (with persist)
- React Hook Form + Zod validation
# Clone and install
git clone https://github.com/whyleonardo/nextjs-boilerplate.git
cd nextjs-boilerplate
bun install
# Set up environment
cp .env.example .env.local
# Edit .env.local — set BETTER_AUTH_SECRET to a random 32+ char string
# Start PostgreSQL
docker compose up -d
# Push database schema
bun run db:push
# Start dev server
bun run devVisit http://localhost:3000
src/
instrumentation.ts # Initialises oRPC server client at startup
env.ts # Type-safe env vars (@t3-oss/env-nextjs + Zod)
app/
layout.tsx # Root layout — imports Providers
page.tsx # Home page
providers.tsx # QueryClientProvider + ReactQueryDevtools
globals.css # Tailwind v4 + shadcn CSS variables
(api)/ # Route group — no layout wrapping
api/
[[...rest]]/route.ts # OpenAPI / Scalar UI handler
auth/[...all]/route.ts # better-auth handler
rpc/[[...rest]]/route.ts # oRPC RPC handler
(auth)/ # Route group — guest-only
layout.tsx # requireGuest() guard
sign-in/page.tsx
sign-up/page.tsx
(dashboard)/
dashboard/page.tsx # Protected page — requireSession() guard
features/ # Feature-based modules
auth/
components/
sign-in-form.tsx
sign-up-form.tsx
todo/ # Reference feature implementation
contracts.ts # API contract (schemas + HTTP metadata)
collections.ts # TanStack DB collection (query-db bridge)
procedures/
list.ts
index.ts # Assembles and exports the router slice
components/
auth/
user-button.tsx # Avatar dropdown with sign-out
ui/ # shadcn/ui primitives
avatar.tsx
button.tsx
card.tsx
dropdown-menu.tsx
form.tsx
input.tsx
label.tsx
separator.tsx
skeleton.tsx
sonner.tsx
lib/
auth/
client.ts # better-auth React client
session.ts # getSession / requireSession / requireGuest
orpc/
client.ts # oRPC client + TanStack Query utils (browser)
server.ts # oRPC direct router client (server-only)
serializer.ts # StandardRPCJsonSerializer for SSR hydration
query/
client.ts # createQueryClient / getQueryClient singleton
hydration.tsx # HydrateClient + per-request getQueryClient
utils.ts # cn() — clsx + tailwind-merge
server/
auth/index.ts # betterAuth config (Drizzle adapter, email/password)
db/
index.ts # Drizzle client (postgres-js)
schema/
auth.ts # user, session, account, verification tables
index.ts # Barrel re-export
rpc/
index.ts # Central router + AppRouter type
middleware.ts # publicProcedure / authMiddleware / protectedProcedure
store/
app-store.ts # Zustand store (persisted to localStorage)
Environment variables are validated at build time using @t3-oss/env-nextjs and Zod schemas.
All environment variable definitions live in src/env.ts:
- Server variables: Only accessible server-side (e.g.,
DATABASE_URL,BETTER_AUTH_SECRET) - Client variables: Must be prefixed with
NEXT_PUBLIC_(e.g.,NEXT_PUBLIC_APP_URL)
Adding a new environment variable:
- Add to
.env.exampleand.env.local - Add schema to
src/env.ts(server or client section) - Add to
runtimeEnvobject insrc/env.ts
Import and use: import { env } from "@/env"
Server-side route guards live in Server Components — no middleware.
Protect a page:
import { requireSession } from "@/lib/auth/session";
export default async function ProtectedPage() {
const { user } = await requireSession(); // Redirects to /sign-in if not authenticated
return <div>Hello {user.email}</div>;
}Redirect authenticated users away from auth pages:
import { requireGuest } from "@/lib/auth/session";
export default async function AuthLayout({ children }) {
await requireGuest(); // Redirects to /dashboard if already signed in
return <div>{children}</div>;
}bun run db:generate # Generate migration from schema changes
bun run db:migrate # Run migrations
bun run db:push # Push schema to DB (dev only)
bun run db:studio # Open Drizzle StudioSchema files live in src/server/db/schema/. The auth.ts schema is auto-generated by better-auth.
The API layer uses a contract-first pattern. Each feature owns its contract (schema + HTTP metadata) and its procedure implementations. The central router in src/server/rpc/index.ts only wires features together.
src/features/<name>/
contracts.ts # API shape: schemas + HTTP route metadata (no logic)
procedures/
<procedure>.ts # One file per procedure
index.ts # Assembles and exports the router slice
// src/features/todo/contracts.ts
import { oc } from "@orpc/contract";
import { z } from "zod";
export const TodoSchema = z.object({
id: z.string(),
title: z.string(),
});
export type Todo = z.infer<typeof TodoSchema>;
export const todoContract = {
list: oc
.route({ method: "GET", path: "/todos", summary: "List all todos" })
.output(z.array(TodoSchema)),
create: oc
.route({ method: "POST", path: "/todos", summary: "Create a todo" })
.input(z.object({ title: z.string().min(1).max(255) }))
.output(TodoSchema),
};// src/features/todo/procedures/list.ts
import { implement } from "@orpc/server";
import { todoContract } from "@/features/todo/contracts";
import { authMiddleware } from "@/server/rpc/middleware";
const os = implement(todoContract);
export const listTodos = os.list.use(authMiddleware).handler(() => {
return []; // replace with a real query
});// src/features/todo/procedures/create.ts
import { implement } from "@orpc/server";
import { type Todo, todoContract } from "@/features/todo/contracts";
import { authMiddleware } from "@/server/rpc/middleware";
const os = implement(todoContract);
export const createTodo = os.create.use(authMiddleware).handler(({ input }) => {
const todo: Todo = { id: crypto.randomUUID(), title: input.title };
return todo;
});// src/features/todo/procedures/index.ts
import { listTodos } from "./list";
import { createTodo } from "./create";
export const todo = {
list: listTodos,
create: createTodo,
};// src/server/rpc/index.ts
import { todo } from "@/features/todo/procedures";
export const router = {
todo,
};
export type AppRouter = typeof router;Two procedure bases are available from src/server/rpc/middleware.ts:
| Export | When to use |
|---|---|
publicProcedure |
No auth required |
authMiddleware |
Use with implement() — gates the handler behind a valid session |
protectedProcedure |
Builder-style alternative to authMiddleware |
"use client";
import { orpc } from "@/lib/orpc/client";
import { useQuery, useMutation } from "@tanstack/react-query";
export function Todos() {
const { data } = useQuery(orpc.todo.list.queryOptions());
const create = useMutation(orpc.todo.create.mutationOptions());
return (
<div>
{data?.map(t => <p key={t.id}>{t.title}</p>)}
<button onClick={() => create.mutate({ title: "New todo" })}>Add</button>
</div>
);
}// No HTTP round-trip — calls the router directly on the server
import "@/lib/orpc/server";
export default async function TodosPage() {
const todos = await globalThis.$client.todo.list();
return <ul>{todos.map(t => <li key={t.id}>{t.title}</li>)}</ul>;
}TanStack DB sits on top of TanStack Query and provides a reactive, client-side collection store. The bridge between them is @tanstack/query-db-collection, which maps a query key directly to a live collection — so any mutation that invalidates the query automatically syncs the collection.
oRPC procedure ──► queryKey / queryFn ──► TanStack Query cache ──► TanStack DB collection
▲
mutations invalidate here
Put collections next to the feature they belong to:
// src/features/todo/collections.ts
import { queryCollectionOptions } from "@tanstack/query-db-collection";
import { createCollection } from "@tanstack/react-db";
import { orpc } from "@/lib/orpc/client";
import { getQueryClient } from "@/lib/query/client";
export const todosCollection = createCollection(
queryCollectionOptions({
syncMode: "on-demand", // Fetch only when something subscribes
queryKey: orpc.todo.list.queryKey(), // Shares the same cache entry as useQuery(orpc.todo.list.queryOptions())
queryFn: async () => await orpc.todo.list.call(),
queryClient: getQueryClient(), // Browser singleton — same instance as QueryClientProvider
getKey: (item) => item.id, // Primary key for TanStack DB's internal map
}),
);Key points:
queryKeymust match the key used byuseQueryso both share the same cache entry.getQueryClient()fromsrc/lib/query/client.tsreturns the browser singleton — the same instance wrapped by<QueryClientProvider>.syncMode: "on-demand"means data is only fetched when the collection is actually used (no background polling on mount).
Use useLiveQuery from @tanstack/react-db. It accepts a query builder function (q) that describes what to fetch from the collection, and returns reactive data, isLoading, and isError flags.
Basic — all todos:
"use client";
import { useLiveQuery } from "@tanstack/react-db";
import { todosCollection } from "@/features/todo/collections";
export function TodoList() {
const { data: todos, isLoading } = useLiveQuery((q) =>
q.from({ todos: todosCollection })
);
if (isLoading) return <p>Loading...</p>;
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}With where filter and select projection:
"use client";
import { useLiveQuery, eq } from "@tanstack/react-db";
import { todosCollection } from "@/features/todo/collections";
export function IncompleteTodos() {
const { data: todos, isLoading, isError } = useLiveQuery((q) =>
q
.from({ todos: todosCollection })
.where(({ todos }) => eq(todos.completed, false))
.select(({ todos }) => ({ id: todos.id, title: todos.title }))
);
if (isLoading) return <p>Loading...</p>;
if (isError) return <p>Something went wrong.</p>;
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}With reactive deps — re-runs the query when a variable changes:
"use client";
import { useLiveQuery, eq } from "@tanstack/react-db";
import { useState } from "react";
import { todosCollection } from "@/features/todo/collections";
export function FilteredTodos() {
const [showCompleted, setShowCompleted] = useState(false);
const { data: todos } = useLiveQuery(
(q) =>
q
.from({ todos: todosCollection })
.where(({ todos }) => eq(todos.completed, showCompleted)),
[showCompleted] // Re-runs when showCompleted changes
);
return (
<>
<button onClick={() => setShowCompleted((v) => !v)}>
Show {showCompleted ? "incomplete" : "completed"}
</button>
<ul>
{todos?.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</>
);
}| Scenario | Recommended |
|---|---|
| Simple list display, no client-side filtering | useQuery(orpc.todo.list.queryOptions()) |
| Client-side filter / projection without a round-trip | useLiveQuery with .where() / .select() |
| Reactive query that depends on local state | useLiveQuery with deps array |
| Optimistic UI with local writes | TanStack DB (built-in optimisticUpdate support) |
| SSR prefetch + hydration | useQuery + HydrateClient (collections are client-only) |
The API automatically generates OpenAPI 3.x specification from your oRPC router.
Interactive API documentation (Scalar UI):
- Development:
http://localhost:3000/api - Production:
https://your-domain.com/api - Or visit
/api/openapiin a browser (auto-detects HTML preference)
Raw OpenAPI spec (JSON):
/api/openapi— JSON specification (default for API clients)/api/openapi?format=json— Explicit JSON format
Use the spec with:
- Postman: Import
/api/openapiURL - API clients: Generate SDKs using openapi-generator
- Custom tools: Consume the OpenAPI JSON directly
The spec is auto-generated from your Zod schemas and contract definitions in src/features/.
bun run dev # Start dev server
bun run build # Production build
bun run start # Start production server
bun run lint # Biome check (via ultracite)
bun run fix # Biome fix (via ultracite)- Email/password authentication (extendable to OAuth, magic links, etc.)
- Server-side session management
- Protected route example (
/dashboard) - Auth pages with client-side forms
- Type-safe API client (oRPC + TanStack Query)
- Database migrations + Drizzle Studio
- shadcn/ui components (button, card, input, form, dropdown, avatar, etc.)
- Minimal Zustand store with localStorage persistence
- Docker Compose PostgreSQL setup
This is infrastructure, not a demo app. No landing page, no todo list, no dashboard layout. Build your own features on top.
MIT