Velora: Building a Nuxt Monorepo Template for Scalable Web Applications
Discover Veloraβa production-ready Nuxt monorepo template with Bun workspaces, shared layers, and organized architecture. Learn monorepo best practices, layer patterns, and why monorepos solve real scaling problems.

Copy-Pasting Components Between Projects is Not a Strategy
I had three separate Nuxt projects running for different parts of my platform: the main app, an admin dashboard, and a landing page. They all needed the same Button component. So naturally, I copied it between projects.
Then I fixed a bug in one Button. Now I had to remember to update it in the other two projects. I forgot. Users found bugs that were already fixed elsewhere.
"There has to be a better way to share code between related projects," I thought while copy-pasting components for the third time that week.
That's when I discovered monorepos. And after struggling with configuration hell, I built Veloraβa Nuxt monorepo template that actually makes sense.
In this post, I'll share why monorepos matter, how Velora solves real architectural problems, and when you should (and shouldn't) use this approach.
The Problem: Multiple Projects, Duplicated Everything
My Scaling Nightmare
I started with one Nuxt app. Simple. Then business needs grew:
Project Structure:
my-projects/
βββ main-app/ # Customer-facing application
βββ admin-dashboard/ # Internal admin tools
βββ marketing-site/ # Landing pages
Each was a separate repository. Each had its own:
- ESLint configuration (different rules across projects)
- UI component library (duplicated, out of sync)
- Utility functions (copy-pasted, different versions)
- TypeScript types (inconsistent interfaces)
- Design tokens (slightly different colors everywhere)
The breaking point: I spent half a day updating the same authentication logic in three places because I forgot they all used the same Supabase instance.
What I Tried First
Approach 1: npm packages for shared code
# Create internal packages
@mycompany/ui-components
@mycompany/utils
@mycompany/types
Problems:
- Had to publish packages (even privately) for every small change
- Version management nightmare
- Lost the convenience of editing shared code locally
- Build and publish workflow slowed iterations to a crawl
Approach 2: Git submodules
git submodule add https://github.com/mycompany/shared-components
Problems:
- Submodules are notoriously difficult to work with
- Teammates constantly had outdated versions
- Merge conflicts became exponentially worse
- "It works on my machine" but with submodules
Approach 3: Copying code and promising to "refactor later"
We all know how that ends.
Enter Monorepos: One Repository, Multiple Applications
What is a Monorepo?
A monorepo is a single repository containing multiple projects (apps, libraries, packages) that can depend on each other.
velora/ # Single repo
βββ apps/ # Multiple applications
β βββ main-app/
β βββ admin-dashboard/
β βββ marketing-site/
βββ layers/ # Shared Nuxt layers
β βββ base/ # UI foundation
β βββ common/ # Business logic
βββ libs/ # Standalone libraries
βββ utils/
Key insight: All projects in one repo, but each can be developed, built, and deployed independently.
Why Monorepos Change Everything
Before monorepo (multiple repos):
# Make a change to shared button component
cd ui-components
# Edit Button.vue
git commit -m "Fix button padding"
npm version patch
npm publish
cd ../main-app
npm install @mycompany/ui-components@latest
npm install # Wait 2 minutes
# Test changes
# Repeat for admin-dashboard
# Repeat for marketing-site
# Total time: 30+ minutes
With monorepo:
# Make a change to shared button component
# Edit layers/base/components/Button.vue
# Save
# All apps immediately see the change (hot reload)
# Total time: 30 seconds
The magic: Shared code lives alongside your apps. Change once, update everywhere instantly.
Velora's Architecture: Organized Complexity
After trying different monorepo structures, I landed on this organization:
velora/
βββ apps/ # Full-featured Nuxt applications
βββ layers/ # Nuxt layers (shared code & config)
βββ libs/ # Framework-agnostic libraries
βββ plugins/ # Standalone Vue apps
βββ package.json # Root workspace configuration
The Four Pillars of Velora
1. Apps - Your Deployable Applications
apps/
βββ auth/
βββ nuxt.config.ts
βββ package.json
βββ app.vue
βββ app/
βββ components/ # App-specific components
βββ pages/ # App routes
βββ stores/ # App-level state
βββ middleware/ # App middleware
Purpose: Complete, deployable Nuxt applications that extend shared layers.
Example: Your main application, admin dashboard, customer portalβeach is an "app."
Key characteristic: Apps consume shared code but don't provide code to others.
2. Layers - The Shared Foundation
This is where Velora shines. Nuxt layers are reusable configurations and code.
layers/
βββ base/ # UI foundation
β βββ components/ # Logo, Button, etc.
β βββ uno.config.ts # Shared UnoCSS config
β βββ nuxt.config.ts
βββ common/ # Business logic
βββ composables/ # Shared composables
βββ stores/ # Shared Pinia stores
βββ types/ # TypeScript types
Layer hierarchy:
base (UI foundation)
β
common (extends base, adds business logic)
β
apps/* (extend common or base)
Real example from my projects:
// layers/base/nuxt.config.ts - UI foundation
export default defineNuxtConfig({
modules: [
"@unocss/nuxt", // Shared styling
"@pinia/nuxt", // State management
"@nuxt/eslint", // Linting
],
});
// layers/common/nuxt.config.ts - Extends base
export default defineNuxtConfig({
extends: [resolve("../base")],
// Add common business logic, types, composables
});
// apps/auth/nuxt.config.ts - Extends common
export default defineNuxtConfig({
extends: [resolve("../../layers/common")],
// Add app-specific configuration
});
The power: Change UnoCSS config in base layer β all apps immediately use new configuration.
3. Libs - Pure Logic, No Framework
libs/
βββ utils/
β βββ src/
β β βββ date.ts
β β βββ string.ts
β β βββ validators.ts
β βββ package.json
β βββ tsconfig.json
βββ types/
βββ src/
β βββ index.ts
βββ package.json
Purpose: Framework-agnostic code that doesn't depend on Vue or Nuxt.
Why separate libs?
- Can be used in Node.js scripts
- Can be used in non-Nuxt projects
- Easier to test in isolation
- Can be published as standalone packages
Example use case:
// libs/utils/src/date.ts
export function formatDate(date: Date): string {
return new Intl.DateTimeFormat("en-US").format(date);
}
// Use in Nuxt app
import { formatDate } from "@velora/utils";
// Use in Node.js script
import { formatDate } from "../libs/utils/src/date";
// Use in any project
4. Plugins - Standalone Vue Apps
plugins/
βββ widget/
βββ src/
βββ package.json
βββ vite.config.ts
Purpose: Vue 3 apps that don't need Nuxt features or can be embedded elsewhere.
Example use cases:
- Embeddable widgets for other sites
- Micro-frontends
- Standalone component libraries
Bun Workspaces: The Secret Sauce
The root package.json ties everything together:
{
"name": "velora",
"workspaces": [
"apps/*",
"layers/*",
"libs/*",
"plugins/*"
],
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"typecheck": "vue-tsc -b --noEmit",
"clean": "bun run clean:all"
}
}
What this enables:
Unified Dependency Management
# Install dependencies for ALL workspaces at once
bun install
# Run commands across all workspaces
bun lint # Lint everything
bun typecheck # Type check everything
bun clean # Clean all build artifacts
Shared Dependencies
// Root package.json - shared dev dependencies
{
"devDependencies": {
"typescript": "^5.3.3",
"vue-tsc": "^1.8.27",
"eslint": "^8.56.0"
}
}
Every workspace uses the same TypeScript version. No version conflicts. No duplicate installations.
Local Package Linking
// apps/auth/package.json
{
"dependencies": {
"@velora/utils": "workspace:*"
}
}
Bun automatically symlinks local packages. Changes to libs/utils are immediately available in apps/auth.
Real-World Example: Building Multi-Tenant SaaS
Let me show you how Velora solved a real problem in my last project.
The Requirement
Build a multi-tenant SaaS platform with:
- Customer app - Main product interface
- Admin dashboard - Manage tenants, users, billing
- Public website - Marketing, docs, blog
All three need to share:
- Design system (colors, components, fonts)
- Authentication logic
- API client
- TypeScript types
The Velora Solution
velora/
βββ apps/
β βββ customer/ # Main product
β βββ admin/ # Admin dashboard
β βββ website/ # Marketing site
βββ layers/
β βββ base/ # Design system
β β βββ components/
β β β βββ Button.vue
β β β βββ Input.vue
β β β βββ Card.vue
β β βββ uno.config.ts # Design tokens
β βββ common/ # Business logic
β βββ composables/
β β βββ useAuth.ts # Shared auth
β βββ services/
β β βββ api.ts # API client
β βββ types/
β βββ index.ts # Shared types
βββ libs/
βββ validators/ # Pure validation logic
Sharing the Design System
<!-- layers/base/components/Button.vue -->
<template>
<button
:class="buttonClasses"
@click="$emit('click')"
>
<slot />
</button>
</template>
<script setup lang="ts">
const props = defineProps<{
variant?: "primary" | "secondary" | "danger";
}>();
const buttonClasses = computed(() => ({
"px-4 py-2 rounded-lg font-semibold transition": true,
"bg-blue-600 text-white hover:bg-blue-700": props.variant === "primary",
"bg-gray-200 text-gray-800 hover:bg-gray-300": props.variant === "secondary",
"bg-red-600 text-white hover:bg-red-700": props.variant === "danger",
}));
</script>
Now all three apps use the same Button:
<!-- apps/customer/app/pages/index.vue -->
<template>
<Button variant="primary" @click="handleClick">
Get Started
</Button>
</template>
<!-- apps/admin/app/pages/dashboard.vue -->
<template>
<Button variant="danger" @click="deleteUser">
Delete User
</Button>
</template>
One change updates three apps. That's the power of layers.
Sharing Authentication Logic
// layers/common/composables/useAuth.ts
export function useAuth() {
const user = useSupabaseUser();
const supabase = useSupabaseClient();
async function signOut() {
await supabase.auth.signOut();
navigateTo("/");
}
const isAuthenticated = computed(() => !!user.value);
return {
user,
signOut,
isAuthenticated,
};
}
Every app can now use useAuth():
<!-- Any app can use this -->
<script setup>
const { user, signOut, isAuthenticated } = useAuth();
</script>
<template>
<div v-if="isAuthenticated">
<p>Welcome, {{ user.email }}</p>
<button @click="signOut">Logout</button>
</div>
</template>
Type Safety Across Apps
// layers/common/types/index.ts
export interface User {
id: string;
email: string;
role: "customer" | "admin";
}
export interface Project {
id: string;
name: string;
owner_id: string;
}
Every app has the same types. No drift. No inconsistencies.
// apps/customer/app/pages/projects.vue
import type { Project } from "#common/types";
const projects = ref<Project[]>([]);
Development Workflow: How I Use Velora Daily
Starting a New Feature
# Start working on customer app
cd apps/customer
bun dev
# Opens at localhost:3000
# Hot reload works
# Can edit shared components in layers/ and see changes immediately
Adding a Shared Component
# Create new component in base layer
# layers/base/components/Modal.vue
# Save file
# All apps that extend base layer can now use <Modal />
# No installation required
# No version bumping
# Just works
Running Tests
# From root - test everything
bun test
# Or test specific workspace
cd apps/customer
bun test
Type Checking
# From root - check all workspaces
bun typecheck
# Catches type errors across apps, layers, and libs
Deploying Apps Independently
# Build customer app
cd apps/customer
bun build
# Deploy to Vercel
# Build admin separately
cd apps/admin
bun build
# Deploy to different domain
# Each app deploys independently
# But shares code during development
Best Practices I Learned the Hard Way
1. Keep Layers Focused
Bad approach - kitchen sink layer:
layers/
βββ everything/
βββ components/ # 50 components
βββ composables/ # 30 composables
βββ stores/ # 15 stores
βββ utils/ # 40 utilities
Good approach - purpose-driven layers:
layers/
βββ base/ # Only UI/design system
βββ common/ # Only shared business logic
βββ e-commerce/ # Only e-commerce specific code
Why? Layers should have a clear purpose. Easy to understand, easy to maintain.
2. Put Pure Logic in Libs
// β Don't put in layers (Vue/Nuxt specific)
// layers/common/composables/validators.ts
export function useEmailValidator() {
const email = ref("");
// Uses Vue ref...
}
// β
Do put in libs (framework agnostic)
// libs/validators/src/email.ts
export function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
Why? Libs can be reused anywhere. Layers are Nuxt-specific.
3. Use Alias for Clean Imports
// β Relative path hell
import { Button } from "../../../layers/base/components/Button.vue";
// β
Clean alias
import { Button } from "#base/components/Button.vue";
Configure in each workspace:
// nuxt.config.ts
export default defineNuxtConfig({
alias: {
"#base": resolve("../../layers/base"),
"#common": resolve("../../layers/common"),
},
});
4. Version Control: What to Commit
# .gitignore
node_modules/
.nuxt/
dist/
.output/
bun.lock # β Do commit lock files!
Commit lock files! This ensures everyone uses the same dependency versions.
5. Set Up Clear Ownership
In a team environment:
apps/customer/ # Team A owns
apps/admin/ # Team B owns
layers/base/ # Design system team owns
layers/common/ # Platform team owns
Use CODEOWNERS file:
# .github/CODEOWNERS
/apps/customer/** @team-customer
/apps/admin/** @team-admin
/layers/base/** @team-design
/layers/common/** @team-platform
When NOT to Use a Monorepo
Monorepos aren't always the answer. Here's when to stick with separate repos:
Multiple Teams, Different Tech Stacks
If one team uses Nuxt and another uses Next.js, separate repos make more sense.
Completely Unrelated Projects
repo/
βββ todo-app/ # Personal project
βββ blog/ # Different project
βββ game/ # Totally different
These shouldn't be in a monorepo. They don't share code or purpose.
Small Projects
One app with no shared code? You don't need a monorepo. Start simple.
Rule of thumb: If you're not actively sharing code between projects, you don't need a monorepo.
Velora vs Other Solutions
Velora vs Nx
Nx is powerful but complex.
Nx:
- Framework agnostic
- Powerful caching
- Steep learning curve
- Heavy tooling
Velora:
- Nuxt-focused
- Simpler architecture
- Easier to understand
- Lighter weight
Use Nx if: You have a large team, need advanced caching, use multiple frameworks.
Use Velora if: You're using Nuxt, want simplicity, and need shared layers.
Velora vs Turborepo
Turborepo focuses on build optimization.
Turborepo:
- Build pipeline orchestration
- Great caching
- Less opinionated about structure
Velora:
- Opinionated Nuxt structure
- Leverages Nuxt layers
- Clear architectural patterns
Use Turborepo if: You need advanced build orchestration and caching.
Use Velora if: You want a ready-to-go Nuxt monorepo structure.
Getting Started with Velora
Installation
# Clone the template
git clone https://github.com/ojaswiat/velora my-monorepo
cd my-monorepo
# Install all dependencies
bun install
# Start developing an app
cd apps/auth
bun dev
Adding Your First App
# Create new app directory
mkdir -p apps/my-app
cd apps/my-app
# Initialize package.json
bun init
Create nuxt.config.ts:
import { createResolver } from "@nuxt/kit";
const { resolve } = createResolver(import.meta.url);
export default defineNuxtConfig({
alias: {
"#my-app": resolve("."),
},
compatibilityDate: "2025-10-17",
extends: [resolve("../../layers/base")],
});
Create app.vue:
<template>
<div>
<h1>My New App</h1>
<NuxtPage />
</div>
</template>
Run it:
bun dev
Your app now has access to all components, composables, and configuration from the base layer.
Creating a Custom Layer
# Create new layer
mkdir -p layers/my-feature
cd layers/my-feature
bun init
Create nuxt.config.ts:
import { createResolver } from "@nuxt/kit";
const { resolve } = createResolver(import.meta.url);
export default defineNuxtConfig({
extends: [resolve("../base")], // Extend base layer
alias: {
"#my-feature": resolve("."),
},
});
Add components, composables, or utilities specific to this feature.
Apps can now extend your custom layer:
// apps/my-app/nuxt.config.ts
export default defineNuxtConfig({
extends: [
resolve("../../layers/base"),
resolve("../../layers/my-feature"),
],
});
Troubleshooting Common Issues
Issue 1: "Cannot find module '#common/types'"
Problem: TypeScript can't resolve layer aliases.
Solution: Run bun dev:prepare to generate .nuxt types:
cd apps/your-app
bun dev:prepare
Issue 2: Changes in Layer Not Reflecting
Problem: Hot reload not picking up layer changes.
Solution: Restart dev server when changing layer configuration files (nuxt.config.ts, uno.config.ts).
Component changes should hot reload automatically.
Issue 3: Dependency Version Conflicts
Problem: Different workspaces installing different versions.
Solution: Move common dependencies to root package.json:
// root package.json
{
"devDependencies": {
"typescript": "^5.3.3", // Everyone uses this version
"vue-tsc": "^1.8.27"
}
}
Run bun install from root to sync versions.
The Future of Velora
I'm actively using and improving Velora. Planned enhancements:
- E2E testing setup with Playwright
- More layer examples (auth, payments, analytics)
- CI/CD templates for deploying multiple apps
- Docker configurations for containerized apps
- Storybook integration for component library documentation
Conclusion: Monorepos for the Win
Building Velora taught me that architecture matters, even for small teams.
A well-organized monorepo saves time, reduces bugs, and makes collaboration smoother. But it's not magicβit's a tool that solves specific problems.
Use a monorepo when:
- You have multiple related applications
- You're sharing significant amounts of code
- You want consistent tooling and dependencies
- You need to update shared code frequently
Don't use a monorepo when:
- Projects are truly independent
- You're working solo on one app
- Different tech stacks make sense
- Simplicity is more important than code sharing
For me, Velora transformed how I build web applications. No more copy-paste between projects. No more out-of-sync components. Just shared code that works everywhere.
Check out Velora on GitHub. Fork it, adapt it, make it yours.
And if you're building multiple Nuxt apps, give monorepos a try. Your future self will thank you.
If this helps you understand monorepos better or inspires you to try this architecture, let's connect! Follow me on Twitter or LinkedIn for more web architecture insights.
Support My Work
If this guide helped you understand monorepo architecture, saved you from copy-paste hell, or inspired better code organization, I'd greatly appreciate your support! Creating templates, documenting best practices, and sharing architectural knowledge takes considerable time and effort. Your contribution helps me continue building open-source tools and educational content.
β Buy me a coffee - Every contribution, big or small, means the world to me and keeps me motivated to create more content!
Cover image by Indira Tjokorda on Unsplash