A Nuxt 4 admin dashboard starter: authentication, theming (light/dark, 6 accent colours, RTL), i18n, and a small set of layout components built on god-kit.
It runs with no backend — a demo account is built in — so you can see the whole UI in one command and replace the sample data page by page.
npm install
npm run dev # http://localhost:3333Sign in with godpanel@test.com / god123, or press Fill demo credentials on the login page.
- Make it yours in 15 minutes
- Adding a page
- Connecting your API
- Building a page — the reusable components
- Theming
- Translations
- Project layout
- Environment variables
- Deploying
- Upgrading god-kit
Work top to bottom; each step is independent.
| # | Goal | File |
|---|---|---|
| 1 | Name and favicon | nuxt.config.ts → app.head, runtimeConfig.public.appName |
| 2 | Logo images | public/logo.png, public/full-logo.png, god-pure-*.png — used by app/components/common/Logo.vue |
| 3 | Default theme | defaultSettings in app/stores/settings.ts |
| 4 | Sidebar links | dashboardNavItems in app/utils/routes.ts |
| 5 | Point at your API | NUXT_PUBLIC_API_URL, then NUXT_PUBLIC_DEMO_MODE=false |
| 6 | Replace sample data | app/pages/dashboard/index.vue, analytics/index.vue, app/composables/useNotifications.ts |
Three steps, in this order.
1. Create the page. Every dashboard page uses the dashboard layout and the auth middleware:
<!-- app/pages/dashboard/customers/index.vue -->
<template>
<div>
<PageHeader :title="t('pages.customers.title')" :subtitle="t('pages.customers.subtitle')">
<template #actions>
<GkButton>{{ t('common.add') }}</GkButton>
</template>
</PageHeader>
<PanelCard :title="t('pages.customers.recent')" padding="lg">
<EmptyState :title="t('common.noData')" />
</PanelCard>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { GkButton } from 'god-kit/vue'
const { t } = useI18n()
definePageMeta({ layout: 'dashboard', middleware: 'auth' })
useHead({ title: 'Customers — God Panel' })
</script>PageHeader, PanelCard, EmptyState, StatCard and AppIcon need no import — components are auto-imported by plain name (components.pathPrefix: false in nuxt.config.ts). Gk* components are imported from god-kit/vue as shown.
2. Add the route and sidebar entry in app/utils/routes.ts:
export const paths = {
dashboard: {
// ...
customers: '/dashboard/customers',
},
} as const
export const dashboardNavItems: NavItem[] = [
// ...
{ key: 'customers', title: 'common.customers', path: paths.dashboard.customers, icon: 'account-group' },
]title is an i18n key, not literal text. icon is an AppIcon name — see Icons.
3. Add the strings to both i18n/locales/en.json and i18n/locales/fa.json. The two files must have identical keys; a key present in only one falls back to English at runtime.
Breadcrumbs and the active-item highlight follow automatically from dashboardNavItems.
useApi() is the entire networking layer — a preconfigured $fetch that adds the base URL and bearer token, and clears the token on 401:
const api = useApi()
const customers = await api<Customer[]>('/customers')
await api('/customers', { method: 'POST', body: form })For page data, prefer useFetch so it runs during SSR and is not re-fetched on hydration:
const { data, pending, error } = await useFetch<Customer[]>('/customers', {
baseURL: useRuntimeConfig().public.apiUrl,
})Demo mode is what lets the panel run with no backend. With NUXT_PUBLIC_DEMO_MODE=false:
godpanel@test.comstops working;login()posts toPOST /auth/login- the demo banner disappears from the login page
Your API needs three endpoints:
| Method | Path | Request | Response |
|---|---|---|---|
POST |
/auth/login |
{ email, password } |
{ user, accessToken } |
GET |
/auth/me |
— | { user } |
POST |
/auth/logout |
— | any |
user must satisfy userSchema in app/types/validation.ts (id, displayName, email, role, optional photoURL / phoneNumber). The response is parsed through that schema, so a mismatch fails loudly at the boundary instead of producing undefined deep in a component.
The token is stored in an auth-token cookie, not localStorage, so route middleware can read it during SSR and render the right page on the first request.
Five components cover most screens. Use them instead of re-styling divs, so spacing and surfaces stay consistent.
<PageHeader title="Customers" subtitle="Everyone who has ordered." kicker="Sales">
<template #actions><GkButton>Export</GkButton></template>
</PageHeader>The standard surface — one border, one shadow, one radius.
<PanelCard title="Revenue" icon="chart-line" padding="lg" interactive>
<template #actions><GkButton variant="ghost" slim>⋯</GkButton></template>
…
</PanelCard>padding: none · sm · md (default) · lg. interactive adds the hover lift for cards that link somewhere.
<StatCard icon="cart-plus" label="Orders" value="156" tone="success" change="+8.2%" />tone colours the icon (primary · success · warning · danger · info). changeTone colours the delta and is separate on purpose — set change-tone="up" on a falling number when down is the good outcome (bounce rate, error count).
<EmptyState bordered icon="bell" title="No notifications" description="You're all caught up.">
<GkButton>Refresh</GkButton>
</EmptyState><AppIcon name="chart-line" :size="20" />Takes a short name mapped in app/utils/app-icon-resolve.ts, or any Iconify id (solar:home-2-bold). Names respect the linear / solid setting from the settings drawer. To add an icon, add a line to LEGACY_TO_ICONIFY — store the -bold variant and the linear swap happens automatically.
import { pushGkSnackbar } from 'god-kit/vue'
pushGkSnackbar({ message: 'Saved', variant: 'success' })GkSnackbarHost is already mounted in app.vue. Header notifications come from useNotifications() — replace the body of that composable with your own request and the header needs no changes.
app/stores/settings.ts is the single source of truth. It persists to a cookie, so the server renders the user's theme on the first paint — no flash of the wrong colours.
| Setting | Values |
|---|---|
colorScheme |
light · dark |
direction |
ltr · rtl |
contrast |
default · high |
primaryColor |
default · purple · cyan · blue · orange · red |
navLayout |
vertical · mini · horizontal |
compactLayout |
boolean |
fontFamily |
Inter · Roboto · Poppins · Barlow · DM Sans · Nunito Sans |
iconStyle |
linear · solid |
Change the shipped defaults in defaultSettings; users override them from the settings drawer (the gear in the header).
Use god-kit tokens, never hex values — a hardcoded colour cannot follow the theme switcher:
color: var(--gk-color-on-surface);
background: var(--gk-color-surface);
border: 1px solid var(--gk-color-border);The same tokens are available as Tailwind utilities through god-kit's preset: bg-gk-surface, text-gk-primary, border-gk-border. Panel-level surface helpers (.panel-card, .panel-header-bar, .panel-section-title) live in app/assets/css/panel-shell.css.
Tailwind's dark: variant is wired to the in-app toggle (darkMode: ['selector', …] in tailwind.config.js), not to the OS setting.
i18n/locales/en.json and fa.json. The two files must have the same keys.
Choosing Persian from the header also flips the layout to RTL; the RTL toggle in the settings drawer can override that afterwards. The chosen language is stored in a lang cookie and applied during SSR by app/plugins/i18n.ts.
Check both files still line up:
node -e "const f=n=>{const o={},w=(d,p='')=>Object.entries(d).forEach(([k,v])=>typeof v==='object'?w(v,p+k+'.'):o[p+k]=v);w(require('./i18n/locales/'+n+'.json'));return Object.keys(o)};const e=f('en'),a=f('fa');const d=[...e.filter(k=>!a.includes(k)).map(k=>'fa missing '+k),...a.filter(k=>!e.includes(k)).map(k=>'en missing '+k)];console.log(d.length?d.join('\n'):'locales in sync')"When writing RTL-safe CSS use logical properties — margin-inline-start, padding-inline, inset-inline-end, text-align: start — rather than left / right.
app/
├── app.vue # Root: layout, snackbar host, settings drawer
├── error.vue # Nuxt error + 404 page (NOT pages/error.vue)
├── assets/css/
│ ├── main.css # Tailwind entry, base + typography, RTL, scrollbars
│ ├── panel-shell.css # .panel-* surface helpers
│ └── dashboard-nav.css # .dn-* sidebar styles
├── components/
│ ├── ui/ # PageHeader, PanelCard, StatCard, EmptyState, AppIcon
│ ├── common/ # Logo, LogoutConfirmDialog
│ ├── dashboard/ # Sidebar, mobile drawer, header
│ ├── settings/drawer/ # Theme drawer
│ └── theme/ # Theme settings, language switcher
├── composables/
│ ├── useApi.ts # $fetch wrapper + auth token cookie
│ ├── useNotifications.ts # Header notification list
│ └── useDynamicFonts.ts # Webfont loading
├── layouts/ # dashboard.vue, auth.vue
├── middleware/ # auth.ts, guest.ts
├── pages/ # File-based routes
├── plugins/
│ ├── auth.ts # Restores the session before the first guard
│ ├── god-kit.client.ts # createGkKit + theme sync
│ ├── i18n.ts # Locale restore + <html lang/dir>
│ ├── error-handler.client.ts
│ └── ssr-primary-preset.server.ts
├── stores/ # auth.ts, settings.ts
├── types/validation.ts # Zod schemas — the only source of shared types
└── utils/ # routes.ts, app-icon-resolve.ts, gk-primary-presets.ts
Components are auto-imported by plain filename, so filenames must be unique across app/components/.
All optional — the panel runs with none of them.
| Variable | Default | Purpose |
|---|---|---|
NUXT_PUBLIC_API_URL |
http://localhost:4000 |
Base URL for useApi() |
NUXT_PUBLIC_DEMO_MODE |
true |
false disables the demo account |
NUXT_PUBLIC_SITE_URL |
— | Canonical site URL |
NUXT_PUBLIC_SENTRY_DSN |
— | Enables the reporting hook in error-handler.client.ts |
NUXT_PUBLIC_GOD_KIT_DOCS |
god-kit docs | Docs link in the sidebar and settings |
# .env
NUXT_PUBLIC_API_URL=https://api.example.com
NUXT_PUBLIC_DEMO_MODE=falseLeaving
NUXT_PUBLIC_DEMO_MODE=truein production means anyone can sign in with the demo account. Set it tofalsebefore you ship.
npm run build # Node server → .output/ (node .output/server/index.mjs)
npm run generate # Static site → .output/public/Use build — the panel is server-rendered, which is what makes the auth guard and theme work on the first request. generate produces a static site where every visitor gets the same pre-rendered HTML.
Netlify / Vercel: build npm run build, and let the platform's Nuxt preset handle the output; no publish directory to set by hand. Docker / VPS: run node .output/server/index.mjs behind your reverse proxy.
Set the environment variables above in the platform dashboard, not in a committed .env.
The panel depends on god-kit@^0.8.9.
god-kit 0.8.10 fixes an SSR hydration mismatch in every teleporting component (GkMenu, GkDialog, GkTooltip, GkSnackbar, GkNavigationDrawer) and adds a hint prop to GkField. Until it is published, the browser console shows "Hydration completed but contains mismatches" on pages that use those components.
Once god-kit@0.8.10 is on npm:
npm install god-kit@^0.8.10app/pages/dashboard/profile/index.vue already passes hint to a GkField; on 0.8.9 the prop is simply ignored.
| Command | Description |
|---|---|
npm run dev |
Dev server on port 3333 |
npm run build |
Production build |
npm run preview |
Serve the production build |
npm run generate |
Static site |
npm run typecheck |
vue-tsc over the app |
AGPL-3.0-only — see LICENSE.
Built with Nuxt, god-kit, Tailwind CSS and Pinia. Design inspired by Minimals.