next-shadcn-dashboard-starter

GitHub

Open source admin dashboard starter built with Next.js 16, shadcn/ui, Tailwind CSS, and TypeScript.

RAW Doc

Clerk Setup

Clerk Setup Guide

This guide covers the setup and configuration of Clerk features used in this starter template.

Clerk Scopes Required

- Authentication - User sign-in/sign-up and session management
- Organizations - Multi-tenant workspace management (see setup below)
- Billing - Organization-level subscription management (see setup below)

Clerk Organizations Setup (Workspaces & Teams)

This starter kit includes multi-tenant workspace management powered by Clerk Organizations. To enable this feature:

Enable Organizations in Clerk Dashboard:

1. Go to Clerk Dashboard
2. Navigate to configure
3. Click Organizations settings
4. Configure default roles if needed in the roles and permissions.

Server-Side Permission Checks:

- This starter follows Clerk's recommended patterns

- Fully client-side navigation filtering using useNav hook
- Supports requireOrg, permission, and role checks (all client-side, instant)
- Configured in src/config/nav-config.ts with access properties
- See docs/nav-rbac.md for detailed documentation

For more information, see:

- Clerk Organizations documentation
- Multi-tenant authentication guide

Clerk Billing Setup (Organization Subscriptions)

This starter kit includes Clerk Billing for B2B to manage organization-level subscriptions. Plans and features are managed through the Clerk Dashboard, and the application checks access using Clerk's has() function.

WARNING

Billing is currently in Beta and its APIs are experimental and may undergo breaking changes. To mitigate potential disruptions, we recommend pinning your SDK and clerk-js package versions.

Key Features:

- Organization-level subscription management
- Plan-based access control using <Protect> component
- Feature-based authorization
- Integrated Stripe payment processing
- Server-side plan/feature checks using has() function

Billing Cost Structure:

Clerk Billing costs 0.7% per transaction, plus transaction fees which are paid directly to Stripe. Clerk Billing is not the same as Stripe Billing. Plans and pricing are managed directly through the Clerk Dashboard and won't sync with your existing Stripe products or plans. Clerk uses Stripe only for payment processing, so you don't need to set up Stripe Billing.

Setup Instructions:

#### 1. Enable Billing:

- Navigate to Billing Settings in the Clerk Dashboard
- Enable billing for your application
- Choose payment gateway:
- Clerk development gateway: A shared test Stripe account for development instances. This allows developers to test and build Billing flows in development without needing to create and configure a Stripe account.
- Stripe account: Use your own Stripe account for production. A Stripe account created for a development instance cannot be used for production. You will need to create a separate Stripe account for your production environment.

#### 2. Create Plans:

- Navigate to Plans page in the Clerk Dashboard
- Select Plans for Organizations tab
- Click Add Plan and create plans (e.g., free, pro, team)
- Set pricing and billing intervals
- Toggle Publicly available to show in <PricingTable /> and <OrganizationProfile /> components

#### 3. Add Features to Plans:

- You can add Features when creating a Plan, or add them later:
1. Navigate to the Plans page
2. Select the Plan you'd like to add a Feature to
3. In the Features section, select Add Feature
- Feature names in Clerk Dashboard should match what you check in code

#### 4. Usage in Code:

Server-side checks using has():

typescript
// Check if organization has a Plan
const hasPremiumAccess = has({ plan: 'gold' });

// Check if organization has a Feature
const hasPremiumAccess = has({ feature: 'widgets' });

The has() method is available on the auth object and checks if the Organization has been granted a specific type of access control (Role, Permission, Feature, or Plan) and returns a boolean value.

Client-side protection using <Protect>:

tsx
<Protect
plan='bronze'
fallback={<p>Only subscribers to the Bronze plan can access this content.</p>}
<h1>Exclusive Bronze Content</h1>

</Protect>

Or protect by Feature:

tsx
<Protect
feature='premium_access'
fallback={<p>Only subscribers with the Premium Access feature can access this content.</p>}
<h1>Exclusive Premium Content</h1>

</Protect>

---

Deployment

Deployment

The starter deploys to Vercel out of the box, or anywhere Docker runs. next.config.ts sets output: 'standalone', so production builds are optimized for self-hosting.

1. Connect the repository to Vercel
2. Add environment variables in the dashboard
3. Deploy

For other platforms, see the Next.js deployment docs.

Environment Variables for Production

Ensure these are set in your deployment platform:

- NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
- CLERK_SECRET_KEY
- All NEXT_PUBLIC_* variables for client-side access
- SENTRY_* variables if using error tracking

Sentry source maps are uploaded automatically in CI.

Docker

Two production-ready Dockerfiles are included: Dockerfile (Node.js) and Dockerfile.bun (Bun). Pass NEXT_PUBLIC_* variables as --build-arg at build time and runtime secrets via -e at run time.

Build the image:

bash

Node.js


docker build \
--build-arg NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_xxxxx \
-t shadcn-dashboard .

OR Bun


docker build -f Dockerfile.bun \
--build-arg NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_xxxxx \
-t shadcn-dashboard .

