The template is Server-Component- and Server-Action-first: most data flow goes through React Server Components (reads) and Server Actions (writes), not HTTP endpoints. The only HTTP routes are the cache-revalidation hook and the Better Auth handler.
Request/response shapes for the domain are defined as Zod schemas in
packages/types/src/index.ts.
apps/web/app/api/revalidate/route.ts. Lets the admin app invalidate the public
app's Next.js cache tags after a mutation. Bearer-authenticated with the shared
REVALIDATE_SECRET.
Auth: Authorization: Bearer <REVALIDATE_SECRET>. Returns 401 on
mismatch, 500 if the secret isn't configured on the server.
Request body
{ "tags": ["public-items", "item:<id>"] }public-items— the list tag (invalidates the items list).item:<id>— a per-detail tag (invalidates one item's detail page). Must match^item:[\w-]+$.
At least one tag is required. Unknown tags that don't match a known constant or
the item:<id> pattern are rejected with 400.
Response 200
{ "revalidated": true, "tags": ["public-items"] }Response 400 / 401 / 500
{ "error": "Invalid body. Expected { tags: string[] }", "knownTags": ["public-items"] }
{ "error": "Unauthorized" }
{ "error": "Revalidation is not configured" }The admin side calls this from apps/admin/lib/revalidate-web.ts after a
mutation (fire-and-forget via after()), only when both NEXT_PUBLIC_WEB_URL
and REVALIDATE_SECRET are set.
apps/admin/app/api/auth/[...all]/route.ts mounts the full Better Auth surface.
See https://better-auth.com/docs for the complete list; the most-used routes:
| Method | Path | Purpose |
|---|---|---|
| POST | /api/auth/sign-up/email |
Create account with email + password |
| POST | /api/auth/sign-in/email |
Sign in with email + password |
| POST | /api/auth/sign-in/social |
Initiate OAuth flow (Google) |
| GET | /api/auth/callback/:provider |
OAuth callback URL |
| POST | /api/auth/sign-out |
Revoke current session |
| GET | /api/auth/get-session |
Read the current session |
| POST | /api/auth/admin/list-users |
(admin) list users |
| POST | /api/auth/admin/ban-user |
(admin) ban a user |
| POST | /api/auth/admin/remove-user |
(admin) delete a user |
In app code, use the typed clients rather than fetching these directly:
// Client component
import { signIn, signOut, authClient } from "@repo/auth/client";
// Server component / route handler / action
import { requireAdmin } from "@repo/auth/next";Mutations are Server Actions, colocated with their route under
apps/admin/app/(dashboard)/. Each guards with requireAdmin(), validates with
a Zod schema from @repo/types, writes via @repo/db, and invalidates the
relevant cache tag.
| Action | Signature | Notes |
|---|---|---|
createItemAction |
(input: ItemInput) |
Validates itemInputSchema; sets owner to the current user; redirects to /items/<id> |
updateItemAction |
(id: string, input: ItemInput) |
Validates and updates |
deleteItemAction |
(id: string) |
Soft delete (deletedAt); redirects to /items |
All three invalidate CACHE_TAGS.items via updateTag(...) so the dashboard
summary and list reads refresh.
ItemInput (itemInputSchema):
title— 2–200 chars, requireddescription— ≤ 2000 chars, optional / nullablestatus—draft | active | archived(defaults todraft)
inviteUserAction, banUserAction, unbanUserAction, removeUserAction,
resetUserPasswordAction — thin wrappers over Better Auth's admin API. See
Authentication — user management.
revokeMySessionAction(token) — revoke one of the current user's own device
sessions.
Public and admin pages read through the typed helpers in
packages/db/src/queries/ (re-exported from @repo/db):
listItems(filters)/getItemById(id)— public reads (soft-deleted excluded)listItemsPaginated(filters, page)— admin list (returnsPaginated<Item>)getAdminSummary()— dashboard per-status counts
The public app wraps these in apps/web/lib/db.ts, which adds Next.js caching
("use cache" + cacheTag) and the DB-less fallback to @repo/db/seed-data.
If you do need a real HTTP route (a webhook, an integration callback, a public JSON API), follow the existing patterns:
- Validate input with a Zod schema; return
{ error, issues? }on failure. - Gate protected routes with
requireAdmin()(it redirects on failure). - Type any
$queryRawresult with a row generic — neverany. - Document the new endpoint here.
// apps/web/app/api/example/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";
const querySchema = z.object({ status: z.enum(["draft", "active", "archived"]).optional() });
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const parsed = querySchema.safeParse(Object.fromEntries(searchParams));
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid query", issues: parsed.error.flatten() },
{ status: 400 },
);
}
// ... read via @repo/db
return NextResponse.json({ data: [] });
}