Sharing Code Between Applications Using Yarn Symlinks: A Simple Alternative to Complex Monorepos

Have you ever found yourself duplicating utilities, data types, or components across different applications? If you work on multiple projects that need to share common code, you’ve likely faced the dilemma of how to manage this shared code efficiently.

Recently, while exploring different approaches to share code between applications, I stumbled upon an elegant and simple solution that many developers overlook: Yarn symlinks. This technique might be the perfect answer if you’re looking for a lightweight alternative to complex monorepo setups.

The Problem: Duplicated Code Everywhere

Imagine you have three applications: a React frontend, a Node.js API, and a React Native mobile app. All of them need:

  • Common validators
  • Shared TypeScript types
  • Date formatting utilities
  • Email validation functions
  • Application constants

The traditional approach would be:

  1. 📁 Copy and paste code into each project
  2. 📦 Publish separate npm packages
  3. 🏗️ Set up a complex monorepo with Lerna or Rush

The problems are obvious:

  • 🔴 Duplication: The same code in multiple places
  • 🔴 Inconsistencies: Different versions of the same code
  • 🔴 Maintenance: Updates in multiple places
  • 🔴 Complexity: Heavy configurations for simple problems

Yarn symlinks offer an elegant middle-ground solution. With a simple command, you can link local code between projects:

yarn add link:/path/to/local/folder

This command creates a symbolic link to a package on your local filesystem, perfect for developing related packages in monorepo environments.

✅ It’s Ideal If:

  • You’re looking for a simple solution to share code
  • You usually release your applications together (e.g., frontend + backend)
  • You want real-time development without complex builds
  • You have a small team working on related projects

❌ It’s Not The Best Option If:

  • You want to publish packages independently
  • You need to pin specific versions of shared code
  • You work with distributed teams with different release cycles
  • You require strict semantic versioning

Step-by-Step Setup

Project Structure

Let’s create a simple monorepo with two applications and a shared library:

monorepo-example/
├── shared-lib/
│   ├── package.json
│   ├── yarn.lock
│   └── src/
│       ├── index.ts
│       ├── utils/
│       └── types/
├── app-frontend/
│   ├── package.json
│   ├── yarn.lock
│   └── src/
├── app-backend/
│   ├── package.json
│   ├── yarn.lock
│   └── src/
└── README.md

Step 1: Create the Shared Library

mkdir monorepo-example && cd monorepo-example
mkdir shared-lib && cd shared-lib
npm init -y

Create the basic structure:

// shared-lib/src/index.ts
export * from './utils/validators';
export * from './utils/formatters';
export * from './types/common';

// shared-lib/src/utils/validators.ts
export const isValidEmail = (email: string): boolean => {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailRegex.test(email);
};

export const isValidPhone = (phone: string): boolean => {
  const phoneRegex = /^\+?[\d\s\-\(\)]+$/;
  return phoneRegex.test(phone);
};

// shared-lib/src/utils/formatters.ts
export const formatCurrency = (amount: number, currency = 'EUR'): string => {
  return new Intl.NumberFormat('es-ES', {
    style: 'currency',
    currency,
  }).format(amount);
};

export const formatDate = (date: Date): string => {
  return new Intl.DateTimeFormat('es-ES').format(date);
};

// shared-lib/src/types/common.ts
export interface User {
  id: string;
  email: string;
  name: string;
  createdAt: Date;
}

export interface ApiResponse<T> {
  success: boolean;
  data?: T;
  error?: string;
}

Configure the package.json:

{
  "name": "shared-lib",
  "version": "1.0.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch"
  },
  "devDependencies": {
    "typescript": "^5.0.0"
  }
}

Step 2: Configure TypeScript

// shared-lib/tsconfig.json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Step 3: Create the Applications

cd ..
mkdir app-frontend && cd app-frontend
npm init -y

cd ..
mkdir app-backend && cd app-backend
npm init -y