Run the container:

bash
docker run -d -p 3000:3000 \
-e NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_xxxxx \
-e CLERK_SECRET_KEY=sk_live_xxxxx \
--restart unless-stopped \
--name shadcn-dashboard \
shadcn-dashboard

---

Forms

Forms

Forms follow the official shadcn TanStack Form conventions, scaled with
TanStack's own createFormHook
pattern: the doc's Field anatomy is written once per widget as a reusable
field component, and pages use them as one-liners inside form.AppField.

- shadcn: TanStack Form — the
anatomy inside every field component
- TanStack Form docs — validators,
listeners, arrays, async validation

Architecture

| File | What it provides |
| --- | --- |
| src/lib/form-context.ts | createFormHookContextsfieldContext, formContext, useFieldContext, useFieldInvalid, BaseFieldProps |
| src/components/forms/fields/*.tsx | 16 field components, each the exact shadcn doc anatomy for its widget |
| src/components/forms/submit-button.tsx | SubmitButton — disables while submitting (form.Subscribe) |
| src/lib/form.ts | createFormHook — exports useAppForm / withForm with everything registered |

The pattern

One useAppForm per form, a Zod schema validated on submit, and one
form.AppField per field rendering the matching component:

tsx
'use client';

import { useAppForm } from '@/lib/form';
import { FieldGroup } from '@/components/ui/field';
import * as z from 'zod';

const formSchema = z.object({
title: z.string().min(5, 'Title must be at least 5 characters.'),
severity: z.string().min(1, 'Select a severity.')
});

export function BugReportForm() {
const form = useAppForm({
defaultValues: { title: '', severity: '' },
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => console.log(value)
});

return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
<FieldGroup>
<form.AppField
name='title'
children={(field) => (
<field.TextField label='Bug Title' required placeholder='Login button broken' />
)}
/>
<form.AppField
name='severity'
children={(field) => (
<field.SelectField
label='Severity'
options={[
{ value: 'low', label: 'Low' },
{ value: 'high', label: 'High' }
]}
/>
)}
/>
<form.AppForm>
<form.SubmitButton>Submit</form.SubmitButton>
</form.AppForm>
</FieldGroup>
</form>
);
}

form.AppField's name is fully typed against defaultValues — typos are
compile errors. Field-level validators/listeners go on the form.AppField
element (async checks, onChangeListenTo linked fields).

Available field components

All render inside form.AppField as field.XxxField; every one takes
label, description?, required?.

| Component | Value type | Notes |
| --- | --- | --- |
| TextField | string / number | Any input type (text, email, password, tel, url, time, number). Number inputs convert at the edge — clearing writes undefined. Shows a spinner while async validators run. |
| TextareaField | string | showCount renders a character counter against maxLength |
| SelectField | string | options array |
| CheckboxField | boolean | Single checkbox (terms, consent) |
| SwitchField | boolean | Label/description left, switch right |
| RadioGroupField | string | FieldSet + FieldLegend semantics |
| SliderField | number | min/max/step + value readout |
| ComboboxField | string | Searchable select (Popover + Command) |
| DatePickerField | Date \| undefined | Popover + Calendar, disabledDates |
| DateRangeField | DateRange \| undefined | Two-month range calendar |
| OtpField | string | 6-digit code (3 + 3) |
| ColorField | string | Native picker + hex input |
| FileUploadField | File[] | Wraps FileUploader, maxSize/maxFiles |
| CheckboxGroupField | string[] | Needs mode='array' on the AppField |
| TagsField | string[] | Needs mode='array'; Enter/Add pushes, badges remove |
| ToggleGroupField | string[] | Needs mode='array'; pass ToggleGroupItems as children |

One-off custom fields — drop down to form.Field

For anything the components don't cover (object-row arrays, bespoke UI), use
the raw doc pattern directly — it composes freely with the components:

tsx
<form.Field
name='members'
mode='array'
children={(field) => (
<>
{field.state.value.map((_, i) => (
<form.Field
name={members[${i}].name}
children={(subField) => {
const isInvalid = subField.state.meta.isTouched && !subField.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<Input
value={subField.state.value}
onChange={(e) => subField.handleChange(e.target.value)}
onBlur={subField.handleBlur}
aria-invalid={isInvalid}
/>
{isInvalid && <FieldError errors={subField.state.meta.errors} />}
</Field>
);
}}
/>
))}
<Button type='button' onClick={() => field.pushValue({ name: '' })}>Add</Button>
</>
)}
/>

The doc conventions inside any custom field: data-invalid on <Field>,
aria-invalid on the control, {isInvalid && <FieldError errors={…} />},
function validators return { message: '…' } objects.

Scaling to large forms — withForm sections

Split a big form into reusable section components with withForm (also
exported from @/lib/form). Sections receive the form instance as a prop and
keep fully typed field names — a typo'd name inside a section is still a
compile error:

tsx
import { useAppForm, withForm } from '@/lib/form';

const ShippingSection = withForm({
defaultValues: checkoutDefaults, // ties the section to the form's shape
render: function ShippingRender({ form }) {
return (
<FieldGroup>
<form.AppField
name='shipping.street'
children={(field) => <field.TextField label='Street' required />}
/>
<form.AppField
name='shipping.city'
children={(field) => <field.TextField label='City' required />}
/>
</FieldGroup>
);
}
});

// In the page:
const form = useAppForm({ defaultValues: checkoutDefaults, ... });
<ShippingSection form={form} />

Deep paths (org.billing.address.city), array sub-paths
(admins[0].prefs.notify), and union-typed leaves all stay typed, and
typechecking stays fast at 40+ fields.

Template-specific notes

Submitting with React Query. onSubmit awaits the mutation; success/error
handling lives on the mutation (see features/products/components/product-form.tsx):

tsx
onSubmit: async ({ value }) => {
await createMutation.mutateAsync(value);
};

Sheet / Dialog forms. The submit button lives in the footer, outside the
<form> element, connected via the HTML form attribute
(features/users/components/user-form-sheet.tsx):

tsx
<form id='user-form-sheet' onSubmit={…}>…</form>
<SheetFooter>
<Button type='submit' form='user-form-sheet'>Save</Button>
</SheetFooter>

Multi-step forms. useFormStepper(stepSchemas, { fullSchema }) from
@/hooks/use-stepper gates step navigation: Next validates the current
step's schema and paints its errors; the final submit re-validates the whole
schema and never submits invalid data. Route every submit through the gate —
see features/forms/components/multi-step-product-form.tsx.

Number inputs. TextField type='number' already converts at the edge;
give required numbers a human message: z.number({ error: 'Price is required' }).

Caveats to know (verified by stress testing):

- field.XxxField components assert their value type via
useFieldContext<T>() — the compiler checks the name path exists, but
not that the widget matches the path's value type (a SwitchField on a
string path compiles and renders wrong values). Match widgets to the table
above.
- Rendering a field component outside form.AppField throws a clear error
(fieldContext only works when within a fieldComponent…) — it cannot fail
silently.
- Two forms with identical field names mounted at once (a sheet over a page)
produce duplicate id attributes — the shadcn doc's id={field.name}
convention. Form state stays fully isolated; only label-target ids
collide. Rename fields or avoid simultaneous mounting if labels must stay
clickable in both.
- Forgetting mode='array' on an AppField using CheckboxGroupField /
TagsField / ToggleGroupField still renders and updates — but keep the
convention: array mode gives TanStack correct per-item meta tracking.

Examples in the dashboard

| Page | Route | Demonstrates |
| --- | --- | --- |
| Basic Form | /dashboard/forms/basic | All 16 field components incl. array-mode groups, pickers, OTP, tags, upload |
| Advanced | /dashboard/forms/advanced | Async validation, linked fields (onChangeListenTo), nested paths, raw form.Field object-row arrays, listener side effects |
| Multi-Step | /dashboard/forms/multi-step | Per-step schemas, validation gate, review step |
| Sheet Form | /dashboard/forms/sheet-form | Sheet + Dialog forms with external submit buttons |

---

Simplified Navigation RBAC System

Overview

This document explains the fully client-side RBAC (Role-Based Access Control) system for navigation items.

Key Insight: Navigation visibility is UX only, not security. We can check everything client-side using Clerk's hooks!

Architecture

Core Files

1. src/hooks/use-nav.ts - Single hook that handles all filtering logic (fully client-side)
2. src/types/index.ts - Type definitions with access property

Why Client-Side?

- Navigation visibility is UX only - Users can't bypass security by seeing/hiding nav items
- Clerk provides all data client-side - useOrganization() gives us membership.permissions and membership.role
- Zero server calls - Instant filtering, no loading states, no UI flashing
- Better performance - No network latency, no async complexity

Note: For actual security (API routes, server actions, page protection), always use server-side checks.

Performance Characteristics

All Checks Are Synchronous

requireOrg: Client-side check using useOrganization()
permission: Client-side check using membership.permissions array
role: Client-side check using membership.role
⚠️ plan/feature: Requires server-side check (see below)

Zero Server Calls

- All navigation filtering happens synchronously
- No loading states
- No UI flashing
- Instant results

Usage

In nav-config.ts

typescript
{
title: 'Teams',
url: '/dashboard/workspaces/team',
icon: 'userPen',
// Simple: requireOrg (client-side check, instant)
access: { requireOrg: true }
}

{
title: 'Admin Panel',
url: '/dashboard/admin',
icon: 'settings',
// All client-side checks - instant!
access: {
requireOrg: true,
permission: 'org:admin:manage', // Client-side from membership.permissions
role: 'admin' // Client-side from membership.role
}

In Components

typescript
import { useFilteredNavItems } from '@/hooks/use-nav';

function MyComponent() {
const filteredItems = useFilteredNavItems(navItems);
// filteredItems is automatically filtered based on RBAC
}

Plan/Feature Checks

Plans and features require Clerk's has() function which is server-side only. Options:

1. Store in organization metadata (recommended for navigation):

typescript
// In your organization setup
organization.publicMetadata.plan = 'pro';

// In nav-config.ts
access: {
requireOrg: true,
// Check metadata instead of plan
}

2. Show item, protect at page level (current approach):
- Navigation item is shown
- Page component checks server-side and redirects/shows error if needed

3. Use server action (if you really need it):
- Only for navigation items that absolutely need plan/feature checks
- Most navigation items won't need this

Scalability

Adding New Items

Just add to nav-config.ts:

typescript
{
title: 'New Feature',
url: '/dashboard/new',
icon: 'star',
access: { plan: 'pro' } // That's it!
}

The system automatically:

- Filters it in sidebar
- Filters it in kbar
- Handles async checks if needed
- Handles sync checks immediately

Adding New Access Types

1. Add to PermissionCheck interface in src/app/actions/rbac.ts
2. Add check logic in checkAccess() function
3. Update use-nav.ts to handle the new type

Comparison: Before vs After

Before (Overcomplicated)

- 4 files with complex logic
- Multiple hooks and utilities
- Unclear data flow
- Potential for bugs

After (Simplified)

- 1 main hook file
- Clear, linear logic
- Easy to understand
- Easy to maintain

Best Practices

1. Use requireOrg: true for simple cases - It's instant and requires no server call
2. Combine checks when possible - { requireOrg: true, permission: '...' } is more efficient than separate checks
3. Avoid unnecessary checks - Don't add access if the item should always be visible

Migration from Old System

The old visible function still works for backward compatibility:

typescript
// Old way (still works)
visible: (context) => !!context?.organization;

// New way (recommended)
access: {
requireOrg: true;
}

Future Improvements

Potential optimizations if needed:

1. Cache permission checks (e.g., React Query)
2. Prefetch permissions on app load
3. Optimistic UI updates

But for now, the current implementation is:

- ✅ Simple
- ✅ Fast
- ✅ Scalable
- ✅ Maintainable

---

Themes

Adding New Themes

This guide explains how to add a new theme to the application. The theme system uses CSS custom properties with [data-theme] selectors for easy theme switching.

The Journey: Adding a New Theme

When adding a new theme, follow this journey:

1. Create theme CSS filesrc/styles/themes/your-theme-name.css with [data-theme='your-theme-name']
2. Import theme → Add @import to src/styles/theme.css
3. Register theme → Add to THEMES array in src/components/themes/theme.config.ts
4. Add fonts (if needed) → Import fonts in src/components/themes/font.config.ts if using custom Google Fonts
5. Set as default (optional) → Update DEFAULT_THEME in src/components/themes/active-theme.tsx

See the Step-by-Step Guide section below for detailed instructions.

Quick Start: Set Your Theme as Default

To make your new theme the default (so it loads automatically without the theme switcher):

1. Open src/components/themes/active-theme.tsx
2. Change line 12: const DEFAULT_THEME = 'your-theme-name';
3. Save and restart your dev server

That's it! Your theme will now be the default for all new users.

Note: Make sure you've completed steps 1-3 above before setting a theme as default.

Theme Structure

All themes are located in src/styles/themes/ directory. Each theme is a complete, self-contained CSS file that defines all design tokens for both light and dark modes.

File Format

Each theme file must follow this structure:

text
/ Detailed source-code truncated for AI context efficiency. /

Step-by-Step Guide: Adding a New Theme

Follow these steps in order to add a new theme to your application.

Step 1: Create Theme CSS File

Create a new file in src/styles/themes/ with a descriptive name (use kebab-case):

bash
src/styles/themes/your-theme-name.css

Important: The filename should match the data-theme attribute value you'll use in the CSS.

Step 2: Define Your Theme with [data-theme] Attribute

Copy the structure from the "File Format" section above and fill in your color values. Use OKLCH color format for better color consistency:

css
/ Light mode tokens /
[data-theme='your-theme-name'] {
--background: oklch(1 0 0); / White /
--foreground: oklch(0.145 0 0); / Dark gray /
--card: oklch(...);
/ ... all other tokens /
}

/ Dark mode tokens /
[data-theme='your-theme-name'].dark {
--background: oklch(0.145 0 0); / Dark /
--foreground: oklch(0.985 0 0); / Light /
/ ... all other tokens with dark mode values /
}

/ Theme inline mappings for Tailwind /
[data-theme='your-theme-name'] {
@theme inline {
/ All the mappings as shown in the File Format section /
}
}

Color Format:

- Use oklch() format: oklch(lightness chroma hue)
- Example: oklch(0.852 0.199 91.936) = light green-blue
- Lightness: 0-1 (0 = black, 1 = white)
- Chroma: 0+ (0 = grayscale, higher = more saturated)
- Hue: 0-360 (color wheel position)

Key Points:

- The [data-theme='your-theme-name'] selector is what makes your theme work
- The value 'your-theme-name' must match exactly in all places (CSS file, config, etc.)
- Always include both light and dark mode variants
- Include the @theme inline block for Tailwind CSS integration

Step 3: Import Theme in theme.css

Add your theme import to src/styles/theme.css:

css
@import './themes/your-theme-name.css';

This makes your theme available to the application.

Step 4: Add Theme to theme.config.ts

Add your theme to the THEMES array in src/components/themes/theme.config.ts:

typescript
export const THEMES = [
// ... existing themes
{
name: 'Your Theme Name', // Display name in the UI
value: 'your-theme-name' // Must match [data-theme] value exactly
}
];

Important: The value field must match the data-theme attribute value from your CSS file exactly.

Step 5: Add Custom Fonts (If Needed)

Only do this step if your theme requires a custom Google Font that isn't already loaded.

If you want to use a Google Font in your theme:

File: src/components/themes/font.config.ts

1. Import the font from next/font/google:

typescript
import { Your_Font_Name } from 'next/font/google';

2. Configure the font with a CSS variable:

typescript
const fontYourName = Your_Font_Name({
subsets: ['latin'],
weight: ['400', '500', '700'], // Adjust weights as needed
variable: '--font-your-name' // Optional: custom variable name
});

3. Add it to the fontVariables export:

typescript
export const fontVariables = cn(
// ... existing fonts
fontYourName.variable
);

4. Use the font in your theme CSS by its display name (not the CSS variable):

css
[data-theme='your-theme-name'] {
--font-sans: 'Your Font Name', sans-serif; / Use the actual font name /
--font-mono: 'Your Mono Font', monospace;
}

Important Notes:

- Use the font's display name in CSS (e.g., 'Geist', 'Architects Daughter'), not the CSS variable
- The font must be imported in font.config.ts for it to be loaded by Next.js
- Font variables from font.config.ts are automatically applied to the body via layout.tsx
- You can use any Google Font available in next/font/google
- Check existing fonts in font.config.ts before adding new ones - you might be able to reuse them

Example: The notebook theme uses Architects Daughter:

- Imported in font.config.ts as Architects_Daughter
- Used in notebook.css as 'Architects Daughter' (with quotes and space)

Step 6: Set as Default Theme (Optional)

If you want your theme to be the default theme that loads when users first visit the application (without needing the theme switcher), update the default theme constant:

File: src/components/themes/theme.config.ts

typescript
/
* Default theme that loads when no user preference is set
* Change this value to set a different default theme
*/
export const DEFAULT_THEME = 'your-theme-name'; // Change from 'vercel' to your theme name

