Skip to content

Instantly share code, notes, and snippets.

@kilip
Last active March 9, 2026 05:34
Show Gist options
  • Select an option

  • Save kilip/9ec33aa16d8dd03e81834b29ba0009bd to your computer and use it in GitHub Desktop.

Select an option

Save kilip/9ec33aa16d8dd03e81834b29ba0009bd to your computer and use it in GitHub Desktop.
Organizer Specification

🧱 Organizer Build Prompt

Organizer — User · Organization · Team · Activity Management


🎯 Project Overview

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.


🛠 Tech Stack

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

🗂 Project Structure

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)

🗂 FSD Architecture (./src)

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 -management suffix anywhere.


🔐 Authentication — better-auth

Plugins

// 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 },
    }
  }
})

Auth Client

// 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(),
  ],
})

Permissions (app/lib/permissions.ts)

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")

Required env vars (auth)

GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

Auth Features

  • 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

🛡 Route Protection — proxy.ts

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()
}

Post-login redirect from referer param

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 referer value 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.ts file. proxy.ts at the project root is the sole request interceptor in Next.js 16.


🗄 Database — Drizzle ORM

File locations

File Path
Drizzle client src/shared/db/index.ts
Schema files src/shared/db/schema/
Schema barrel src/shared/db/schema/index.ts

Drizzle client

// 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

// 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! },
})

better-auth schema generation

# Outputs auth tables as Drizzle schema into the correct location
npx better-auth generate --output src/shared/db/schema/auth.ts

Do not manually duplicate the auth tables. Import them from auth.ts in the schema barrel.

Custom tables

// 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"

Zod Schema Generation — drizzle-orm/zod

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 from drizzle-orm/zod directly, 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 use createInsertSchema / createUpdateSchema from @/shared/utils/drizzle-zod as the base.


☁️ File Storage — Cloudflare R2

User avatar images are stored on Cloudflare R2. The image field on the user record holds the public R2 URL.

Setup

pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

R2 client

// 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

Upload server action

// 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 }
}

Required env vars

R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET_NAME=
R2_PUBLIC_URL=

Profile form integration

  • Use shadcn <Input type="file" accept="image/*"> for avatar upload (use register("avatar") or Controller)
  • On file selection, call uploadAvatarAction(formData) and update the displayed <Avatar>
  • Show upload progress using <Skeleton> overlay while uploading

🌐 Zod — Indonesian Locale

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 i18next

Import rule: Never import z from "zod" directly anywhere in the project. Always import from @/shared/utils/zod so all validation error messages are in Indonesian.

// ✅ correct — everywhere in features/, entities/, widgets/
import { z } from "@/shared/utils/zod"

// ❌ wrong
import { z } from "zod"

👤 User Entity

better-auth Base Fields (auto-managed)

id, name, email, emailVerified, image, role, banned, banReason, banExpires, createdAt, updatedAt

The role field is added by the admin plugin and holds the system-level role: "user" (default) or "admin". The image field stores the Cloudflare R2 public URL of the user's avatar.

Additional Fields

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

honorifics and postNominalLetters are 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).

Formatted display name

// 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."

User Profile Page (shadcn UI)

  • 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>

User Profile Page (shadcn UI)


🏢 Organization Entity

Source

Use better-auth's built-in organization model. Do not duplicate the schema.

Fields (via better-auth)

id, name, slug, logo, metadata, createdAt

⚠️ Organization Creation — System Admin Only

Only users with system role admin may create a new organization.

Two-layer enforcement:

  1. proxy.ts: /dashboard/organizations/new is listed in adminRoutes — non-admin users are redirected at the proxy level.
  2. 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,
  })
}

👥 Team Entity

Source

Use better-auth's built-in team model (part of the organization plugin).

Fields (via better-auth)

id, name, organizationId, createdAt


📥 Join Request Flow

  1. User browses /dashboard/explore → clicks "Request to Join" → shadcn <Dialog> opens
  2. User fills in preferred team (<Select>, optional) and a message (<Textarea>)
  3. JoinRequest created with status: "pending"
  4. Org admin or owner sees pending requests tab → approves or rejects
  5. On approval: user is added via better-auth org API → status set to "approved"
  6. On rejection: status set to "rejected"

📅 Activity Entity

Activities are owned by an Organization.

Features

  • Create / edit / delete activity → Org admin or owner
  • List activities per organization (paginated, filterable by date range)
  • Activity detail page: info + attendee list
  • Public activities visible on /dashboard/explore

