My go-to fullstack stack for new projects. Everything here is up to date with the latest versions. The frontend is Vue but the architecture is nearly identical to React — swap Pinia for Zustand, Vue Router for React Router, composables for hooks, and you're there. TanStack Query, up-fetch with zod or valibot, and the entire backend stay the same.
docker-compose upThat's it. DB gets seeded automatically on first run.
- Frontend: http://localhost:3000
- Backend: http://localhost:4000
| Password | Role | |
|---|---|---|
| admin@test.com | password |
Admin |
| editor@test.com | password |
Editor |
| viewer@test.com | password |
Viewer |
- Frontend: Vue 3 + TypeScript, Pinia, TanStack Query, up-fetch
- Backend: NestJS + TypeScript, Drizzle ORM
- DB: PostgreSQL
- Auth: JWT + bcrypt
- Validation: Zod (shared between FE/BE)
- Linting/Formatting: Biome
- Monorepo: Nx + pnpm
The project uses Nx to manage the monorepo. Adds extra complexity but the main benefit here is the shared library (libs/shared/) — Nx sets up TypeScript path aliases so both frontend and backend can import from @managment-system/shared. All Zod schemas, inferred types and permission constants live there, so validation logic and types stay in sync without duplication.
Permissions are checked on both sides. Backend uses a guard chain (middleware attaches user from JWT -> AuthGuard checks authentication -> PermissionsGuard checks required permissions). Frontend uses a usePermissions() composable that exposes hasPermission() — components use it with v-if for visibility and :disabled for greyed-out state.
I used up-fetch as the HTTP client on the frontend instead of axios. It's a thin wrapper around the native fetch API that adds defaults (base URL, headers, error handling) without the overhead of a full HTTP library. The API client configures it once with the JWT token injection and 401 handling, and all service files just call it directly.
Went with Drizzle ORM over TypeORM or Prisma. Simpler, more explicit, very close to raw SQL, and pairs well with Zod since I can keep everything TypeScript-native without decorators or class-transformer.
I went with Biome instead of ESLint + Prettier. It handles both linting and formatting in one tool, also good support for monorepo, to exted base biome.json.
Mutations use setQueryData to update the server cache directly instead of invalidateQueries. When the API already returns the updated entity, there's no reason to refetch the API — just patch the cache. It's instant (no loading flicker), saves a network request, and keeps related caches in sync (e.g. updating a role also patches that role inside the users list and current user).
Pinia handles state management on the frontend. For some store is uses pinia-plugin-persistedstate to automatically store state to session/local storage — similar to Zustand's persist middleware in React. You just add persist: true to the store definition and the plugin takes care.
Permission keys use the resource:action format (e.g. users:view, roles:edit) — this is a common RBAC convention that keeps keys readable and easy to group by resource.
Four tables: users, roles, permissions, role_permissions. Migrations run on backend startup.