Note:

- This is the single source of truth for the default theme - it's automatically used in both server-side rendering and client-side code
- This will make your theme the default for all new users
- Existing users who have already selected a theme will still see their saved preference (stored in cookies)
- The default theme is applied immediately on page load (no flash of unstyled content)

Step 7: Test Your Theme

1. Start your development server
2. Open the theme selector in the UI
3. Select your new theme
4. Verify it works in both light and dark modes
5. Test scaled variant by selecting "Your Theme Name (Scaled)"
6. If you set it as default, clear your browser cookies and refresh to see it load automatically

Quick Reference: File Locations

When adding a new theme, you'll work with these files in this order:

1. ✅ src/styles/themes/your-theme-name.css - Create theme file with [data-theme] attribute
2. ✅ src/styles/theme.css - Import your theme file
3. ✅ src/components/themes/theme.config.ts - Add theme to THEMES array
4. ⚠️ src/components/themes/font.config.ts - Add fonts only if needed
5. ⚠️ src/components/themes/active-theme.tsx - Set as default only if desired

Required Tokens

Minimum Required

At minimum, your theme should define these tokens:

- --background
- --foreground
- --card & --card-foreground
- --popover & --popover-foreground
- --primary & --primary-foreground
- --secondary & --secondary-foreground
- --muted & --muted-foreground
- --accent & --accent-foreground
- --destructive & --destructive-foreground
- --border
- --input
- --ring
- --radius

