Application name: organizer
Build a full-stack admin dashboard named Organizer for managing Users, Organizations, Teams, and Activities, with attendance tracking. The application uses better-auth for authentication including its built-in organization, team, and admin support. All UI — dashboard layouts, data tables, forms, dialogs, and navigation — must be built exclusively with shadcn/ui components. User images are stored on Cloudflare R2.
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router, latest) |
| Package Manager | pnpm |
| Auth | better-auth (organization + team + admin plugins) |
| Architecture | Feature-Sliced Design (FSD) in ./src |
| Form Handling | react-hook-form + zod |
| UI Library | shadcn/ui (all UI components) |
| Database ORM | Drizzle ORM |
| File Storage | Cloudflare R2 |
| Styling | Tailwind CSS v4 |
The Next.js App Router lives at the root of the project. All FSD architecture code lives under ./src.
/ # project root
├── app/ # ← Next.js App Router (root, NOT inside src/)
│ ├── (auth)/
│ │ ├── sign-in/page.tsx
│ │ └── sign-up/page.tsx
│ ├── activity/ # ← public attendance page (semi-protected)
│ │ └── page.tsx
│ ├── join/ # ← public join-request page (semi-protected)
│ │ └── [slug]/
│ │ └── page.tsx
│ ├── dashboard/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── profile/
│ │ ├── users/
│ │ ├── organizations/
│ │ ├── activities/
│ │ └── explore/
│ ├── api/
│ │ └── auth/[...all]/route.ts
│ ├── lib/
│ │ ├── auth.ts # ← better-auth config
│ │ ├── auth-client.ts # ← better-auth client
│ │ └── permissions.ts # ← better-auth access control
│ ├── layout.tsx
│ └── proxy.ts # ← route protection proxy
├── src/ # ← All FSD layers live here
│ ├── widgets/
│ ├── features/
│ ├── entities/
│ └── shared/
├── drizzle/ # migrations output
├── drizzle.config.ts
└── proxy.ts # ← Next.js 16 route protection (replaces middleware.ts)
src/
├── widgets/ # Composite UI blocks (Sidebar, Header, DataTable)
├── features/ # User-facing features — NO "management" suffix
│ ├── auth/
│ ├── user/
│ ├── organization/
│ ├── team/
│ ├── activity/
│ └── attendance/
├── entities/ # Business entities (models, types, UI atoms)
│ ├── user/
│ ├── organization/
│ ├── team/
│ └── activity/
└── shared/ # Reusable utilities, UI primitives, config
├── ui/
│ ├── shadcn/ # shadcn-installed components (Button, Card, etc.)
│ └── common/
│ ├── utils/ # shadcn cn() utility (utils.ts)
│ └── hooks/ # shadcn hooks (use-mobile.ts, etc.)
├── db/ # Drizzle client + schema (see Database section)
├── utils/ # shared utilities
│ ├── r2.ts # Cloudflare R2 client
│ ├── zod.ts # zod configured with id locale
│ ├── drizzle-zod.ts # createSchemaFactory wrapper
│ └── format-name.ts # formatDisplayName() helper
├── config/ # env, constants
└── types/ # global TypeScript types
Each FSD slice internal structure:
feature-name/
├── ui/ # React components (shadcn-based)
├── model/ # hooks, zod schemas
├── api/ # server actions
└── index.ts # public API (barrel export)
Naming rule: Feature folders use short domain names —
user,organization,team,activity. No-managementsuffix anywhere.
// app/lib/auth.ts
import * as dotenv from "dotenv"
dotenv.config() // load .env before accessing process.env
import { betterAuth } from "better-auth"
import { drizzleAdapter } from "better-auth/adapters/drizzle"
import { organization } from "better-auth/plugins"
import { admin } from "better-auth/plugins/admin"
import { db } from "@/shared/db"
import * as schema from "@/shared/db/schema"
import { v7 as uuidv7 } from "uuid"
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
usePlural: true, // camelCase mapping for Drizzle schema
}),
advanced: {
generateId: () => uuidv7(),
},
// Email/password — disabled in production, use Google OAuth instead
emailAndPassword: {
enabled: process.env.NODE_ENV !== "production",
},
// Social providers
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
mapProfileToUser: (profile) => ({
name: profile.name,
image: profile.picture,
email: profile.email,
}),
},
},
plugins: [
organization({
teams: {
enabled: true,
},
}), // org + team support
admin(), // system-level admin role
],
user: {
additionalFields: {
nickName: { type: "string", required: false },
honorifics: { type: "string", required: false },
postNominalLetters: { type: "string", required: false },
dateOfBirth: { type: "date", required: false },
placeOfBirth: { type: "string", required: false },
gender: { type: "string", required: false },
education: { type: "string", required: false },
}
}
})// app/lib/auth-client.ts
import { createAuthClient } from "better-auth/react"
import { organizationClient } from "better-auth/client/plugins"
import { adminClient } from "better-auth/client/plugins"
export const authClient = createAuthClient({
plugins: [
organizationClient({
teams: true, // enable team support in client
}),
adminClient(),
],
})Define all org-level permissions in one place using better-auth's createAccessControl helper. Import this in server actions to check roles before mutations.
// app/lib/permissions.ts
import { createAccessControl } from "better-auth/plugins/access"
import { defaultStatements, adminAc } from "better-auth/plugins/admin/access"
export const ac = createAccessControl({
...defaultStatements,
organization: ["create", "update", "delete", "read"],
team: ["create", "update", "delete", "read"],
activity: ["create", "update", "delete", "read"],
attendance: ["create", "read"],
joinRequest: ["create", "approve", "reject", "read"],
member: ["invite", "remove", "update"],
})
// Role definitions
export const systemAdmin = ac.newRole({
organization: ["create", "update", "delete", "read"],
team: ["create", "update", "delete", "read"],
activity: ["create", "update", "delete", "read"],
attendance: ["create", "read"],
joinRequest: ["create", "approve", "reject", "read"],
member: ["invite", "remove", "update"],
})
export const orgOwner = ac.newRole({
organization: ["update", "delete", "read"],
team: ["create", "update", "delete", "read"],
activity: ["create", "update", "delete", "read"],
attendance: ["create", "read"],
joinRequest: ["approve", "reject", "read"],
member: ["invite", "remove", "update"],
})
export const orgAdmin = ac.newRole({
organization: ["read"],
team: ["create", "update", "delete", "read"],
activity: ["create", "update", "delete", "read"],
attendance: ["create", "read"],
joinRequest: ["approve", "reject", "read"],
member: ["invite", "remove"],
})
export const orgMember = ac.newRole({
organization: ["read"],
team: ["read"],
activity: ["read"],
attendance: ["create", "read"],
joinRequest: ["create", "read"],
member: [],
})Import
ac,orgAdmin,orgOwner, etc. in server actions to guard mutations:const canCreate = await ac.authorize(session.user.role, "activity", "create") if (!canCreate.success) throw new Error("Forbidden")
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=- Google OAuth sign-in (all environments)
- Email/password sign-in (non-production only)
- Session management via
auth.api.getSession() - Protected routes via
proxy.ts(see Route Protection section) - System roles:
user,admin(via better-auth admin plugin) - Org-level roles:
owner,admin,member
Use the Next.js 16 proxy.ts feature for centralized route protection. Place it at the project root — no middleware.ts required. Next.js 16 picks up proxy.ts automatically as the request interceptor.
// proxy.ts (project root)
import { auth } from "@/app/lib/auth"
import { NextRequest, NextResponse } from "next/server"
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
// Public routes — no session required
const publicRoutes = ["/sign-in", "/sign-up", "/api/auth"]
if (publicRoutes.some(r => pathname.startsWith(r))) {
return NextResponse.next()
}
// Fetch session
const session = await auth.api.getSession({
headers: request.headers,
})
// Unauthenticated — redirect to sign-in with referer param
if (!session) {
const signInUrl = new URL("/sign-in", request.url)
// Encode the intended destination as a search param
// e.g. /activity → /sign-in?referer=activity
const referer = pathname.replace(/^\//, "") // strip leading slash
signInUrl.searchParams.set("referer", referer)
return NextResponse.redirect(signInUrl)
}
// Admin-only routes
const adminRoutes = ["/dashboard/users", "/dashboard/organizations/new"]
if (adminRoutes.some(r => pathname.startsWith(r))) {
if (session.user.role !== "admin") {
return NextResponse.redirect(new URL("/dashboard", request.url))
}
}
return NextResponse.next()
}On the sign-in page, after a successful login read referer from the search params and redirect the user back to that route:
// app/(auth)/sign-in/page.tsx (client component excerpt)
import { useRouter, useSearchParams } from "next/navigation"
import { authClient } from "@/app/lib/auth-client"
const searchParams = useSearchParams()
const router = useRouter()
async function handleSignIn() {
await authClient.signIn.social({ provider: "google" })
// After OAuth callback, read referer and navigate back
const referer = searchParams.get("referer")
router.replace(referer ? `/${referer}` : "/dashboard")
}For Google OAuth (which uses a server-side callback), persist the
referervalue through the OAuth state param or a short-lived cookie so the callback route can redirect to/${referer}after session creation.
Do not create a
middleware.tsfile.proxy.tsat the project root is the sole request interceptor in Next.js 16.
| File | Path |
|---|---|
| Drizzle client | src/shared/db/index.ts |
| Schema files | src/shared/db/schema/ |
| Schema barrel | src/shared/db/schema/index.ts |
// src/shared/db/index.ts
import * as dotenv from "dotenv"
dotenv.config() // load .env before accessing process.env
import { drizzle } from "drizzle-orm/postgres-js"
import postgres from "postgres"
import * as schema from "./schema"
const client = postgres(process.env.DATABASE_URL!)
export const db = drizzle(client, { schema })// drizzle.config.ts (project root)
import * as dotenv from "dotenv"
dotenv.config() // load .env before accessing process.env
import { defineConfig } from "drizzle-kit"
export default defineConfig({
schema: "./src/shared/db/schema",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: { url: process.env.DATABASE_URL! },
})# Outputs auth tables as Drizzle schema into the correct location
npx better-auth generate --output src/shared/db/schema/auth.tsDo not manually duplicate the auth tables. Import them from auth.ts in the schema barrel.
// src/shared/db/schema/join-requests.ts
import { pgTable, text, timestamp, pgEnum } from "drizzle-orm/pg-core"
import { createId } from "@paralleldrive/cuid2"
export const requestStatusEnum = pgEnum("request_status", ["pending", "approved", "rejected"])
export const joinRequests = pgTable("join_requests", {
id: text("id").primaryKey().$defaultFn(() => createId()),
userId: text("user_id").notNull(),
organizationId: text("organization_id").notNull(),
teamId: text("team_id"),
status: requestStatusEnum("status").notNull().default("pending"),
message: text("message"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
})// src/shared/db/schema/activities.ts
import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core"
import { v7 as uuidv7 } from "uuid"
export const activities = pgTable("activities", {
id: text("id").primaryKey().$defaultFn(() => uuidv7()),
title: text("title").notNull(),
description: text("description"),
organizationId: text("organization_id").notNull(),
startAt: timestamp("start_at").notNull(),
endAt: timestamp("end_at").notNull(),
location: text("location"),
isPublic: boolean("is_public").notNull().default(false),
createdBy: text("created_by").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
})// src/shared/db/schema/attendances.ts
import { pgTable, text, timestamp, unique } from "drizzle-orm/pg-core"
import { createId } from "@paralleldrive/cuid2"
export const attendances = pgTable("attendances", {
id: text("id").primaryKey().$defaultFn(() => createId()),
activityId: text("activity_id").notNull(),
userId: text("user_id").notNull(),
signedAt: timestamp("signed_at").notNull().defaultNow(),
note: text("note"),
}, (t) => ({
uniq: unique().on(t.activityId, t.userId),
}))// src/shared/db/schema/index.ts — barrel export
export * from "./auth" // better-auth generated tables
export * from "./join-requests"
export * from "./activities"
export * from "./attendances"Use the built-in createSchemaFactory from drizzle-orm/zod (replaces the deprecated drizzle-zod package). Pass the custom localized z instance so all generated error messages use the Indonesian locale automatically.
// src/shared/utils/drizzle-zod.ts
import { createSchemaFactory } from "drizzle-orm/zod"
import { z } from "@/shared/utils/zod" // localized zod instance
export const { createInsertSchema, createSelectSchema, createUpdateSchema } =
createSchemaFactory({ zodInstance: z })Always import schema helpers from
@/shared/utils/drizzle-zod— never fromdrizzle-orm/zoddirectly, so the localized zod instance is always used.
| Helper | Use case |
|---|---|
createInsertSchema(table) |
Create / POST forms — required fields, excludes auto-generated id, createdAt, updatedAt |
createSelectSchema(table) |
Parsing API responses and server action return values |
createUpdateSchema(table) |
Edit / PATCH forms — all fields optional |
Pattern for every feature model/schema.ts:
// src/features/activity/model/schema.ts
import { createInsertSchema, createUpdateSchema } from "@/shared/utils/drizzle-zod"
import { activities } from "@/shared/db/schema"
import { z } from "@/shared/utils/zod"
export const createActivitySchema = createInsertSchema(activities, {
title: (s) => s.min(3).max(120),
description: (s) => s.max(2000).optional(),
startAt: z.coerce.date(),
endAt: z.coerce.date(),
}).omit({ id: true, createdBy: true, createdAt: true, updatedAt: true })
export const updateActivitySchema = createUpdateSchema(activities).omit({
id: true, organizationId: true, createdBy: true, createdAt: true, updatedAt: true,
})
export type CreateActivityInput = z.infer<typeof createActivitySchema>
export type UpdateActivityInput = z.infer<typeof updateActivitySchema>// src/features/user/model/schema.ts
import { z } from "@/shared/utils/zod"
export const userProfileSchema = z.object({
name: z.string().min(2),
nickName: z.string().max(50).optional(),
honorifics: z.string().max(50).optional(), // free-text <Input>
postNominalLetters: z.string().max(100).optional(), // free-text <Input>
dateOfBirth: z.coerce.date().optional(),
placeOfBirth: z.string().optional(),
gender: z.enum(["male","female","other","prefer_not_to_say"]).optional(),
education: z.string().optional(),
})
export type UserProfileInput = z.infer<typeof userProfileSchema>The user profile schema is defined manually because the additional user fields are managed by
better-auth(not a plain Drizzle table), so schema generation cannot be used. For all custom tables (activities,join_requests,attendances), always usecreateInsertSchema/createUpdateSchemafrom@/shared/utils/drizzle-zodas the base.
User avatar images are stored on Cloudflare R2. The image field on the user record holds the public R2 URL.
pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner// src/shared/utils/r2.ts
import { S3Client } from "@aws-sdk/client-s3"
export const r2 = new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
})
export const R2_BUCKET = process.env.R2_BUCKET_NAME!
export const R2_PUBLIC_URL = process.env.R2_PUBLIC_URL! // e.g. https://assets.yourdomain.com// src/features/user/api/upload-avatar.ts
"use server"
import { PutObjectCommand } from "@aws-sdk/client-s3"
import { r2, R2_BUCKET, R2_PUBLIC_URL } from "@/shared/utils/r2"
import { auth } from "@/app/lib/auth"
import { headers } from "next/headers"
import { createId } from "@paralleldrive/cuid2"
export async function uploadAvatarAction(formData: FormData) {
const session = await auth.api.getSession({ headers: await headers() })
if (!session) throw new Error("Unauthorized")
const file = formData.get("avatar") as File
if (!file || file.size === 0) throw new Error("No file provided")
const ext = file.name.split(".").pop()
const key = `avatars/${session.user.id}/${createId()}.${ext}`
const buffer = Buffer.from(await file.arrayBuffer())
await r2.send(new PutObjectCommand({
Bucket: R2_BUCKET,
Key: key,
Body: buffer,
ContentType: file.type,
}))
const publicUrl = `${R2_PUBLIC_URL}/${key}`
// Update user image field via better-auth
await auth.api.updateUser({
headers: await headers(),
body: { image: publicUrl },
})
return { url: publicUrl }
}R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET_NAME=
R2_PUBLIC_URL=- Use shadcn
<Input type="file" accept="image/*">for avatar upload (useregister("avatar")orController) - On file selection, call
uploadAvatarAction(formData)and update the displayed<Avatar> - Show upload progress using
<Skeleton>overlay while uploading
Configure a single localized zod instance and import it everywhere instead of importing directly from "zod".
// src/shared/utils/zod.ts
import { z } from "zod"
import i18next from "i18next"
import { zodI18nMap } from "zod-i18n-map"
import translation from "zod-i18n-map/locales/id/zod.json"
i18next.init({
lng: "id",
resources: { id: { zod: translation } },
})
z.setErrorMap(zodI18nMap)
export { z }Install the dependency:
pnpm add zod-i18n-map i18nextImport rule: Never import
zfrom"zod"directly anywhere in the project. Always import from@/shared/utils/zodso all validation error messages are in Indonesian.
// ✅ correct — everywhere in features/, entities/, widgets/
import { z } from "@/shared/utils/zod"
// ❌ wrong
import { z } from "zod"id, name, email, emailVerified, image, role, banned, banReason, banExpires, createdAt, updatedAt
The
rolefield is added by the admin plugin and holds the system-level role:"user"(default) or"admin". Theimagefield stores the Cloudflare R2 public URL of the user's avatar.
nickName: string | null // casual name
honorifics: string | null // free-text, e.g. "Dr.", "Prof.", "Ir.", "H."
postNominalLetters: string | null // free-text, e.g. "PhD", "M.T.", "S.Kom."
dateOfBirth: Date | null
placeOfBirth: string | null
gender: "male" | "female" | "other" | "prefer_not_to_say" | null
education: string | null
honorificsandpostNominalLettersare free-text<Input>fields. Do NOT use a<Select>or predefined dropdown for these — users must be able to type any value (e.g. regional or custom titles).
// src/shared/utils/format-name.ts
export function formatDisplayName(user: {
honorifics?: string | null
name: string
postNominalLetters?: string | null
}): string {
const prefix = user.honorifics ? `${user.honorifics} ` : ""
const suffix = user.postNominalLetters ? `, ${user.postNominalLetters}` : ""
return `${prefix}${user.name}${suffix}`
}
// Example output: "Prof. Ir. Budi Santoso, M.T."- Display formatted name using
formatDisplayName() - Use shadcn
<Card>,<Avatar>,<Separator>for layout - Avatar upload via R2 (see File Storage section)
- Form field types:
honorifics→<Input>(free-text, NOT a select)postNominalLetters→<Input>(free-text, NOT a select)gender→<Select>(enum, fixed options)dateOfBirth→<Popover>+<Calendar>- All other text fields →
<Input>
Use better-auth's built-in organization model. Do not duplicate the schema.
id, name, slug, logo, metadata, createdAt
Only users with system role
adminmay create a new organization.
Two-layer enforcement:
proxy.ts:/dashboard/organizations/newis listed inadminRoutes— non-admin users are redirected at the proxy level.- Server Action: Re-verify before calling
auth.api.createOrganization():
// src/features/organization/api/create-organization.ts
"use server"
import { auth } from "@/app/lib/auth"
import { headers } from "next/headers"
export async function createOrganizationAction(data: CreateOrgInput) {
const session = await auth.api.getSession({ headers: await headers() })
if (!session || session.user.role !== "admin") {
throw new Error("Unauthorized: only system admins can create organizations")
}
return auth.api.createOrganization({
name: data.name, slug: data.slug, userId: session.user.id,
})
}Use better-auth's built-in team model (part of the organization plugin).
id, name, organizationId, createdAt
- User browses
/dashboard/explore→ clicks "Request to Join" → shadcn<Dialog>opens - User fills in preferred team (
<Select>, optional) and a message (<Textarea>) JoinRequestcreated withstatus: "pending"- Org
adminorownersees pending requests tab → approves or rejects - On approval: user is added via
better-authorg API → status set to"approved" - On rejection: status set to
"rejected"
Activities are owned by an Organization.
- Create / edit / delete activity → Org
adminorowner - List activities per organization (paginated, filterable by date range)
- Activity detail page: info + attendee list
- Public activities visible on
/dashboard/explore
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Input {...register("title")} placeholder="Title" />
<Textarea {...register("description")} />
<Controller name="startAt" control={control} render={...} /> {/* <Popover>+<Calendar> */}
<Controller name="endAt" control={control} render={...} /> {/* <Popover>+<Calendar> */}
<Input {...register("location")} />
<Controller name="isPublic" control={control} render={...} /> {/* <Switch> */}
<Button type="submit" disabled={isSubmitting}>Save</Button>
</form>- "Sign Attendance" button on activity detail page (org member or higher)
- Duplicate prevention via unique constraint in Drizzle schema
- Attendance status badge:
<Badge>/<Badge variant="outline"> - Admin/owner view: full attendee list in shadcn
<Table> - Export attendees as CSV (server action)
All UI must use shadcn/ui components exclusively.
Use shadcn's Base component library with the Mira preset theme.
// components.json (project root)
{
"style": "base-mira",
"aliases": {
"components": "@/shared/ui/shadcn",
"utils": "@/shared/ui/common/utils",
"hooks": "@/shared/ui/common/hooks",
"ui": "@/shared/ui/shadcn"
}
}When running shadcn init, select:
- Component library: Base
- Preset: Mira
| Path | Contents |
|---|---|
src/shared/ui/shadcn/ |
All shadcn-installed components (button.tsx, card.tsx, etc.) |
src/shared/ui/common/utils/ |
utils.ts — the cn() helper from shadcn |
src/shared/ui/common/hooks/ |
shadcn hooks (use-mobile.ts, etc.) |
<SidebarProvider>,<Sidebar>,<SidebarMenu>,<SidebarMenuItem>for navigation<Breadcrumb>for page context<Separator>for section dividers
<div className="grid grid-cols-4 gap-4">
<Card>
<CardHeader><CardTitle>Total Members</CardTitle></CardHeader>
<CardContent><p className="text-4xl font-bold">128</p></CardContent>
</Card>
</div>- shadcn
<Table>+ TanStack Table - Sorting via
<Button variant="ghost">column headers - Filtering via
<Input>search - Pagination via
<Pagination>
Do not install or use the shadcn form component. Use a plain <form> tag and wire standard shadcn inputs (<Input>, <Textarea>, <Select>, <Switch>, etc.) directly to react-hook-form via register() or Controller.
// ✅ correct pattern
const { register, control, handleSubmit, formState: { errors, isSubmitting } } =
useForm<Input>({ resolver: zodResolver(schema) })
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<Label htmlFor="title">Title</Label>
<Input id="title" {...register("title")} />
{errors.title && <p className="text-sm text-destructive">{errors.title.message}</p>}
</div>
{/* For controlled components (Select, Switch, etc.) use Controller */}
<Controller name="gender" control={control} render={({ field }) => (
<div>
<Label>Gender</Label>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>...</SelectContent>
</Select>
{errors.gender && <p className="text-sm text-destructive">{errors.gender.message}</p>}
</div>
)} />
<Button type="submit" disabled={isSubmitting}>Submit</Button>
</form>
// ❌ do NOT use — no shadcn <Form> wrapper
<Form {...form}>
<form ...>...</form>
</Form>- Text →
<Input>with{...register("field")}| Long text →<Textarea>with{...register("field")} - Enum / controlled →
<Controller>wrapping<Select>| Toggle →<Controller>wrapping<Switch> - Dates →
<Controller>wrapping<Popover>+<Calendar>| File →<Input type="file"> - Errors → plain
<p className="text-sm text-destructive">{errors.field?.message}</p>
- Create/edit →
<Dialog>or<Sheet> - Destructive →
<AlertDialog variant="destructive">
- Toasts →
<Sonner>| Loading →<Skeleton>| Empty →<Card>+<Button>CTA
/ → redirect to /dashboard
/sign-in → Sign In (Google OAuth button; email/password form shown in non-production only)
/sign-up → Sign Up (Google OAuth; email/password form shown in non-production only)
/dashboard → Overview stats
/dashboard/profile → User profile edit
/activity → Today's activities (semi-public; requires login, referer redirect)
/join/[slug] → Join organization page — pick team, submit request (semi-public; requires login, referer redirect)
/dashboard/users → [SYSTEM ADMIN] All users, ban, role change
/dashboard/organizations → User's organizations list
/dashboard/organizations/new → [SYSTEM ADMIN] Create organization
/dashboard/organizations/[slug] → Org detail
/dashboard/organizations/[slug]/edit → Edit org (org admin/owner)
/dashboard/organizations/[slug]/members → Member management (org admin/owner)
/dashboard/organizations/[slug]/join-requests → Pending requests (org admin/owner)
/dashboard/organizations/[slug]/teams → Teams list
/dashboard/organizations/[slug]/teams/new → Create team (org admin/owner)
/dashboard/organizations/[slug]/teams/[id] → Team detail & members
/dashboard/activities → All activities across user's orgs
/dashboard/activities/[id] → Activity detail + sign attendance
/dashboard/explore → Browse public orgs + request to join
A semi-public page accessible to any authenticated member. Unauthenticated users are redirected to sign-in and returned here after login.
- Unauthenticated user visits
/activity proxy.tsdetects no session → redirects to/sign-in?referer=activity- After successful login, sign-in page reads
referer=activity→ redirects user to/activity - Authenticated user sees today's activities filtered to their organizations
- Server component — fetch data server-side using
auth.api.getSession()+ Drizzle queries - Determine today's date range in UTC:
startOf(today)→endOf(today) - Query all activities where:
startAtfalls within today's date rangeorganizationIdis in the list of organizations the current user belongs to
- For each activity, also query whether the current user already has an
Attendancerecord - Render the list using shadcn
<Card>components, one per activity
// app/activity/page.tsx (Server Component)
import { auth } from "@/app/lib/auth"
import { db } from "@/shared/db"
import { activities, attendances } from "@/shared/db/schema"
import { and, eq, gte, lte, inArray } from "drizzle-orm"
import { headers } from "next/headers"
import { startOfDay, endOfDay } from "date-fns"
export default async function ActivityPage() {
const session = await auth.api.getSession({ headers: await headers() })
// proxy.ts guarantees session exists at this point
// Get the user's organization IDs via better-auth API
const memberships = await auth.api.listOrganizations({
headers: await headers(),
})
const orgIds = memberships.map((m) => m.id)
const today = new Date()
const todayStart = startOfDay(today)
const todayEnd = endOfDay(today)
// Fetch today's activities in user's orgs
const todayActivities = orgIds.length
? await db
.select()
.from(activities)
.where(
and(
inArray(activities.organizationId, orgIds),
gte(activities.startAt, todayStart),
lte(activities.startAt, todayEnd),
)
)
.orderBy(activities.startAt)
: []
// Fetch which ones the user has already signed
const activityIds = todayActivities.map((a) => a.id)
const signed = activityIds.length
? await db
.select({ activityId: attendances.activityId })
.from(attendances)
.where(
and(
eq(attendances.userId, session!.user.id),
inArray(attendances.activityId, activityIds),
)
)
: []
const signedIds = new Set(signed.map((s) => s.activityId))
return <ActivityList activities={todayActivities} signedIds={signedIds} userId={session!.user.id} />
}// src/features/attendance/ui/ActivityList.tsx
"use client"
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/shared/ui/shadcn/card"
import { Badge } from "@/shared/ui/shadcn/badge"
import { Button } from "@/shared/ui/shadcn/button"
import { signAttendanceAction } from "@/features/attendance/api/sign-attendance"
export function ActivityList({ activities, signedIds, userId }) {
if (activities.length === 0) {
return (
<Card className="text-center p-8">
<p className="text-muted-foreground">No activities scheduled for today.</p>
</Card>
)
}
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{activities.map((activity) => {
const isSigned = signedIds.has(activity.id)
return (
<Card key={activity.id}>
<CardHeader>
<CardTitle>{activity.title}</CardTitle>
<CardDescription>{activity.location ?? "—"}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{new Intl.DateTimeFormat("id-ID", { timeStyle: "short" }).format(activity.startAt)}
{" – "}
{new Intl.DateTimeFormat("id-ID", { timeStyle: "short" }).format(activity.endAt)}
</p>
</CardContent>
<CardFooter className="justify-between">
{isSigned ? (
<Badge>Sudah Hadir</Badge>
) : (
<form action={signAttendanceAction.bind(null, activity.id, userId)}>
<Button type="submit" size="sm">Tandai Hadir</Button>
</form>
)}
</CardFooter>
</Card>
)
})}
</div>
)
}// src/features/attendance/api/sign-attendance.ts
"use server"
import { db } from "@/shared/db"
import { attendances } from "@/shared/db/schema"
import { auth } from "@/app/lib/auth"
import { headers } from "next/headers"
import { revalidatePath } from "next/cache"
import { v7 as uuidv7 } from "uuid"
export async function signAttendanceAction(activityId: string) {
const session = await auth.api.getSession({ headers: await headers() })
if (!session) throw new Error("Unauthorized")
await db.insert(attendances).values({
id: uuidv7(),
activityId,
userId: session.user.id,
}).onConflictDoNothing() // unique constraint prevents duplicates
revalidatePath("/activity")
}pnpm add date-fns # startOfDay / endOfDay helpersA semi-public page that lets any authenticated user find an organization by its slug, browse available teams, and submit a join request. Unauthenticated visitors are redirected to sign-in and returned here after login (same referer mechanism as /activity).
- Unauthenticated user visits
/join/acme-corp proxy.tsredirects to/sign-in?referer=join/acme-corp- After login, sign-in page reads
referer→ redirects to/join/acme-corp - Authenticated user sees the org info + team picker form
- Server component — look up the organization by
slugvia Drizzle query - If slug not found → render a shadcn
<Card>404 state (org not found) - If the user is already a member of that org → show an informational card (already a member)
- If the user already has a
pendingjoin request for that org → show a "request pending" card - Otherwise → render the join request form
// app/join/[slug]/page.tsx (Server Component)
import { auth } from "@/app/lib/auth"
import { db } from "@/shared/db"
import { joinRequests } from "@/shared/db/schema"
import { eq, and } from "drizzle-orm"
import { headers } from "next/headers"
export default async function JoinPage({ params }: { params: { slug: string } }) {
const session = await auth.api.getSession({ headers: await headers() })
// proxy.ts guarantees session exists at this point
// 1. Find org by slug via better-auth API
const org = await auth.api.findOrganizationBySlug({
query: { slug: params.slug },
headers: await headers(),
})
if (!org) return <OrgNotFound slug={params.slug} />
// 2. Check if user is already a member
const membership = await auth.api.getActiveMember({
query: { organizationId: org.id },
headers: await headers(),
}).catch(() => null)
if (membership) return <AlreadyMember orgName={org.name} />
// 3. Check for existing pending request
const existingRequest = await db
.select()
.from(joinRequests)
.where(
and(
eq(joinRequests.userId, session!.user.id),
eq(joinRequests.organizationId, org.id),
eq(joinRequests.status, "pending"),
)
)
.limit(1)
if (existingRequest.length > 0) return <RequestPending orgName={org.name} />
// 4. Fetch teams for the org via better-auth API
const teams = await auth.api.listTeams({
query: { organizationId: org.id },
headers: await headers(),
})
return <JoinForm org={org} teams={teams} userId={session!.user.id} />
}The team <Select> must always include an "Unknown / Let admin assign" option as the first entry with value="" (empty string / null). This allows a user to submit a join request without selecting a team, so the org admin can assign them later.
<SelectContent>
{/* Always first — lets admin assign the team later */}
<SelectItem value="">Unknown / Let admin assign</SelectItem>
{teams.map((team) => (
<SelectItem key={team.id} value={team.id}>{team.name}</SelectItem>
))}
</SelectContent>When the form is submitted with teamId = "", store teamId: null in the join_requests table.
// src/features/organization/ui/JoinForm.tsx
"use client"
import { useForm, Controller } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "@/shared/utils/zod"
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/shared/ui/shadcn/select"
import { Textarea } from "@/shared/ui/shadcn/textarea"
import { Button } from "@/shared/ui/shadcn/button"
import { Label } from "@/shared/ui/shadcn/label"
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/shared/ui/shadcn/card"
import { submitJoinRequestAction } from "@/features/organization/api/submit-join-request"
import { toast } from "sonner"
const joinSchema = z.object({
teamId: z.string().optional(), // empty string = unknown/unassigned
message: z.string().max(500).optional(),
})
type JoinInput = z.infer<typeof joinSchema>
export function JoinForm({ org, teams, userId }) {
const { control, register, handleSubmit, formState: { errors, isSubmitting } } =
useForm<JoinInput>({ resolver: zodResolver(joinSchema) })
async function onSubmit(data: JoinInput) {
try {
await submitJoinRequestAction({
organizationId: org.id,
teamId: data.teamId || null, // empty string → null
message: data.message,
})
toast.success("Permintaan bergabung telah dikirim")
} catch {
toast.error("Gagal mengirim permintaan")
}
}
return (
<Card className="max-w-md mx-auto">
<CardHeader>
<CardTitle>Bergabung dengan {org.name}</CardTitle>
<CardDescription>Pilih tim dan kirim permintaan bergabung</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<CardContent className="space-y-4">
<div>
<Label>Tim yang dituju</Label>
<Controller name="teamId" control={control} render={({ field }) => (
<Select onValueChange={field.onChange} defaultValue={field.value}>
<SelectTrigger><SelectValue placeholder="Pilih tim..." /></SelectTrigger>
<SelectContent>
<SelectItem value="">Unknown / Biar admin yang tentukan</SelectItem>
{teams.map((team) => (
<SelectItem key={team.id} value={team.id}>{team.name}</SelectItem>
))}
</SelectContent>
</Select>
)} />
{errors.teamId && <p className="text-sm text-destructive">{errors.teamId.message}</p>}
</div>
<div>
<Label>Pesan (opsional)</Label>
<Textarea placeholder="Kenalkan diri kamu..." {...register("message")} />
{errors.message && <p className="text-sm text-destructive">{errors.message.message}</p>}
</div>
</CardContent>
<CardFooter>
<Button type="submit" className="w-full" disabled={isSubmitting}>
Kirim Permintaan
</Button>
</CardFooter>
</form>
</Card>
)
}// src/features/organization/api/submit-join-request.ts
"use server"
import { db } from "@/shared/db"
import { joinRequests } from "@/shared/db/schema"
import { auth } from "@/app/lib/auth"
import { headers } from "next/headers"
import { revalidatePath } from "next/cache"
import { v7 as uuidv7 } from "uuid"
interface JoinRequestInput {
organizationId: string
teamId: string | null
message?: string
}
export async function submitJoinRequestAction(input: JoinRequestInput) {
const session = await auth.api.getSession({ headers: await headers() })
if (!session) throw new Error("Unauthorized")
await db.insert(joinRequests).values({
id: uuidv7(),
userId: session.user.id,
organizationId: input.organizationId,
teamId: input.teamId ?? null, // null = unknown, admin will assign
message: input.message,
status: "pending",
})
revalidatePath(`/join/${input.organizationId}`)
}// Org not found
function OrgNotFound({ slug }) {
return (
<Card className="max-w-md mx-auto text-center p-8">
<CardTitle>Organisasi tidak ditemukan</CardTitle>
<CardDescription>Tidak ada organisasi dengan slug "{slug}"</CardDescription>
</Card>
)
}
// Already a member
function AlreadyMember({ orgName }) {
return (
<Card className="max-w-md mx-auto text-center p-8">
<CardTitle>Kamu sudah bergabung</CardTitle>
<CardDescription>Kamu sudah menjadi anggota {orgName}.</CardDescription>
</Card>
)
}
// Pending request exists
function RequestPending({ orgName }) {
return (
<Card className="max-w-md mx-auto text-center p-8">
<Badge variant="outline" className="mb-2">Menunggu Persetujuan</Badge>
<CardTitle>Permintaan sedang diproses</CardTitle>
<CardDescription>Permintaan bergabung ke {orgName} sedang menunggu persetujuan admin.</CardDescription>
</Card>
)
}| Action | Required Role |
|---|---|
| Create organization | System admin only |
| Edit / delete organization | Org owner or admin |
| Invite / remove members | Org owner or admin |
| Approve / reject join requests | Org admin or owner |
| Add / edit / delete team | Org admin or owner |
| Create / edit / delete activity | Org admin or owner |
| Sign attendance | Org member, admin, or owner |
| View activity attendees | Org member, admin, or owner |
| Manage all users (ban, change role) | System admin only |
| Edit own profile | Self |
Org
admincan do everything an orgownercan for team, activity, and join request management. The distinction is thatowneradditionally has rights to delete the organization and transfer ownership.
| Component | shadcn Primitives Used |
|---|---|
<DataTable> |
Table, Input, Button, Pagination |
<PageHeader> |
Breadcrumb, Button, Separator |
<StatusBadge> |
Badge |
<UserAvatar> |
Avatar |
<ConfirmDialog> |
AlertDialog |
<EmptyState> |
Card, Button |
<FormSheet> |
Sheet, Button (form inside uses plain <form> + Controller) |
- Import
zfrom@/shared/utils/zod— never from"zod"directly - For custom Drizzle tables: use
createInsertSchema/createUpdateSchemafrom@/shared/utils/drizzle-zod(wrapsdrizzle-orm/zodwith the localized zod instance), then.omit()auto-fields and.extend()any refinements - For better-auth managed entities (user profile): define schema manually with
z.object() - Place schemas in
feature/model/schema.tsand export typedInputtypes viaz.infer<> useForm<z.infer<typeof schema>>({ resolver: zodResolver(schema) })- Use a plain
<form onSubmit={handleSubmit(onSubmit)}>tag — never the shadcn<Form>wrapper - Uncontrolled fields:
<Input {...register("field")}>— useregister()directly - Controlled fields (Select, Switch, Calendar, etc.): wrap with
<Controller name="..." control={control} render={({ field }) => ...} /> - Errors:
{errors.field && <p className="text-sm text-destructive">{errors.field.message}</p>} - Disable
<Button type="submit">whileisSubmitting toast.success()/toast.error()on server action result
# 1. Create Next.js 16 app (app/ at root, manually create src/)
pnpm create next-app@latest organizer --typescript --tailwind --app
# 2. Init shadcn — when prompted select:
# Component library: Base
# Preset: Mira
# Set custom alias paths:
# components → @/shared/ui/shadcn
# utils → @/shared/ui/common/utils
# hooks → @/shared/ui/common/hooks
# lib → @/shared/ui/common
pnpm dlx shadcn@latest init
# 3. Add shadcn components (installs into src/shared/ui/shadcn/)
pnpm dlx shadcn@latest add sidebar card table badge button input textarea \
select switch dialog sheet alert-dialog popover calendar avatar \
separator breadcrumb skeleton sonner pagination
# 4. Install better-auth
pnpm add better-auth
# 5. Install Drizzle (drizzle-orm/zod is built-in, no separate package needed)
pnpm add drizzle-orm postgres @paralleldrive/cuid2 uuid dotenv
pnpm add -D drizzle-kit @types/uuid
# 6. Install form libs + localized zod
pnpm add react-hook-form @hookform/resolvers zod zod-i18n-map i18next
# 7. Install AWS SDK for R2
pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
# 9. Date utilities
pnpm add date-fns
# 10. Generate better-auth schema → Drizzle
npx better-auth generate --output src/shared/db/schema/auth.ts
# 11. Push schema to DB
npx drizzle-kit pushNote:
app/stays at project root. Manually create./srcand place all FSD slices there. Do not use--src-dirwithcreate-next-app.
- Use Next.js Server Actions for all mutations; no separate API routes except
/api/auth/[...all] - Use
revalidatePath()after every mutation for cache invalidation - Keep
better-authas the single source of truth for user, org, and team data - Use
auth.api.*methods server-side;authClient.*methods client-side - All dates stored as UTC, displayed in local timezone via
Intl.DateTimeFormat - Use
authClient.useSession()for reactive session state in client components - Route protection is handled by
proxy.tsat the project root — do not create amiddleware.tsfile - Use Drizzle
.$with()CTEs and.leftJoin()for complex queries — avoid N+1 patterns - R2 keys follow the pattern
avatars/{userId}/{uniqueId}.{ext}— never expose the S3 credentials to the client - Import
zexclusively from@/shared/utils/zodthroughout the codebase for consistent Indonesian error messages