Scaffolding a monorepo with multiple apps and shared packages

monoreposveltetypescript

Build a production-ready Turborepo monorepo with SvelteKit apps, shared component libraries, and centralized configuration using pnpm workspaces.

This architecture demonstrates how to scale SvelteKit development across multiple applications while maintaining consistent tooling, shared components, and optimized build pipelines. The approach leverages Turborepo’s intelligent caching and task orchestration alongside pnpm’s efficient workspace management.

Repository Structure

The monorepo separates consumer applications from internal packages, creating clear boundaries between public-facing code and shared infrastructure:

apps/
  demo/              - Main SvelteKit app with Tailwind CSS v4
  docs/              - Documentation site with mdsvex
packages/
  components/        - Shared Svelte 5 component library
  shared/            - Utilities: cn(), formatters
  eslint-config/     - Shared ESLint 9 flat config
  typescript-config/ - Shared TypeScript configs
  tailwind-config/   - Shared Tailwind v4 styles/theme

Applications Layer

Both applications use SvelteKit with identical tooling but serve different purposes. The demo app showcases component usage and interactive examples, while the docs app extends SvelteKit with mdsvex for markdown-based documentation, enabling rich technical writing with Shiki syntax highlighting and custom remark/rehype plugins.

Package Layer

Internal packages fall into two categories: runtime dependencies (components, shared utilities) and development dependencies (config packages). Runtime packages ship actual code consumed by apps, while config packages standardize tooling across the workspace.

Shared Configuration Strategy

Centralizing configuration prevents drift and ensures all workspace members follow identical patterns. Each config package exports reusable settings consumed by apps and other packages.

TypeScript Configuration

The @repo/typescript-config package provides two base configurations:

base.json - For libraries and utilities:

packages/typescript-config/base.json
{
	"compilerOptions": {
		"target": "ES2022",
		"module": "ESNext",
		"lib": ["ES2022"],
		"strict": true,
		"declaration": true
	}
}

Used by @repo/shared to generate type declarations for exported utilities.

svelte.json - For SvelteKit applications:

packages/typescript-config/svelte.json
{
	"extends": "./base.json",
	"compilerOptions": {
		"target": "ESNext",
		"allowJs": true,
		"sourceMap": true,
		"types": ["svelte"]
	}
}

Consumed by apps and @repo/components via:

apps/demo/tsconfig.json
{
	"extends": "@repo/typescript-config/svelte.json"
}

ESLint Configuration

Modern ESLint 9 uses flat config arrays instead of legacy .eslintrc files. The shared config exports a composable array:

packages/eslint-config/index.js
import tseslint from 'typescript-eslint'
import eslintPluginSvelte from 'eslint-plugin-svelte'

export const config = [
	...tseslint.configs.recommended,
	...eslintPluginSvelte.configs['flat/recommended'],
	{
		rules: {
			'@typescript-eslint/no-unused-vars': 'warn'
		}
	}
]

Apps import and extend this config:

apps/demo/eslint.config.js
import { config } from '@repo/eslint-config/index.js'

export default [
	...config,
	{
		ignores: ['.svelte-kit/**', 'build/**']
	}
]

Tailwind CSS v4 Configuration

Tailwind v4’s new architecture uses CSS imports instead of JavaScript config files. The @repo/tailwind-config package exports a centralized stylesheet:

packages/tailwind-config/styles.css
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';

@theme {
	--font-family-sans: 'Inter', system-ui, sans-serif;
	--font-family-mono: 'JetBrains Mono', monospace;
}

Apps consume this via a single import:

apps/demo/src/app.css
@import '@repo/tailwind-config';

The @tailwindcss/vite plugin handles dynamic CSS generation during development and optimized production builds.

Component Library Architecture

The @repo/components package demonstrates headless UI patterns using bits-ui primitives with Svelte 5’s modern reactivity system.

Package Structure

Components export as named modules, allowing tree-shaking and selective imports:

packages/components/package.json
{
  "exports": {
    "./Button": "./src/lib/Button.svelte",
    "./Dialog": "./src/lib/Dialog/index.js",
    "./next": "./src/lib/next/index.js"
  }
}

Apps import specific components:

apps/demo/src/routes/+page.svelte
<script>
	import Button from '@repo/components/Button'
	import { Dialog, DialogTitle, DialogBody } from '@repo/components/Dialog'
</script>

Development Workflow

The library uses @sveltejs/package in watch mode during development:

packages/components/package.json
{
	"scripts": {
		"dev": "svelte-package -w",
		"build": "svelte-package"
	}
}

Running pnpm --filter @repo/components dev enables hot module replacement—changes to components immediately reflect in consuming apps without manual rebuilds.

Composition Patterns

Complex components use composition over configuration. Instead of a monolithic <Dialog> with 20 props, smaller subcomponents provide flexibility:

example usage
<Dialog bind:open>
	<DialogTitle>Confirm Action</DialogTitle>
	<DialogBody>
		<p>This operation cannot be undone.</p>
	</DialogBody>
	<DialogActions>
		<Button on:click={() => (open = false)}>Cancel</Button>
		<Button variant="danger">Confirm</Button>
	</DialogActions>
</Dialog>

Each subcomponent handles its own styling and behavior while coordinating through Svelte contexts.

Shared Utilities

