| name | dashboard-page-scaffold |
| description | Provides the exact scaffold pattern for new Next.js dashboard pages in carwash-saas. Use this when creating a new page under apps/dashboard/app/(dashboard)/. Covers the Server Component vs Client Component split, TanStack Query hook co-location, loading/error/data states, and Tailwind layout conventions. |
Dashboard Page Scaffold
Every page under apps/dashboard/app/(dashboard)/ follows one of two patterns depending on whether the data needs to be live (client-side polling) or is fine as a server-rendered snapshot.
Pattern A — Server Component page (static / infrequent data)
Use when: data doesn't need to auto-refresh, or the page is purely read-only for the user.
Examples: Overview stats page, settings, loyalty config.
apps/dashboard/app/(dashboard)/
your-page/
page.tsx ← async Server Component, no 'use client'
import { createClient } from "@/lib/supabase/server";
import type { Database } from "@carwash/types";
type YourRow = {
id: string;
name: string;
};
async function getYourData(
supabase: Awaited<ReturnType<typeof createClient>>,
): Promise<YourRow[]> {
const { data, error } = await supabase
.from("your_table")
.select("id, name")
.order("name");
if (error) throw error;
return (data ?? []) as YourRow[];
}
export default async function YourPage() {
const supabase = await createClient();
const items = await getYourData(supabase);
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-6">Your Page Title</h1>
{items.length === 0 && (
<p className="text-sm text-gray-400 text-center py-16">
No items found.
</p>
)}
<div className="grid grid-cols-1 gap-4">
{items.map((item) => (
<div
key={item.id}
className="bg-white rounded-xl border border-gray-200 p-5"
>
{item.name}
</div>
))}
</div>
</div>
);
}
function YourCard({ name }: { name: string }) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-5">{name}</div>
);
}
Pattern B — Client Component page (live / interactive data)
Use when: data should auto-refresh, or the page has user interactions (forms, mutations, filters).
Examples: Transactions page (30s polling), Staff page, any page with a mutation button.
apps/dashboard/app/(dashboard)/
your-page/
page.tsx ← 'use client' at the top
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { createClient } from "@/lib/supabase/client";
import type { YourSchema } from "@carwash/types";
type YourRow = {
id: string;
name: string;
created_at: string;
};
function useYourData() {
const supabase = createClient();
return useQuery<YourRow[]>({
queryKey: ["your-data"],
queryFn: async () => {
const { data, error } = await supabase
.from("your_table")
.select("id, name, created_at")
.order("created_at", { ascending: false });
if (error) throw error;
return (data ?? []) as YourRow[];
},
});
}
function useDoSomething() {
const supabase = createClient();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: { id: string }) => {
const { error } = await supabase.rpc("your_rpc", { p_id: payload.id });
if (error) throw error;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["your-data"] });
},
});
}
export default function YourPage() {
const { data: items, isLoading, error } = useYourData();
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Your Page Title</h1>
</div>
{/* Loading state */}
{isLoading && (
<div className="text-center py-16 text-gray-400 text-sm">Loading…</div>
)}
{/* Error state */}
{error && (
<div className="text-center py-16 text-red-500 text-sm">
Failed to load. Try refreshing.
</div>
)}
{/* Data state — only render after loading and no error */}
{!isLoading && !error && (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
{items?.length === 0 && (
<p className="text-center py-16 text-gray-400 text-sm">
No items yet.
</p>
)}
{items?.map((item) => (
<div
key={item.id}
className="px-5 py-4 border-b border-gray-100 last:border-0"
>
{item.name}
</div>
))}
</div>
)}
</div>
);
}
function YourCard({ name }: { name: string }) {
return <div className="font-medium text-gray-900">{name}</div>;
}
Checklist before creating a new page
Layout and Tailwind conventions
These patterns match existing pages — keep them consistent:
| Element | Class pattern |
|---|
| Page title | text-2xl font-bold text-gray-900 mb-6 |
| Page subtitle / description | text-sm text-gray-500 mb-6 |
| Card / panel | bg-white rounded-xl border border-gray-200 |
| Card with padding | bg-white rounded-xl border border-gray-200 p-5 or p-6 |
| Table wrapper | bg-white rounded-xl border border-gray-200 overflow-hidden |
| Table header cell | px-5 py-3 text-left font-medium text-gray-500 uppercase text-xs tracking-wide |
| Table body cell | px-5 py-4 |
| Table row divider | divide-y divide-gray-100 on <tbody> |
| Loading placeholder | text-center py-16 text-gray-400 text-sm |
| Error message | text-center py-16 text-red-500 text-sm |
| Empty state | text-center py-16 text-gray-400 text-sm |
| Grid layout | grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 |
| Badge (colored) | px-2 py-0.5 rounded text-xs font-medium + color variant |
| Color variant lookup | Record<string, string> object, e.g. washTypeBadge |
Common anti-patterns to reject
| Anti-pattern | Correct approach |
|---|
'use client' on a page that has no interactivity | Use an async Server Component (Pattern A) |
useEffect + useState for data fetching | Use TanStack Query useQuery |
useQuery hook defined outside the page file | Co-locate until used in 2+ pages |
Missing isLoading or error handling | Always render all three TanStack states |
| Rendering data directly without null check | Use optional chaining items?.map(...) |
Using array index as key | Use key={item.id} from DB |
const supabase = await createClient() in a client component | No await in browser client; only server client requires it |
| Helper component in a separate file (used once) | Co-locate in the same page file |
import type { Database } for every field | Define a local type YourRow with only selected fields |