Optional Tokens

These can be omitted if not needed:

- --chart-1 through --chart-5 (defaults to primary colors)
- --sidebar-* tokens (defaults to card colors)
- --font-* tokens (uses system defaults)
- --shadow-* tokens (no shadows if omitted)
- --tracking-normal (no letter spacing if omitted)
- --spacing (uses default)

Example: Complete Theme

See src/styles/themes/claude.css for a complete example with all tokens defined.

Example: Minimal Theme

For a minimal theme, you can copy an existing theme and modify only the colors you want to change. The system will fall back to defaults for any missing tokens.

Color Format Reference

OKLCH Format

text
oklch(lightness chroma hue)

- Lightness: 0-1 (0 = black, 1 = white)
- Chroma: 0+ (0 = grayscale, 0.2+ = colorful)
- Hue: 0-360 degrees
- 0/360 = Red
- 60 = Yellow
- 120 = Green
- 180 = Cyan
- 240 = Blue
- 300 = Magenta

Examples

css
/ Pure white /
--background: oklch(1 0 0);

/ Pure black /
--foreground: oklch(0 0 0);

/ Bright blue /
--primary: oklch(0.7 0.2 240);

/ Muted gray /
--muted: oklch(0.5 0 0);

Scaled Variants

All themes automatically support scaled variants. When a user selects "Theme Name (Scaled)", the .theme-scaled class is applied, which adjusts spacing and text sizes. No additional CSS is needed in your theme file.