The @repo/shared package provides framework-agnostic utilities compiled to JavaScript for universal consumption.

Class Name Merging

The cn() function combines clsx and tailwind-merge:

packages/shared/src/cn.ts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'

export function cn(...inputs: ClassValue[]) {
	return twMerge(clsx(inputs))
}

This handles Tailwind class conflicts intelligently:

cn('px-4 py-2', 'px-6')
// Result: 'py-2 px-6' (px-4 is removed)

Formatters

Locale-aware formatters provide consistent data presentation:

packages/shared/src/formatters.ts
export function formatNumber(value: number, locale = 'en-US'): string {
	return new Intl.NumberFormat(locale).format(value)
}

export function formatDate(date: Date, locale = 'en-US'): string {
	return new Intl.DateTimeFormat(locale).format(date)
}

export function formatRelativeTime(
	value: number,
	unit: Intl.RelativeTimeFormatUnit,
	locale = 'en-US'
): string {
	return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(value, unit)
}

Turborepo Pipeline Configuration

Turborepo orchestrates tasks across the workspace with dependency-aware execution and intelligent caching.

turbo.json
{
	"pipeline": {
		"build": {
			"dependsOn": ["^build"],
			"outputs": [".svelte-kit/**", ".vercel/**", "dist/**"]
		},
		"dev": {
			"cache": false,
			"persistent": true
		},
		"lint": {
			"dependsOn": ["^build"]
		},
		"check": {
			"dependsOn": ["^build"]
		}
	}
}

Dependency Graph Execution

The ^build notation creates a dependency graph. Running pnpm build from the root:

  1. Builds @repo/shared (no dependencies)
  2. Builds @repo/components (depends on shared)
  3. Builds apps in parallel (depend on components)

Turborepo caches outputs based on input fingerprints. If @repo/shared hasn’t changed, its build is skipped and cached artifacts are restored.

Development Mode

The dev task runs multiple processes concurrently:

pnpm dev
# Starts:
# - apps/demo dev server
# - apps/docs dev server
# - packages/components in watch mode

Changes to components automatically trigger HMR in consuming apps without full rebuilds.

Workspace Dependencies

Internal packages use the workspace:* protocol, which pnpm resolves to local workspace versions:

apps/demo/package.json
{
	"dependencies": {
		"@repo/components": "workspace:*",
		"@repo/shared": "workspace:*"
	},
	"devDependencies": {
		"@repo/eslint-config": "workspace:*",
		"@repo/tailwind-config": "workspace:*",
		"@repo/typescript-config": "workspace:*"
	}
}

During publishing, pnpm replaces workspace:* with actual version ranges. For local development, it symlinks packages, enabling instant updates without reinstallation.

Running Workspace Commands

Turborepo provides global task execution, while pnpm filters enable targeted operations.

Global Tasks

Run tasks across all workspaces:

pnpm build    # Build everything
pnpm lint     # Lint all packages
pnpm check    # Type-check all packages

Filtered Tasks

Target specific workspaces:

pnpm --filter demo dev          # Start demo app
pnpm --filter @repo/components build   # Build components
pnpm --filter docs check        # Type-check docs app

Multiple Filters

Combine filters for batch operations:

pnpm --filter "./apps/*" build  # Build all apps
pnpm --filter "@repo/*" lint    # Lint all packages

Technology Choices

Why Turborepo Over Nx or Rush

Turborepo prioritizes simplicity and speed. Unlike Nx’s opinionated generators or Rush’s strict publishing workflows, Turborepo focuses solely on task orchestration and caching. The entire configuration fits in 20 lines of JSON.

Why pnpm Over npm or Yarn

pnpm’s symlink-based approach saves gigabytes of disk space and enables instant cross-package changes. The workspace:* protocol provides better local development ergonomics than npm workspaces or Yarn’s portal: protocol.

Why Tailwind CSS v4

Version 4’s CSS-based configuration eliminates JavaScript config files and enables better IDE support. The new @tailwindcss/vite plugin generates CSS on demand, reducing build times by 40-60% compared to v3’s PostCSS pipeline.

Why ESLint 9 Flat Config

Flat config arrays are easier to compose and debug than legacy cascading configs. TypeScript support is first-class, and the new config format enables better IDE integration and error messages.

Best Practices

Keep Packages Focused

Each package should solve one problem. @repo/shared handles utilities, not components. @repo/eslint-config handles linting, not TypeScript. Clear boundaries prevent circular dependencies and make packages easier to reason about.

Version Configuration Packages Together

When updating ESLint or TypeScript, bump the config package version and update dependents simultaneously. Mismatched config versions create subtle bugs that are difficult to debug.

Use Type Imports for Shared Types

When packages share types, use import type to avoid circular runtime dependencies:

import type { ButtonProps } from '@repo/components/Button'

This ensures type-only imports are erased during compilation.

Leverage Turborepo Caching

Structure tasks to maximize cache hits. Separate type checking from builds, keep test files colocated with source, and avoid tasks that generate timestamps or random IDs.

This architecture demonstrates how modern tooling—Turborepo, pnpm, Tailwind v4, ESLint 9—can create a maintainable monorepo foundation. The key is balancing shared infrastructure with application flexibility, centralizing configuration without creating bottlenecks, and optimizing for both development speed and production quality.

© 2025 vigerust.dev. All rights reserved.