Mila's team uses Vue and needs an admin dashboard. Nuxt 3 gives her file-based routing, server API routes, auto-imports (no more import statements for every component and composable), and built-in data fetching with suspense. The developer experience is unmatched: create a file in server/api/ and you have an endpoint, create a file in composables/ and it's available everywhere.
Step 1: Project Setup
npx nuxi@latest init admin-dashboard
cd admin-dashboard
npx nuxi module add @nuxtjs/tailwindcss
npm install drizzle-orm @libsql/client zod h3-zod
Step 2: Server API Routes
// server/api/users/index.get.ts
import { db } from "~/server/utils/db";
import { users } from "~/server/db/schema";
import { desc, like, sql } from "drizzle-orm";
export default defineEventHandler(async (event) => {
const query = getQuery(event);
const page = Number(query.page) || 1;
const search = String(query.search || "");
const limit = 20;
const offset = (page - 1) * limit;
const where = search ? like(users.name, `%${search}%`) : undefined;
const [data, [{ count }]] = await Promise.all([
db.query.users.findMany({
where,
orderBy: [desc(users.createdAt)],
limit,
offset,
}),
db.select({ count: sql<number>`count(*)` }).from(users).where(where),
]);
return {
users: data,
pagination: { page, limit, total: Number(count), pages: Math.ceil(Number(count) / limit) },
};
});
// server/api/users/[id].patch.ts
import { z } from "zod";
import { db } from "~/server/utils/db";
import { users } from "~/server/db/schema";
import { eq } from "drizzle-orm";
const UpdateUserSchema = z.object({
name: z.string().min(1).max(100).optional(),
role: z.enum(["user", "admin", "editor"]).optional(),
status: z.enum(["active", "suspended", "banned"]).optional(),
});
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id");
const body = await readValidatedBody(event, UpdateUserSchema.parse);
const [updated] = await db.update(users)
.set({ ...body, updatedAt: new Date() })
.where(eq(users.id, id!))
.returning();
if (!updated) throw createError({ statusCode: 404, message: "User not found" });
return updated;
});
Step 3: Composables for Shared State
// composables/useUsers.ts
export function useUsers() {
const search = ref("");
const page = ref(1);
const { data, pending, refresh } = useFetch("/api/users", {
query: { search, page },
watch: [search, page],
});
const updateUser = async (id: string, updates: Record<string, unknown>) => {
await $fetch(`/api/users/${id}`, { method: "PATCH", body: updates });
await refresh();
};
const deleteUser = async (id: string) => {
await $fetch(`/api/users/${id}`, { method: "DELETE" });
await refresh();
};
return {
users: computed(() => data.value?.users || []),
pagination: computed(() => data.value?.pagination),
loading: pending,
search,
page,
refresh,
updateUser,
deleteUser,
};
}
// composables/useAuth.ts
export function useAuth() {
const user = useState<User | null>("auth-user", () => null);
const login = async (email: string, password: string) => {
const result = await $fetch("/api/auth/login", {
method: "POST",
body: { email, password },
});
user.value = result.user;
navigateTo("/dashboard");
};
const logout = async () => {
await $fetch("/api/auth/logout", { method: "POST" });
user.value = null;
navigateTo("/login");
};
return { user: readonly(user), login, logout, isAuthenticated: computed(() => !!user.value) };
}
Step 4: Dashboard Page with Auto-Imported Components
<!-- pages/dashboard/users.vue -->
<script setup lang="ts">
// No imports needed — useUsers is auto-imported from composables/
const { users, pagination, loading, search, page, updateUser, deleteUser } = useUsers();
const editingUser = ref<string | null>(null);
definePageMeta({
middleware: "auth",
layout: "dashboard",
});
</script>
<template>
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">Users</h1>
<input
v-model="search"
placeholder="Search users..."
class="px-4 py-2 border rounded-lg w-64"
/>
</div>
<!-- Auto-imported DataTable component from components/ -->
<DataTable :loading="loading">
<template #header>
<th class="px-4 py-3 text-left">Name</th>
<th class="px-4 py-3 text-left">Email</th>
<th class="px-4 py-3 text-left">Role</th>
<th class="px-4 py-3 text-left">Status</th>
<th class="px-4 py-3 text-right">Actions</th>
</template>
<template #body>
<tr v-for="user in users" :key="user.id" class="border-t hover:bg-gray-50">
<td class="px-4 py-3 font-medium">{{ user.name }}</td>
<td class="px-4 py-3 text-gray-600">{{ user.email }}</td>
<td class="px-4 py-3">
<RoleBadge :role="user.role" />
</td>
<td class="px-4 py-3">
<StatusBadge :status="user.status" />
</td>
<td class="px-4 py-3 text-right space-x-2">
<button @click="editingUser = user.id" class="text-blue-600 hover:underline">
Edit
</button>
<button @click="deleteUser(user.id)" class="text-red-600 hover:underline">
Delete
</button>
</td>
</tr>
</template>
</DataTable>
<!-- Pagination -->
<Pagination
v-if="pagination"
:current="pagination.page"
:total="pagination.pages"
@update="page = $event"
class="mt-4"
/>
<!-- Edit modal -->
<UserEditModal
v-if="editingUser"
:user-id="editingUser"
@close="editingUser = null"
@save="(updates) => { updateUser(editingUser!, updates); editingUser = null; }"
/>
</div>
</template>
Step 5: Auth Middleware
// middleware/auth.ts
export default defineNuxtRouteMiddleware(async (to) => {
const { user } = useAuth();
if (!user.value) {
// Try to restore session
try {
const session = await $fetch("/api/auth/session");
user.value = session.user;
} catch {
return navigateTo(`/login?redirect=${to.fullPath}`);
}
}
});
Summary
Mila's team ships dashboard features in hours. Nuxt auto-imports mean zero boilerplate — create a composable, it's available everywhere; create a component, it's ready to use in templates. Server routes are just files in server/api/ with full TypeScript support. useFetch handles loading states, caching, and reactivity automatically — change the search query and the table re-fetches. Vue's template syntax with v-for, v-if, and v-model makes the dashboard UI readable even for designers. The auth middleware protects routes declaratively with one line in definePageMeta.