From each application, run:

# In app-frontend/
cd app-frontend
yarn add link:../shared-lib

# In app-backend/
cd ../app-backend
yarn add link:../shared-lib

This will add shared-lib as a dependency in both package.json files:

{
  "dependencies": {
    "shared-lib": "link:../shared-lib"
  }
}

Step 5: Use the Shared Code

// app-frontend/src/components/UserForm.tsx
import React, { useState } from 'react';
import { isValidEmail, User } from 'shared-lib';

export const UserForm: React.FC = () => {
  const [email, setEmail] = useState('');
  const [isValid, setIsValid] = useState(false);

  const handleEmailChange = (value: string) => {
    setEmail(value);
    setIsValid(isValidEmail(value));
  };

  return (
    <form>
      <input
        type="email"
        value={email}
        onChange={(e) => handleEmailChange(e.target.value)}
        style={{ borderColor: isValid ? 'green' : 'red' }}
      />
      <p>Email {isValid ? 'valid' : 'invalid'}</p>
    </form>
  );
};
// app-backend/src/controllers/userController.ts
import { isValidEmail, User, ApiResponse } from 'shared-lib';

export class UserController {
  async createUser(userData: any): Promise<ApiResponse<User>> {
    if (!isValidEmail(userData.email)) {
      return {
        success: false,
        error: 'Invalid email'
      };
    }

    // User creation logic...
    const user: User = {
      id: generateId(),
      email: userData.email,
      name: userData.name,
      createdAt: new Date()
    };

    return {
      success: true,
      data: user
    };
  }
}

Local Development Workflow

Real-Time Development

To have changes in shared-lib reflected automatically:

# Terminal 1: Build library in watch mode
cd shared-lib
yarn build --watch

# Terminal 2: Develop app-frontend
cd app-frontend
yarn dev

# Terminal 3: Develop app-backend
cd app-backend
yarn dev

Limitations with Create React App

If you use Create React App, you’ll need some additional configuration because CRA doesn’t detect changes outside its src folder:

# Install CRACO to customize webpack
npm install @craco/craco --save-dev
// craco.config.js
const path = require('path');

module.exports = {
  webpack: {
    configure: (webpackConfig) => {
      // Allow imports from outside src/
      webpackConfig.resolve.plugins = webpackConfig.resolve.plugins.filter(
        plugin => plugin.constructor.name !== 'ModuleScopePlugin'
      );

      // Add shared-lib to watched directories
      webpackConfig.watchOptions = {
        ignored: /node_modules\/(?!shared-lib)/,
      };

      return webpackConfig;
    },
  },
};

Continuous Integration

In CI/CD, you need to build the shared library before the applications:

# .github/workflows/deploy.yml
name: Build and Deploy
on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2

      - name: Setup Node.js
        uses: actions/setup-node@v2
        with:
          node-version: '18'

      # 1. Build shared-lib first
      - name: Build shared library
        working-directory: ./shared-lib
        run: |
          yarn install
          yarn build

      # 2. Build frontend
      - name: Build frontend
        working-directory: ./app-frontend
        run: |
          yarn install  # Automatically links shared-lib
          yarn build

      # 3. Build backend
      - name: Build backend
        working-directory: ./app-backend
        run: |
          yarn install
          yarn build

Automation Script

To simplify development, you can create scripts that manage everything:

// package.json (monorepo root)
{
  "name": "monorepo-example",
  "scripts": {
    "install:all": "yarn install:shared && yarn install:frontend && yarn install:backend",
    "install:shared": "cd shared-lib && yarn install",
    "install:frontend": "cd app-frontend && yarn install",
    "install:backend": "cd app-backend && yarn install",
    "build:all": "yarn build:shared && yarn build:frontend && yarn build:backend",
    "build:shared": "cd shared-lib && yarn build",
    "build:frontend": "cd app-frontend && yarn build",
    "build:backend": "cd app-backend && yarn build",
    "dev:shared": "cd shared-lib && yarn dev",
    "dev:frontend": "cd app-frontend && yarn dev",
    "dev:backend": "cd app-backend && yarn dev",
    "link:all": "cd app-frontend && yarn add link:../shared-lib && cd ../app-backend && yarn add link:../shared-lib"
  },
  "devDependencies": {
    "concurrently": "^7.6.0"
  }
}

For parallel development:

{
  "scripts": {
    "dev:all": "concurrently \"yarn dev:shared\" \"yarn dev:frontend\" \"yarn dev:backend\""
  }
}

Advantages and Disadvantages

✅ Advantages

  1. Simplicity: Minimal configuration compared to complex monorepos
  2. Real-time development: Instant changes between projects
  3. No duplication: Single place for shared code
  4. Flexibility: Each project maintains its independence
  5. No overhead: No need for complex additional tools

❌ Disadvantages

  1. Limited versioning: You can’t pin specific versions
  2. Fragile: Changes in shared-lib can break all apps
  3. More complex CI/CD: Need to build shared-lib first
  4. Limitations with some tools: Like Create React App
  5. Not scalable: Can be problematic for large teams

When to Evolve

Towards Yarn Workspaces

If you notice you have many duplicated dependencies:

// package.json (root)
{
  "private": true,
  "workspaces": [
    "shared-lib",
    "app-frontend",
    "app-backend"
  ]
}

Towards Lerna

If you need to publish packages independently:

npm install --global lerna
lerna init
lerna bootstrap
lerna version
lerna publish

Towards Rush or Nx

For large teams with complex build and testing needs.

Real-World Use Cases

Startup Tech Stack

startup-monorepo/
├── shared-core/          # Types, validators, constants
├── web-app/             # React frontend
├── mobile-app/          # React Native
├── api-server/          # Node.js API
└── admin-panel/         # Admin React app

Microservices with Shared Code

microservices-suite/
├── shared-types/        # TypeScript interfaces
├── shared-utils/        # Common utilities
├── user-service/        # User microservice
├── payment-service/     # Payment microservice
└── notification-service/ # Notification microservice

Best Practices

1. Clear Shared Library Structure

// shared-lib/src/index.ts
// Exports organized by module
export * from './validation';
export * from './formatting';
export * from './types';
export * from './constants';
export * from './utils';

2. Manual Semantic Versioning

Even if you don’t use npm publish, maintain a changelog:

# CHANGELOG.md

## [1.2.0] - 2024-07-15
### Added
- New formatCurrency function
- isValidPhone validator

### Changed
- isValidEmail is now stricter

### Breaking Changes
- Renamed User.createdDate to User.createdAt

3. Testing Shared Code

// shared-lib/package.json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch"
  }
}

4. Clear Documentation

/**
 * Validates if an email has the correct format
 * @param email - The email to validate
 * @returns true if the email is valid
 * @example
 * ```typescript
 * isValidEmail('user@example.com') // true
 * isValidEmail('invalid-email') // false
 * ```
 */
export const isValidEmail = (email: string): boolean => {
  // ...
};

Conclusion

Yarn symlinks offer an elegant and simple solution to share code between related applications. It’s not the perfect solution for all cases, but it can be ideal if you’re looking for:

  • A lightweight alternative to complex monorepo setups
  • Real-time development between related projects
  • Simplicity over advanced features
  • An intermediate solution before evolving to more complex tools

When should you consider this solution?

  • Small teams (2-5 developers)
  • Related projects with similar release cycles
  • Need to share code without additional complexity
  • As an intermediate step towards a more sophisticated monorepo

The key is understanding that there’s no one-size-fits-all solution. Yarn symlinks can be perfect for your current situation, and you can always evolve towards more complex tools when your project requires it.

Have you used symlinks in your projects? What strategies do you use to share code between applications? Share your experience in the comments!


Additional resources: