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

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

1627 words

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:

Comments

Latest Posts

6 min

1231 words

Are you tired of seeing imports like import Logger from "../../../utils/logger" in your Node.js projects? If you develop applications with complex folder structures, you’ve surely encountered the labyrinth of dots and slashes that relative imports can become. Fortunately, TypeScript offers an elegant solution: Path Aliases.

In this complete guide, you’ll learn to configure path aliases in Node.js projects with TypeScript, forever eliminating those confusing imports and significantly improving the readability and maintainability of your code.

7 min

1379 words

CookieStore API: The Async Future of Cookie Management in JavaScript

For decades, web developers have depended on the old and limited document.cookie interface to handle cookies in the browser. This synchronous API, with its peculiar string syntax, has been a source of frustration and errors. But that’s changing with the arrival of CookieStore API, a modern and asynchronous interface that promises to revolutionize how we interact with cookies.

The Problem with document.cookie

Before diving into CookieStore, let’s recall the headaches document.cookie has caused us:

7 min

1313 words

The Filament v4 Beta has officially arrived, and it’s undoubtedly the most ambitious and comprehensive update in this framework’s history. After exploring in detail all the new features, I can confidently say that this version represents a quantum leap in terms of performance, ease of use, and development capabilities.

In this comprehensive analysis, we’ll explore each of the new features in Filament v4, explaining not just what’s new, but also how these improvements can transform your workflow and your application possibilities.

4 min

728 words

The Filament team has announced exciting details about the upcoming Filament v4 Beta release, and it’s undoubtedly the most anticipated version to date. Filament v4 is the largest and most feature-packed release Filament has ever had, surpassing even the massive v3 that required over 100 minor versions.

Most Notable Features of Filament v4

Nested Resources

One of the longest-standing community requests is finally becoming reality. Nested resources allow you to operate on a Filament resource within the context of a parent resource.

11 min

2211 words

How many times have you started a Laravel project manually creating models, controllers, migrations, factories, form requests, and tests one by one? If you’re like most Laravel developers, you’ve probably wasted countless hours on these repetitive tasks that, while necessary, don’t add direct value to your application’s business logic.

Laravel Blueprint is completely changing this paradigm. This code generation tool, created by Jason McCreary (the same genius behind Laravel Shift), allows you to generate multiple Laravel components from a single, readable, and expressive YAML file. In this deep analysis, we’ll explore how Blueprint can transform your development workflow and why it’s gaining traction in the Laravel community.

1 min

106 words

Options Pattern in Golang

Option pattern is a functional programming pattern that is used to provide optional arguments to a function that can be used to modify its behavior.

How to create a simple event streaming in Laravel?

Event streams provide you with a way to send events to the client without having to reload the page. This is useful for things like updating the user interface in real-time changes are made to the database.