Best Practices

1. Use descriptive theme names: Use kebab-case (e.g., ocean-blue, forest-green)
2. Provide both light and dark modes: Always define both variants
3. Test accessibility: Ensure sufficient contrast between foreground and background
4. Keep tokens consistent: Use similar lightness/chroma values for related colors
5. Document special features: If your theme has unique characteristics (like no shadows or custom fonts), add comments

Troubleshooting

Theme Not Appearing

- Check that the file is imported in src/styles/theme.css
- Verify the theme name matches in both CSS file and theme-selector.tsx
- Ensure the file is saved and the dev server has reloaded

Colors Not Applying

- Verify all required tokens are defined
- Check that @theme inline block includes all color mappings
- Ensure OKLCH format is correct (no typos)

Dark Mode Not Working

- Verify .dark selector is correct: [data-theme='name'].dark
- Check that dark mode tokens are defined
- Ensure next-themes is properly configured

Setting a Default Theme

By default, the application uses the vercel theme. To change the default theme that loads for new users:

Change Default Theme Constant

Edit src/components/themes/theme.config.ts and update the DEFAULT_THEME constant:

typescript
/
* Default theme that loads when no user preference is set
* Change this value to set a different default theme
*/
export const DEFAULT_THEME = 'your-theme-name'; // Change this value