Create/Edit Form (shadcn)

<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>

✅ Attendance Entity

Features

  • "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)

🎨 Dashboard UI — shadcn/ui Design System

All UI must use shadcn/ui components exclusively.

shadcn configuration — Base UI + Mira preset

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.)

Layout

  • <SidebarProvider>, <Sidebar>, <SidebarMenu>, <SidebarMenuItem> for navigation
  • <Breadcrumb> for page context
  • <Separator> for section dividers

Dashboard Overview

<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>

Data Tables

  • shadcn <Table> + TanStack Table
  • Sorting via <Button variant="ghost"> column headers
  • Filtering via <Input> search
  • Pagination via <Pagination>

Forms — mandatory pattern

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>

Dialogs & Confirmations

  • Create/edit → <Dialog> or <Sheet>
  • Destructive → <AlertDialog variant="destructive">

Feedback

  • Toasts → <Sonner> | Loading → <Skeleton> | Empty → <Card> + <Button> CTA

🖥 Dashboard Pages & Routes

/                          → 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

📋 /activity — Today's Attendance Page

A semi-public page accessible to any authenticated member. Unauthenticated users are redirected to sign-in and returned here after login.

Authentication flow

  1. Unauthenticated user visits /activity
  2. proxy.ts detects no session → redirects to /sign-in?referer=activity
  3. After successful login, sign-in page reads referer=activity → redirects user to /activity
  4. Authenticated user sees today's activities filtered to their organizations

Page behaviour

  • 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:
    • startAt falls within today's date range
    • organizationId is in the list of organizations the current user belongs to
  • For each activity, also query whether the current user already has an Attendance record
  • Render the list using shadcn <Card> components, one per activity

Data query (server action / server component)

// 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} />
}

UI components

// 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>
  )
}

Sign attendance server action

// 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")
}

Dependencies

pnpm add date-fns   # startOfDay / endOfDay helpers

🤝 /join/[slug] — Organization Join Page

A 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).

Authentication flow

  1. Unauthenticated user visits /join/acme-corp
  2. proxy.ts redirects to /sign-in?referer=join/acme-corp
  3. After login, sign-in page reads referer → redirects to /join/acme-corp
  4. Authenticated user sees the org info + team picker form

Page behaviour

  • Server component — look up the organization by slug via 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 pending join request for that org → show a "request pending" card
  • Otherwise → render the join request form

Data query (server component)

// 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} />
}

Team list — unknown option

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.

Join form component

// 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>
  )
}

Submit join request server action

// 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}`)
}

State cards (server component UI)

// 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>
  )
}

🔒 Authorization Rules

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 admin can do everything an org owner can for team, activity, and join request management. The distinction is that owner additionally has rights to delete the organization and transfer ownership.


🧩 Key Shared Components

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)

✅ Form Standards (react-hook-form + zod)

  1. Import z from @/shared/utils/zodnever from "zod" directly
  2. For custom Drizzle tables: use createInsertSchema / createUpdateSchema from @/shared/utils/drizzle-zod (wraps drizzle-orm/zod with the localized zod instance), then .omit() auto-fields and .extend() any refinements
  3. For better-auth managed entities (user profile): define schema manually with z.object()
  4. Place schemas in feature/model/schema.ts and export typed Input types via z.infer<>
  5. useForm<z.infer<typeof schema>>({ resolver: zodResolver(schema) })
  6. Use a plain <form onSubmit={handleSubmit(onSubmit)}> tag — never the shadcn <Form> wrapper
  7. Uncontrolled fields: <Input {...register("field")}> — use register() directly
  8. Controlled fields (Select, Switch, Calendar, etc.): wrap with <Controller name="..." control={control} render={({ field }) => ...} />
  9. Errors: {errors.field && <p className="text-sm text-destructive">{errors.field.message}</p>}
  10. Disable <Button type="submit"> while isSubmitting
  11. toast.success() / toast.error() on server action result

🚀 Scaffold Steps

# 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 push

Note: app/ stays at project root. Manually create ./src and place all FSD slices there. Do not use --src-dir with create-next-app.


📝 Implementation Notes

  • 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-auth as 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.ts at the project root — do not create a middleware.ts file
  • 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 z exclusively from @/shared/utils/zod throughout the codebase for consistent Indonesian error messages
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment