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.

πŸ“… Published: July 20, 2025 ✏️ Updated: August 18, 2025 By Ojaswi Athghara
#monorepo #nuxt-layers #bun-workspace #scalability #architecture #vue-apps

Velora: Building a Nuxt Monorepo Template for Scalable Web Applications

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

Related Blogs

Ojaswi Athghara

SDE, 4+ Years

Β© ojaswiat.com 2025-2027