How it works:

- Single source of truth: DEFAULT_THEME is defined in theme.config.ts and imported everywhere it's needed
- Server-side: Applied immediately in the HTML data-theme attribute (no flash)
- Client-side: Used as fallback when no cookie preference exists
- User preferences: Still respects saved user preferences (stored in cookies)
- Automatic: No need to update multiple files - change it once and it works everywhere

Benefits of this approach:

✅ No code duplication - defined once, used everywhere
✅ Type-safe - TypeScript ensures consistency
✅ Easy to change - update one line in one file
✅ Well-documented - clear comments explain its purpose
✅ Immediate application - no flash of unstyled content

Using Google Fonts in Themes

Note: This section provides additional details about fonts. For the complete step-by-step process, see Step 5 in the "Step-by-Step Guide" above.

When to Add Fonts

You only need to add fonts to font.config.ts if:

- Your theme uses a Google Font that isn't already imported
- You want to use a custom font that requires loading

Tip: Check src/components/themes/font.config.ts first - many fonts may already be available!

Font Loading Process

1. Import the font in src/components/themes/font.config.ts:

typescript
import { Roboto, Roboto_Mono } from 'next/font/google';

2. Configure the font with a CSS variable:

typescript
const fontRoboto = Roboto({
subsets: ['latin'],
weight: ['400', '500', '700'],
variable: '--font-roboto'
});

3. Add to fontVariables export:

typescript
export const fontVariables = cn(
// ... existing fonts
fontRoboto.variable
);

4. Use in your theme CSS with the font's display name:

css
[data-theme='your-theme'] {
--font-sans: 'Roboto', sans-serif; / Use display name, not CSS variable /
--font-mono: 'Roboto Mono', monospace;
}

Important Notes

- Font names: Use the font's display name in CSS (e.g., 'Roboto', 'Open Sans'), not the CSS variable name
- Font loading: Fonts must be imported in font.config.ts to be loaded by Next.js
- Automatic application: Font variables are automatically applied to the body element via layout.tsx
- Available fonts: Check Next.js Font Optimization for available Google Fonts

Example: Notebook Theme

The notebook theme uses Architects Daughter:

In font.config.ts:

typescript
import { Architects_Daughter } from 'next/font/google';

const fontArchitectsDaughter = Architects_Daughter({
subsets: ['latin'],
weight: '400',
variable: '--font-architects-daughter'
});

export const fontVariables = cn(
// ... other fonts
fontArchitectsDaughter.variable
);

In notebook.css:

css
[data-theme='notebook'] {
--font-sans: 'Architects Daughter', sans-serif;
}

Reference Files

- Complete theme example: src/styles/themes/claude.css
- Theme aggregator: src/styles/theme.css
- Theme selector component: src/components/themes/theme-selector.tsx
- Theme provider: src/components/themes/active-theme.tsx
- Theme configuration (includes default theme): src/components/themes/theme.config.ts
- Font configuration: src/components/themes/font.config.ts

---

README

<h1 align="center">Admin Dashboard Template with Next.js &amp; Shadcn UI</h1>

<div align="center">Free, open source admin dashboard starter built with Next.js 16, shadcn/ui, Tailwind CSS, and TypeScript</div>

<div align="center">
<a href="https://dub.sh/shadcn-dashboard"><strong>View Demo</strong></a>
</div>

<br />

<div align="center">
<img src="/public/shadcn-dashboard.png" alt="Shadcn Dashboard Cover" style="max-width: 100%; border-radius: 8px;" />
</div>

<br />

<p align="center">
<a href="https://github.com/Kiranism/next-shadcn-dashboard-starter/stargazers"><img src="https://img.shields.io/github/stars/Kiranism/next-shadcn-dashboard-starter?style=social" alt="GitHub stars" /></a>
<a href="https://github.com/Kiranism/next-shadcn-dashboard-starter/network/members"><img src="https://img.shields.io/github/forks/Kiranism/next-shadcn-dashboard-starter?style=social" alt="Forks" /></a>
<a href="https://github.com/Kiranism/next-shadcn-dashboard-starter/blob/main/LICENSE"><img src="https://img.shields.io/github/license/Kiranism/next-shadcn-dashboard-starter" alt="MIT License" /></a>
<img src="https://img.shields.io/badge/Next.js-16-black" alt="Next.js" />
<a href="https://go.clerk.com/ILdYhn7"><img src="https://img.shields.io/badge/Sponsored_by-Clerk-6C47FF?style=flat-square&logo=clerk" alt="Sponsored by Clerk" /></a>
</p>

Overview

A free, open source (MIT) admin dashboard starter built with Next.js 16, shadcn/ui on Base UI primitives, TypeScript, and Tailwind CSS v4.

Every feature is a working, production-ready implementation, not static demo UI. Tables search, filter, sort, and paginate for real. Forms validate and mutate with cache invalidation.
Auth, organizations, and billing function end-to-end.

Clone it, strip what you don't need with the built-in cleanup script, and start building on patterns you'd write yourself. It works well as a base for SaaS apps, internal tools, and admin panels.

Why This Template

Most dashboard templates are static demo boilerplates: screens that look finished but need rebuilding the moment you wire in real data. This starter takes the opposite approach:

- Everything actually works. Data tables run end-to-end: server prefetch, client-side React Query cache, and URL-synced search, filtering, sorting, and pagination via nuqs. Forms are built from reusable, composable fields with Zod validation, including advanced patterns like multi-step and dialog/sheet forms, with real create/update mutations and cache invalidation on success.
- Industry-standard implementations. The data layer follows the official TanStack Query SSR pattern (server prefetch + HydrationBoundary + useSuspenseQuery), typed end to end, organized in a feature-based structure with a clean API layer per feature. These are patterns you copy into production code as-is, not mockups you rebuild from scratch.
- Minimal by design. Deliberately lean, with no bloated boilerplate, so you spend your time tweaking it to your use case, not deleting someone else's code. The built-in cleanup script strips any feature you don't need in under a minute.

Tech Stack

- Framework - Next.js 16
- Language - TypeScript
- Auth - Clerk
- Error tracking - Sentry
- Styling - Tailwind CSS v4
- Components - shadcn/ui on Base UI primitives
- Charts - RechartsEvil Charts
- Schema validation - Zod
- Data fetching - TanStack React Query
- State management - Zustand
- Search param state - Nuqs
- Tables - TanStack Data TablesDice Table
- Forms - TanStack Form + Zod
- Command+K interface - kbar
- Linter / Formatter - OxLintOxfmt
- Pre-commit hooks - Husky
- Themes - tweakcn

_Looking for a TanStack Start version? Here's the repo._

Features

- Pre-built dashboard layout with sidebar, header, and content area
- Analytics overview page with cards and charts
- Data tables with React Query prefetch, client-side cache, search, filter, and pagination
- Authentication and user management through Clerk
- Multi-tenant workspaces using Clerk Organizations (create, switch, manage teams)
- Billing and subscriptions via Clerk Billing for B2B, with plan management and feature gating
- Client-side RBAC navigation that filters menu items by organization, permissions, and roles
- Infobar component for tips, status messages, or contextual notes on any page
- shadcn/ui components on Base UI primitives, styled with Tailwind CSS
- Six-plus themes with a theme switcher
- Feature-based folder structure
- A starting point for SaaS dashboards, internal tools, and client admin panels

Use Cases

A few things you can build with it:

- SaaS admin dashboards
- Internal tools and operations panels
- Analytics dashboards
- Client project admin panels
- A boilerplate for new Next.js shadcn projects

Pages

| Page | Notes |
| :------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Signup / Signin | Auth handled by Clerk, with passwordless sign-in, social logins, and enterprise SSO. |
| Dashboard Overview | Cards and Recharts graphs. Parallel routes give each section its own loading and error state. |
| Product List (Table) | TanStack Table plus React Query (server prefetch, client cache) with nuqs URL state for search, filter, and pagination. shallow: true keeps interactions on the client. |
| Create Product Form | TanStack Form and Zod with useMutation for create and update. Cache is invalidated on success. |
| Users (Table) | Same setup as Products: React Query with nuqs, server prefetch, and client-side pagination and filtering. |
| React Query Demo | A Pokemon API example showing the server prefetch, HydrationBoundary, and useSuspenseQuery pattern with client-side cache. |
| Profile | Clerk's account management UI for profile and security settings. |
| Kanban Board | Drag-and-drop task board built with dnd-kit and Zustand. Column sorting, priority badges, assignees, and due dates. |
| Chat | Messaging UI with a conversation list, message bubbles, quick replies, attachments, and an auto-reply demo. Multi-panel layout that works on mobile. |
| AI Chat | Scripted AI chat that streams a predefined conversation through the real useChat lifecycle — no model, API route, or key. Built with the shadcn chat components (MessageScroller, Bubble, Marker). |
| Notifications | Notification center with a header badge, popover preview, and a full page with All / Unread / Read tabs. Includes mark-as-read and mark-all-as-read. |
| Workspaces | Organization management using Clerk's <OrganizationList />. View, create, and switch between organizations. |
| Team Management | Team management using Clerk's <OrganizationProfile />. Manage members, roles, permissions, security, and org details. Needs an active organization. |
| Billing & Plans | Billing page using Clerk's <PricingTable />. View plans, subscribe, and manage subscriptions. Needs an active organization. |
| Exclusive Page | Plan-based access control with Clerk's <Protect>. Only available to organizations on the Pro plan, with a fallback UI for everyone else. |
| Not Found | A root-level not-found page. |
| Global Error | A shared error page wired to Sentry for logging, reports, and session replay. |

Folder Structure

text
/ Detailed source-code truncated for AI context efficiency. /

Getting Started

NOTE

This starter uses Next.js 16 (App Router) with React 19 and shadcn/ui. To run it locally:

Clone the repo:

text
git clone https://github.com/Kiranism/next-shadcn-dashboard-starter.git

- bun install
- Copy the example env file: cp env.example.txt .env.local
- Fill in the required variables in .env.local
- bun run dev

##### Environment variables

See env.example.txt for the variables you need. They cover authentication and error tracking.

##### Clerk setup

For setting up Clerk auth (including organizations, workspaces, and teams), see clerk_setup.md.

The app should now be running at http://localhost:3000.

WARNING

After cloning or forking, be careful when pulling the latest changes. Updates can cause merge conflicts.

---

Cleanup Script: Start Minimal in 60 Seconds

Most starters make you hand-delete demo pages and rip out dependencies. This one ships with a cleanup script that removes the optional features you don't need (folders, files, dependencies, docs, and env entries), leaving a minimal base to build on. Run --list to see what's removable:

bash
bun run cleanup --interactive    # interactive mode
bun run cleanup --list # see available features
bun run cleanup --dry-run chat # preview before removing
bun run cleanup kanban chat # remove specific features

Run bun run cleanup --help for all options (with npm, pass flags after --: npm run cleanup -- --list). The replacement files it writes live in scripts/cleanup-templates/ as real, typechecked code. When you're done, delete scripts/cleanup.js, scripts/cleanup-templates/, and the cleanup entry in package.json.

FAQ

Is it production ready?
Yes. Every feature is a complete, working implementation: authentication, CRUD flows, table search/filter/sort/pagination, and form validation with mutations all function end-to-end. It's a starting point for real applications, not a visual mockup.

How is this different from other dashboard templates?
Most dashboard templates are static demo boilerplates: screens that look finished but need rebuilding once you wire in real data. Here the tables, forms, auth, organizations, and billing all work end-to-end, the implementations follow official TanStack and Next.js patterns, and a cleanup script keeps the base minimal so you tweak it to your use case instead of deleting code.

Is it free for commercial use?
Yes. MIT-licensed and free for both personal and commercial projects: no paid tier, no license keys.

Can I use it without Clerk?
Yes. Run bun run cleanup clerk to remove Clerk authentication (along with organizations and billing) and wire in your own auth solution.

How do I remove demo pages or features I don't need?
Run bun run cleanup --interactive and pick what to strip, or bun run cleanup --list to see what can be removed.

Does it support Next.js 16, React 19, and Tailwind CSS v4?
Yes. The template is built on Next.js 16 (App Router), React 19, and Tailwind CSS v4, with shadcn/ui on Base UI primitives, and is actively maintained to track new releases.

Can I use npm instead of Bun?
Yes. Bun is preferred, but npm works too, and the repo even ships both Node.js and Bun Dockerfiles for deployment.

Does it work with AI coding assistants?
Yes. The repo ships AGENTS.md and CLAUDE.md with the project's conventions, plus a bundled Claude Code skill (.claude/skills/kiranism-shadcn-dashboard) that teaches agents how to add pages, tables, forms, and navigation the template way. Works with Claude Code, Cursor, and any tool that reads AGENTS.md.

What data fetching pattern does it use?
TanStack React Query with the official SSR pattern: prefetchQuery on the server, HydrationBoundary with dehydrate for hydration, and useSuspenseQuery on the client, plus nuqs for URL-synced search-param state. Mutations invalidate the cache on success.

How do I deploy it?
Deploy to Vercel out of the box, or use the included Docker setups: a Node.js Dockerfile and a Bun Dockerfile, both using Next.js standalone output mode. See the deployment guide.

Deploy

Deploy to Vercel out of the box, or use the included Docker setups: a Node.js Dockerfile and a Bun Dockerfile, both using Next.js standalone output mode. Full guide: docs/deployment.md.

Support

If this template saved you some time, a star is appreciated. You can also buy me a coffee if you'd like.

[](https://buymeacoffee.com/kir4n)

---