---
# === IDENTITY ===
id: software/migrations/rails-to-nodejs/2026
canonical_question: "How do I migrate a Ruby on Rails app to Node.js (Express/NestJS)?"
aliases:
  - "Rails to Node.js migration guide"
  - "convert Rails to Express"
  - "replace Ruby on Rails with Node.js"
  - "Rails to NestJS migration"
  - "modernize Rails app with Node.js"
  - "Ruby on Rails to JavaScript backend"
entity_type: software_reference
domain: software > migrations > rails_to_nodejs
region: global
jurisdiction: global
temporal_scope: 2018-2026

# === VERIFICATION ===
last_verified: 2026-05-17
confidence: 0.91
version: 2.1
first_published: 2026-02-17

# === TEMPORAL VALIDITY ===
temporal_validity:
  status: evolving
  last_breaking_change: "NestJS 11 (2025) — Express 5 is now the default HTTP engine"
  next_review: 2026-11-13
  change_sensitivity: medium

# === CONSTRAINTS ===
constraints:
  - "ActiveRecord patterns (callbacks, validations-in-model, lazy loading) have no direct ORM equivalent in Node.js — must refactor to explicit service-layer logic"
  - "Rails convention-over-configuration is lost — NestJS restores some structure via modules/decorators but Express requires manual architectural decisions"
  - "Rails asset pipeline (Sprockets/Webpacker/Propshaft) has no backend-framework equivalent — requires separate frontend tooling (Vite, esbuild, Webpack)"
  - "Do not run rails db:migrate and prisma migrate on the same database simultaneously — designate one migration authority during transition"
  - "Sidekiq and BullMQ use incompatible Redis data structures — cannot share job queues between Rails and Node.js"

# === SKIP CONDITIONS ===
skip_this_unit_if:
  - condition: "Migrating from Rails to a Python framework (Django/FastAPI)"
    use_instead: "software/migrations/rails-to-django/2026"
  - condition: "Migrating between Node.js frameworks only (Express to NestJS)"
    use_instead: "software/migrations/express-to-nestjs/2026"
  - condition: "Rails app is small, stable, and performant — no business reason to migrate"
    use_instead: "Upgrade Rails version instead (Rails 8 with Kamal 2, Solid adapters, Thruster)"

# === AGENT HINTS ===
inputs_needed:
  - key: rails_version
    question: "Which version of Rails is the source application running?"
    type: choice
    options: ["Rails 6.x", "Rails 7.x", "Rails 8.x"]
  - key: target_framework
    question: "Which Node.js framework should the application migrate to?"
    type: choice
    options: ["NestJS (opinionated, closest to Rails)", "Express (minimal, maximum flexibility)", "Fastify (performance-first)"]
  - key: app_complexity
    question: "How complex is the Rails application?"
    type: choice
    options: ["Small (< 20 models, API-only)", "Medium (20-80 models, some background jobs)", "Large (80+ models, multiple job queues, ActionCable, complex auth)"]

# === DISTRIBUTION ===
canonical_source: "https://knowledgelib.io/software/migrations/rails-to-nodejs/2026"
suggested_citation: "Source: knowledgelib.io — AI Knowledge Library (verified 2026-05-17)"

# === RELATED UNITS ===
related_kos:
  related_to:
    - id: "software/migrations/php-to-nodejs/2026"
      label: "PHP to Node.js Migration"
    - id: "software/migrations/javascript-to-typescript/2026"
      label: "JavaScript to TypeScript Migration"
  alternative_to:
    - id: "software/migrations/rails-to-django/2026"
      label: "Rails to Django Migration"
  solves:
    - id: "software/migrations/express-to-nestjs/2026"
      label: "Express to NestJS Migration"
  often_confused_with:
    - id: "software/migrations/rails-to-django/2026"
      label: "Rails to Django Migration (Python alternative, not JavaScript)"

# === SOURCES (8 authoritative sources) ===
# Types: official_docs, technical_blog, rfc_spec, academic_paper, community_resource, industry_report
sources:
  - id: src1
    title: "Migrating Our ToolJet Server From Ruby on Rails to Node.js"
    author: ToolJet Engineering
    url: https://blog.tooljet.com/how-we-migrated-tooljet-server-from-ruby-to-node-js/
    type: technical_blog
    published: 2022-04-15
    reliability: high
  - id: src2
    title: "NestJS vs. Ruby on Rails"
    author: Better Stack Community
    url: https://betterstack.com/community/guides/scaling-nodejs/nestjs-vs-rails/
    type: technical_blog
    published: 2024-08-20
    reliability: high
  - id: src3
    title: "LinkedIn Moved from Rails to Node: 27 Servers Cut and Up to 20x Faster"
    author: High Scalability
    url: http://highscalability.com/blog/2012/10/4/linkedin-moved-from-rails-to-node-27-servers-cut-and-up-to-2.html
    type: industry_report
    published: 2012-10-04
    reliability: high
  - id: src4
    title: "NestJS Documentation — Migration Guide (v10 to v11)"
    author: NestJS
    url: https://docs.nestjs.com/migration-guide
    type: official_docs
    published: 2025-02-01
    reliability: high
  - id: src5
    title: "Prisma 7 Release: Rust-Free, Faster, and More Compatible"
    author: Prisma
    url: https://www.prisma.io/blog/announcing-prisma-orm-7-0-0
    type: official_docs
    published: 2025-12-01
    reliability: high
  - id: src6
    title: "Ruby on Rails vs Node.js: A Head-to-Head Comparison"
    author: Kinsta
    url: https://kinsta.com/blog/ruby-on-rails-vs-node-js/
    type: technical_blog
    published: 2024-03-12
    reliability: moderate_high
  - id: src7
    title: "Why I Moved from Rails to Node.js"
    author: Marco Moauro
    url: https://implementing.substack.com/p/why-i-moved-from-rails-to-nodejs
    type: community_resource
    published: 2024-11-18
    reliability: moderate_high
  - id: src8
    title: "Announcing NestJS 11: What's New"
    author: Trilon Consulting
    url: https://trilon.io/blog/announcing-nestjs-11-whats-new
    type: technical_blog
    published: 2025-01-30
    reliability: high
---

# How to Migrate a Ruby on Rails App to Node.js (Express/NestJS)

## TL;DR

- **Bottom line**: Migrate incrementally using the strangler fig pattern — run Rails and Node.js side-by-side behind a reverse proxy, moving one domain boundary (routes + models + jobs) at a time. NestJS 11 is the strongest match for Rails developers due to its opinionated MVC structure, decorators, built-in DI, and CLI generators. [src1, src2]
- **Key tool/command**: `npx @nestjs/cli new my-app --strict && npx prisma init` to scaffold a NestJS 11 project with Prisma 7 ORM (closest equivalent to ActiveRecord migrations + query interface). [src4, src5]
- **Watch out for**: Directly porting ActiveRecord patterns (callbacks, validations-in-model, implicit lazy loading) to Node.js ORMs — they do not exist. Use explicit service-layer validation and eager loading instead. [src1, src7]
- **Works with**: Node.js 22+ LTS (or 24 LTS), NestJS 11+, Express 5.x, Prisma 7+, Drizzle ORM, TypeORM 0.3+, Ruby on Rails 6.x-8.x as source.

## Constraints
<!-- Agents: read this section before recommending any action from this unit.
     These are hard constraints that must not be violated. -->

- ActiveRecord callbacks (`before_save`, `after_create`, `around_update`) have no safe ORM equivalent in Node.js. Replicate logic explicitly in the service layer. Using TypeORM entity listeners or Prisma middleware as a substitute creates hidden side effects that are harder to debug than Rails callbacks. [src1, src7]
- Rails convention-over-configuration philosophy does not transfer. NestJS restores ~70% of this structure via modules, decorators, and CLI generators, but Express requires every architectural decision to be made manually. Budget extra planning time for Express migrations. [src2]
- The Rails asset pipeline (Sprockets in 6.x, Webpacker in 6.x, Propshaft/esbuild in 7.x/8.x) has no equivalent in Node.js backend frameworks. If the Rails app serves HTML views with embedded assets, a separate frontend build tool (Vite, esbuild, Webpack) or frontend framework (React, Next.js, Vue) is required. [src6]
- Never run `rails db:migrate` and `npx prisma migrate dev` on the same database simultaneously. Designate one tool as the sole migration authority during the transition period. Schema drift between the two is unrecoverable without manual intervention. [src5]
- Sidekiq and BullMQ use incompatible Redis data structures. Do not attempt to read Sidekiq's Redis keys from BullMQ or vice versa. During transition, run both job processors in parallel: new jobs go to BullMQ, existing jobs drain from Sidekiq. [src1]

## Quick Reference

| Rails Pattern | Node.js Equivalent | Example |
|---|---|---|
| `rails new myapp` | `npx @nestjs/cli new myapp --strict` | Scaffolds project with modules, controllers, services, strict TS [src4] |
| `rails generate scaffold Post` | `nest generate resource posts` | Creates module + controller + service + DTO + spec [src2, src4] |
| `ActiveRecord` (ORM) | `Prisma 7` / `Drizzle` / `TypeORM` | `prisma.post.findMany({ where: { published: true } })` [src5] |
| `db:migrate` / `db:rollback` | `npx prisma migrate dev` | Schema-first migrations with auto-generated SQL [src5] |
| `config/routes.rb` | `@Controller()` + `@Get()` decorators | `@Get(':id') findOne(@Param('id') id: string)` [src2, src4] |
| `before_action` / `after_action` | NestJS Guards + Interceptors | `@UseGuards(AuthGuard)` on controller or route [src2] |
| `ApplicationController` filters | Express middleware / NestJS Pipes | `app.use(cors()); app.use(helmet())` [src6] |
| ERB / Haml views | React / EJS / Pug (or API-only) | Most migrations go API-only; use `@Render('index')` for SSR [src1, src7] |
| `Sprockets` / `Propshaft` / `Webpacker` | Vite / esbuild (standalone) | `vite build` — Rails 7+/8 already uses esbuild [src6] |
| `Sidekiq` (background jobs) | `BullMQ` (Redis-backed) | `@Processor('email') process(job) { ... }` [src1] |
| `ActionMailer` | `Nodemailer` + `@nestjs-modules/mailer` | `transporter.sendMail({ to, subject, html })` [src1] |
| `RSpec` / `Minitest` | `Jest` / `Vitest` + `Supertest` | `it('returns 200', () => request(app).get('/').expect(200))` [src1, src2] |
| `Devise` (authentication) | `Passport.js` + `@nestjs/jwt` | `@UseGuards(JwtAuthGuard)` [src1] |
| `ActiveSupport::Callbacks` | NestJS Lifecycle Hooks | `onModuleInit()`, `onApplicationBootstrap()` [src4] |
| `Rails.credentials` / `dotenv` | `@nestjs/config` + `.env` files | `configService.get<string>('DATABASE_URL')` [src4] |
| `Pundit` / `CanCanCan` (authorization) | `CASL` / NestJS Guards | `@Roles('admin') @UseGuards(RolesGuard)` [src2] |
| `Solid Queue` / `Solid Cache` (Rails 8) | BullMQ + `cache-manager` (v6) | `@Cacheable({ ttl: 300 })` with NestJS CacheModule [src8] |
| `ActionCable` (WebSockets) | `Socket.io` / `@nestjs/websockets` | `@WebSocketGateway() @SubscribeMessage('events')` [src2] |

## Decision Tree

```
START: Should I migrate from Rails to Node.js?
├── Is the app primarily I/O-bound (APIs, real-time, microservices)?
│   ├── YES → Node.js is a strong fit (up to 20x throughput gain on I/O) [src3]
│   │   ├── Team prefers convention-over-configuration?
│   │   │   ├── YES → Use NestJS 11 (closest to Rails' opinionated structure) [src2, src8]
│   │   │   └── NO → Use Express 5 + hand-picked libraries (maximum flexibility) [src7]
│   │   ├── Need strict TypeScript + DI container?
│   │   │   ├── YES → NestJS 11 (built-in DI, decorators, modules) [src4]
│   │   │   └── NO → Express 5 or Fastify 5 with manual setup
│   │   ├── Targeting serverless/edge deployment?
│   │   │   ├── YES → Use Drizzle ORM (57KB runtime vs Prisma's 1.6MB) [src5]
│   │   │   └── NO → Use Prisma 7 (best DX, 3x faster than Prisma 5) [src5]
│   │   └── Need maximum raw HTTP performance?
│   │       ├── YES → Fastify 5 (2-3x faster than Express for pure routing) [src6]
│   │       └── NO → Express 5 (largest ecosystem, most middleware) [src6]
│   └── NO ↓
├── Is the app CPU-heavy (image processing, ML, complex computation)?
│   ├── YES → Stay on Rails or consider Go/Rust — Node.js single-thread limits CPU work [src6]
│   └── NO ↓
├── Is the app using Rails 8 Solid adapters (Queue, Cache, Cable)?
│   ├── YES → Rails 8 eliminated Redis dependency — reconsider migration need
│   └── NO ↓
├── Is the migration driven by unifying frontend + backend language?
│   ├── YES → Migrate to Node.js — single-language stack reduces context switching [src1]
│   └── NO ↓
├── Is the team already proficient in JavaScript/TypeScript?
│   ├── YES → Migrate incrementally (strangler fig pattern) [src1, src3]
│   └── NO → Invest in training first; premature migration increases risk
└── DEFAULT → Keep Rails if it works. Migrate only for clear technical or business reasons.
```

## Step-by-Step Guide

### 1. Audit the Rails application and plan boundaries

Map every Rails model, controller, background job, mailer, and third-party integration. Identify domain boundaries that can be extracted independently. Prioritize API endpoints and stateless services first — these have the lowest migration risk. [src1, src7]

```bash
# List all Rails models
find app/models -name "*.rb" | wc -l

# List all controllers and their actions
grep -r "def " app/controllers/ --include="*.rb" | grep -v "#"

# List all Sidekiq/Solid Queue workers
find app/workers app/jobs -name "*.rb" 2>/dev/null | wc -l

# List all ActionMailer mailers
find app/mailers -name "*.rb" 2>/dev/null | wc -l

# Export current schema for reference
rails db:schema:dump
cat db/schema.rb

# List all gem dependencies that need npm equivalents
bundle list | wc -l
```

**Verify**: You should have a document listing every model, controller action, job, mailer, and external API dependency with migration priority (1=move first, 3=move last).

### 2. Set up the Node.js project with NestJS and Prisma

Initialize a NestJS 11 project alongside the existing Rails app. Configure Prisma 7 to point at the same PostgreSQL database so both apps can run in parallel during migration. [src4, src5]

```bash
# Create NestJS 11 project with strict TypeScript
npx @nestjs/cli new my-app-node --strict
cd my-app-node

# Install Prisma 7 and essential packages
npm install @prisma/client @nestjs/config class-validator class-transformer
npm install -D prisma

# Initialize Prisma with existing database
npx prisma init
# Edit .env: DATABASE_URL="postgresql://user:pass@localhost:5432/myapp_development"

# Introspect existing Rails database to generate Prisma schema
npx prisma db pull

# Generate Prisma client (Prisma 7: outputs to project src, not node_modules)
npx prisma generate
```

**Verify**: `npx prisma studio` opens browser showing all existing Rails tables and data intact.

### 3. Migrate models and database access layer

Convert ActiveRecord models to Prisma schema definitions. Replace callbacks with explicit service methods. Replace model validations with class-validator DTOs. Key difference: Prisma 7 is pure TypeScript (no Rust engine), so queries are up to 3.4x faster than Prisma 5 and the bundle is 90% smaller. [src1, src5]

```typescript
// prisma/schema.prisma — generated by db pull, then cleaned up
model Post {
  id        Int      @id @default(autoincrement())
  title     String   @db.VarChar(255)
  body      String
  published Boolean  @default(false)
  authorId  Int      @map("author_id")
  author    User     @relation(fields: [authorId], references: [id])
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  @@map("posts") // match Rails table name
}

// src/posts/dto/create-post.dto.ts
import { IsString, IsBoolean, IsOptional, MaxLength } from 'class-validator';

export class CreatePostDto {
  @IsString()
  @MaxLength(255)
  title: string;

  @IsString()
  body: string;

  @IsBoolean()
  @IsOptional()
  published?: boolean = false;
}

// src/posts/posts.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreatePostDto } from './dto/create-post.dto';

@Injectable()
export class PostsService {
  constructor(private prisma: PrismaService) {}

  async create(dto: CreatePostDto, authorId: number) {
    // Explicit — no hidden callbacks
    return this.prisma.post.create({
      data: { ...dto, authorId },
    });
  }

  async findAll(page = 1, limit = 20) {
    return this.prisma.post.findMany({
      where: { published: true },
      include: { author: true },  // explicit eager loading (no implicit lazy load)
      skip: (page - 1) * limit,
      take: limit,
      orderBy: { createdAt: 'desc' },
    });
  }
}
```

**Verify**: `npm run test -- --grep PostsService` — all service tests pass with same data as Rails.

### 4. Migrate controllers and routing

Convert Rails controllers to NestJS controllers with decorators. Replace `before_action` filters with Guards and Interceptors. NestJS 11 uses Express 5 by default — ensure middleware is compatible with Express 5's async error handling. [src2, src4, src8]

```typescript
// src/posts/posts.controller.ts
import {
  Controller, Get, Post, Body, Param, Query,
  UseGuards, ParseIntPipe, HttpCode, HttpStatus,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { PostsService } from './posts.service';
import { CreatePostDto } from './dto/create-post.dto';
import { CurrentUser } from '../auth/current-user.decorator';

@Controller('api/v1/posts')
export class PostsController {
  constructor(private readonly postsService: PostsService) {}

  // Rails: GET /posts — index action with before_action :authenticate_user!
  @Get()
  findAll(@Query('page') page = 1, @Query('limit') limit = 20) {
    return this.postsService.findAll(+page, +limit);
  }

  // Rails: POST /posts — create action
  @Post()
  @UseGuards(JwtAuthGuard)  // replaces before_action :authenticate_user!
  @HttpCode(HttpStatus.CREATED)
  create(@Body() dto: CreatePostDto, @CurrentUser() user: any) {
    return this.postsService.create(dto, user.id);
  }

  // Rails: GET /posts/:id — show action
  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return this.postsService.findOne(id);
  }
}
```

**Verify**: `curl http://localhost:3001/api/v1/posts` returns same JSON shape as `curl http://localhost:3000/api/v1/posts` (Rails).

### 5. Migrate background jobs and mailers

Replace Sidekiq workers with BullMQ processors. Replace ActionMailer with Nodemailer. Both use Redis as the queue backend, so you can reuse your existing Redis instance (but not share queues — see Constraints). If migrating from Rails 8 Solid Queue, BullMQ is the direct replacement since both use a persistent store for job state. [src1]

```typescript
// src/jobs/email.processor.ts — replaces Sidekiq worker
import { Processor, Process } from '@nestjs/bull';
import { Job } from 'bull';
import { MailerService } from '@nestjs-modules/mailer';

@Processor('email')
export class EmailProcessor {
  constructor(private mailerService: MailerService) {}

  @Process('welcome')
  async sendWelcome(job: Job<{ email: string; name: string }>) {
    await this.mailerService.sendMail({
      to: job.data.email,
      subject: 'Welcome!',
      template: 'welcome',  // templates/welcome.hbs
      context: { name: job.data.name },
    });
  }
}

// Enqueue from service (replaces WelcomeMailer.welcome(user).deliver_later)
import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull';

@Injectable()
export class UsersService {
  constructor(@InjectQueue('email') private emailQueue: Queue) {}

  async create(dto: CreateUserDto) {
    const user = await this.prisma.user.create({ data: dto });
    await this.emailQueue.add('welcome', {
      email: user.email,
      name: user.name,
    });
    return user;
  }
}
```

**Verify**: `redis-cli LLEN bull:email:wait` shows queued jobs; check inbox for test email.

### 6. Set up the reverse proxy for incremental cutover (strangler fig)

Run both Rails (port 3000) and NestJS (port 3001) behind nginx. Route migrated endpoints to Node.js, everything else stays on Rails. Cut over one resource at a time. [src1, src3]

```nginx
# /etc/nginx/conf.d/myapp.conf
upstream rails_app {
  server 127.0.0.1:3000;
}

upstream node_app {
  server 127.0.0.1:3001;
}

server {
  listen 80;
  server_name myapp.com;

  # Migrated endpoints → Node.js
  location /api/v1/posts {
    proxy_pass http://node_app;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
  }

  location /api/v1/users {
    proxy_pass http://node_app;
    proxy_set_header Host $host;
  }

  # Everything else → Rails (shrinks over time)
  location / {
    proxy_pass http://rails_app;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
  }
}
```

**Verify**: `curl -I http://myapp.com/api/v1/posts` shows `X-Powered-By: Express` header; `curl -I http://myapp.com/legacy` shows Rails response headers.

### 7. Migrate authentication and decommission Rails

Port Devise authentication to Passport.js + JWT. bcrypt hashes are compatible between Rails Devise and Node.js bcrypt — users can log in with existing passwords without resetting. Verify all endpoints return identical responses. Remove Rails app when all traffic flows through Node.js. [src1, src2]

```typescript
// src/auth/auth.service.ts — replaces Devise
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../prisma/prisma.service';

@Injectable()
export class AuthService {
  constructor(
    private prisma: PrismaService,
    private jwtService: JwtService,
  ) {}

  async validateUser(email: string, password: string) {
    const user = await this.prisma.user.findUnique({ where: { email } });
    if (!user) throw new UnauthorizedException('Invalid credentials');

    // bcrypt hashes are compatible — Rails Devise uses bcrypt too
    const valid = await bcrypt.compare(password, user.encryptedPassword);
    if (!valid) throw new UnauthorizedException('Invalid credentials');

    return user;
  }

  async login(user: any) {
    const payload = { sub: user.id, email: user.email };
    return { access_token: this.jwtService.sign(payload) };
  }
}
```

**Verify**: `curl -X POST http://localhost:3001/auth/login -d '{"email":"test@example.com","password":"password"}' -H "Content-Type: application/json"` returns JWT token that works on all migrated endpoints.

## Code Examples

### Express 5: Minimal REST API (equivalent of Rails scaffold)

> Full script: [express-minimal-rest-api-equivalent-of-rails-scaff.ts](scripts/express-minimal-rest-api-equivalent-of-rails-scaff.ts) (39 lines)

```typescript
// Input:  HTTP requests to /api/posts
// Output: JSON responses with CRUD operations on posts
import express, { Request, Response, NextFunction } from 'express';
import { PrismaClient } from '@prisma/client';
const app = express();
// ... (see full script)
```

### NestJS 11: Full module with dependency injection (closest to Rails)

> Full script: [nestjs-full-module-with-dependency-injection-close.ts](scripts/nestjs-full-module-with-dependency-injection-close.ts) (62 lines)

```typescript
// Input:  HTTP requests to /api/v1/posts (mirrors Rails resource routes)
// Output: JSON responses with validation, auth guards, pagination
// src/posts/posts.module.ts
import { Module } from '@nestjs/common';
import { PostsController } from './posts.controller';
// ... (see full script)
```

### ActiveRecord to Prisma 7: Schema and query migration

```ruby
# RAILS: app/models/post.rb (ActiveRecord)
class Post < ApplicationRecord
  belongs_to :author, class_name: 'User'
  has_many :comments, dependent: :destroy
  has_many :tags, through: :post_tags

  validates :title, presence: true, length: { maximum: 255 }
  validates :body, presence: true

  scope :published, -> { where(published: true) }
  scope :recent, -> { order(created_at: :desc).limit(10) }

  before_save :generate_slug

  private
  def generate_slug
    self.slug = title.parameterize
  end
end
```

```typescript
// NODE.JS: prisma/schema.prisma + src/posts/posts.service.ts (Prisma 7)

// Schema (replaces ActiveRecord migration + model associations)
// prisma/schema.prisma
model Post {
  id        Int       @id @default(autoincrement())
  title     String    @db.VarChar(255)
  slug      String    @unique
  body      String
  published Boolean   @default(false)
  authorId  Int       @map("author_id")
  author    User      @relation(fields: [authorId], references: [id])
  comments  Comment[] // replaces has_many :comments
  tags      Tag[]     @relation("PostTags") // replaces has_many :through
  createdAt DateTime  @default(now()) @map("created_at")
  updatedAt DateTime  @updatedAt @map("updated_at")

  @@map("posts")
}

// Service (replaces scopes + callbacks + validations)
import slugify from 'slugify';

@Injectable()
export class PostsService {
  constructor(private prisma: PrismaService) {}

  // Replaces: Post.published.recent
  async findPublishedRecent() {
    return this.prisma.post.findMany({
      where: { published: true },       // scope :published
      orderBy: { createdAt: 'desc' },   // scope :recent
      take: 10,
      include: { author: true, tags: true },
    });
  }

  // Replaces: before_save :generate_slug + create
  async create(dto: CreatePostDto, authorId: number) {
    const slug = slugify(dto.title, { lower: true, strict: true });
    return this.prisma.post.create({
      data: { ...dto, slug, authorId },
    });
  }
}
```

## Anti-Patterns

### Wrong: Replicating ActiveRecord callbacks in Node.js

```typescript
// BAD — trying to replicate Rails before_save/after_create callbacks with ORM hooks
// This creates hidden side effects that are hard to test and debug

class PostModel {
  @BeforeInsert()
  generateSlug() {
    this.slug = this.title.toLowerCase().replace(/\s+/g, '-');
  }

  @AfterInsert()
  async sendNotification() {
    await emailService.notify(this.authorId, 'Post created!');
  }

  @BeforeUpdate()
  async validatePermissions() {
    const user = await userRepo.findOne(this.authorId);
    if (!user.canEdit) throw new Error('Unauthorized');
  }
}
```

### Correct: Explicit operations in service layer

```typescript
// GOOD — all side effects are explicit, testable, and visible in the call chain

@Injectable()
export class PostsService {
  constructor(
    private prisma: PrismaService,
    private notifications: NotificationsService,
  ) {}

  async create(dto: CreatePostDto, authorId: number) {
    const slug = slugify(dto.title, { lower: true, strict: true });

    const post = await this.prisma.post.create({
      data: { ...dto, slug, authorId },
    });

    // Side effects are explicit — not hidden in model hooks
    await this.notifications.notifyAuthor(authorId, 'Post created!');

    return post;
  }
}
```

### Wrong: Using synchronous Rails-style middleware chains

```typescript
// BAD — blocking synchronous patterns from Rails carried into Node.js
app.use((req, res, next) => {
  // Synchronous database call — blocks the event loop!
  const db = require('better-sqlite3')('app.db');
  const user = db.prepare('SELECT * FROM users WHERE token = ?').get(req.headers.token);
  req.user = user;
  next();
});
```

### Correct: Async middleware with proper error handling

```typescript
// GOOD — non-blocking async middleware, proper error propagation
// Express 5 automatically catches rejected promises in async handlers
app.use(async (req: Request, res: Response, next: NextFunction) => {
  try {
    const token = req.headers.authorization?.replace('Bearer ', '');
    if (!token) return next(); // skip auth for public routes

    const payload = jwt.verify(token, process.env.JWT_SECRET!);
    req.user = await prisma.user.findUnique({
      where: { id: (payload as any).sub },
    });
    next();
  } catch (err) {
    next(err); // propagate to error handler, don't swallow
  }
});
```

### Wrong: One-to-one class mapping from Rails to Node.js

```typescript
// BAD — directly translating every Rails concern, helper, and module into separate files
// Results in over-engineering and unnecessary abstraction

// app/concerns/sluggable.ts (from Rails concern)
// app/concerns/publishable.ts
// app/concerns/searchable.ts
// app/helpers/posts_helper.ts
// app/decorators/post_decorator.ts
// app/presenters/post_presenter.ts
// app/serializers/post_serializer.ts
// ... 15+ files for a single resource
```

### Correct: Lean module structure following Node.js conventions

```typescript
// GOOD — NestJS module with focused files; concerns become utility functions or pipes

// src/posts/
//   posts.module.ts      (module declaration)
//   posts.controller.ts  (routes + request handling)
//   posts.service.ts     (business logic — includes slug generation, publishing)
//   dto/create-post.dto.ts (validation via class-validator)
//   dto/update-post.dto.ts
//   posts.spec.ts        (tests)
// src/common/
//   slugify.util.ts      (shared utility — replaces concern)

// Total: 6-7 files per resource vs 15+ in a naive Rails port
```

### Wrong: Migrating the entire monolith at once

```typescript
// BAD — "big bang" rewrite: stop all Rails development, rewrite everything in Node.js
// This approach consistently fails for applications with more than ~10 models
// LinkedIn, ToolJet, and others used incremental migration for a reason [src1, src3]

// Day 1: "Let's rewrite everything!"
// Month 3: "We're 40% done, but Rails is still serving production"
// Month 6: "The Rails app drifted, Node app is outdated, we need to reconcile"
// Month 9: Project abandoned
```

### Correct: Strangler fig pattern with parallel operation

```typescript
// GOOD — incremental migration with reverse proxy routing [src1, src3]

// Phase 1: Migrate stateless read endpoints (GET /api/posts, GET /api/users)
// Phase 2: Migrate write endpoints with simpler models
// Phase 3: Migrate background jobs (Sidekiq → BullMQ)
// Phase 4: Migrate authentication (Devise → Passport + JWT)
// Phase 5: Migrate remaining endpoints, decommission Rails

// Each phase: deploy → verify parity → monitor → next phase
// Both apps share the same PostgreSQL database throughout
```

### Wrong: Ignoring Express 4 to Express 5 breaking changes in NestJS 11

```typescript
// BAD — NestJS 11 defaults to Express 5, but using Express 4 middleware patterns
// Express 5 changed route parameter syntax and removed deprecated methods

// This no longer works in Express 5 / NestJS 11:
app.get('/users/:userId?', handler);  // optional param syntax changed
res.send(200);                         // removed — use res.sendStatus(200)
req.param('name');                     // removed — use req.params.name
```

### Correct: Express 5 compatible patterns

```typescript
// GOOD — updated for Express 5 syntax (default in NestJS 11) [src4, src8]

app.get('/users{/:userId}', handler); // Express 5 optional param syntax
res.sendStatus(200);                   // correct method
req.params.name;                       // explicit parameter access
// Express 5 also auto-catches async errors — no need for express-async-errors
```

## Common Pitfalls

- **N+1 queries after migration**: ActiveRecord has `includes()` for eager loading, but Prisma requires explicit `include: { relation: true }` on every query. Without it, related data triggers separate queries. Fix: use Prisma's `include` or `select` on all queries that access relations, and enable query logging with `prisma.$on('query')` in development. [src5]
- **Losing Rails validations silently**: ActiveRecord `validates :email, presence: true, uniqueness: true` has no direct Prisma equivalent. Prisma enforces `@unique` at the database level but won't validate presence before the query. Fix: use `class-validator` decorators on DTOs (`@IsEmail()`, `@IsNotEmpty()`) and enable `ValidationPipe` globally in NestJS. [src2, src4]
- **Session-based auth incompatibility**: Rails Devise stores sessions in encrypted cookies. Node.js apps typically use JWT or stateless tokens. Users will be logged out during migration. Fix: implement JWT auth in the Node.js app and either issue new tokens at migration or run a token-exchange endpoint that validates the Rails session cookie and returns a JWT. [src1]
- **Time zone mismatches**: Rails `Time.zone` and `ActiveSupport::TimeZone` handle time zones transparently. Node.js `Date` is always UTC internally. Fix: store everything as UTC in the database (which Rails likely already does), use `dayjs` or `date-fns-tz` for display formatting, and configure Prisma with `@default(now())` for timestamps. [src6]
- **Missing Rails console equivalent**: `rails console` provides an interactive REPL with full app context. Node.js has no built-in equivalent. Fix: create a REPL script using `node --experimental-repl-await` with Prisma client loaded, or use `npx prisma studio` for visual data inspection. [src7]
- **Background job format incompatibility**: Sidekiq and BullMQ use different Redis data structures. You cannot share a job queue between Rails and Node.js. Fix: during transition, run both Sidekiq and BullMQ. New jobs go to BullMQ; old jobs drain from Sidekiq. Do not attempt to read Sidekiq's Redis keys from BullMQ. [src1]
- **Encrypted credentials migration**: Rails `credentials.yml.enc` uses application-specific keys. Node.js has no equivalent — use `.env` files with `dotenv` or `@nestjs/config`. Fix: manually extract all secrets from `rails credentials:show` and add them to `.env`. For encrypted database fields (e.g., using Lockbox gem), replicate the key derivation in Node.js using `futoin-hkdf`. [src1]
- **Express 5 breaking changes in NestJS 11**: NestJS 11 defaults to Express 5 which changes optional parameter syntax (`:name?` becomes `{/:name}`), removes `res.send(status)`, and removes `req.param()`. Fix: run the Express codemods tool (`npx @expressjs/codemod`) and update middleware before upgrading to NestJS 11. [src4, src8]

## Diagnostic Commands

```bash
# Verify Node.js and npm versions (need Node.js 22+ for current LTS)
node -v && npm -v

# Check NestJS project health and version
npx nest info

# Verify Prisma 7 database connection and schema sync
npx prisma db pull --print
npx prisma migrate status

# Compare Rails and Node.js API responses side-by-side
diff <(curl -s http://localhost:3000/api/v1/posts | jq .) \
     <(curl -s http://localhost:3001/api/v1/posts | jq .)

# Check BullMQ job queue status (requires Redis CLI)
redis-cli KEYS "bull:*"
redis-cli LLEN "bull:email:wait"
redis-cli LLEN "bull:email:completed"

# Run NestJS tests with coverage
npm run test -- --coverage
npm run test:e2e

# Check for missing environment variables
node -e "const required = ['DATABASE_URL','JWT_SECRET','REDIS_URL']; required.forEach(k => { if(!process.env[k]) console.log('MISSING: ' + k) })"

# Lint and type-check the NestJS project
npx tsc --noEmit
npx eslint src/ --ext .ts

# Check Express version (should be 5.x in NestJS 11)
npm ls express

# Check Prisma version (should be 7.x for pure TypeScript runtime)
npx prisma version
```

## Version History & Compatibility

| Technology | Version | Status | Notes |
|---|---|---|---|
| Node.js 24.x | Current LTS | LTS until Oct 2028 | Recommended for new projects, URLPattern global, built-in TS execution |
| Node.js 22.x | Maintenance LTS | LTS until Apr 2027 | Stable choice, widely deployed, OpenSSL 3.5.2 |
| Node.js 20.x | Maintenance | EOL Apr 2026 | Minimum for this guide, upgrade recommended |
| NestJS 11.x | Current (2025) | Recommended | Express 5 default, improved logging, CacheModule v6, ParseDatePipe [src8] |
| NestJS 10.x | Previous | Supported | Uses Express 4.x by default, stable but missing v11 improvements |
| Express 5.x | Current stable (Oct 2024) | Recommended | Breaking: route syntax, removed deprecated methods, async error handling |
| Express 4.x | Legacy | Maintenance only | Most middleware compatible but no async error catching |
| Prisma 7.x | Current (late 2025) | Recommended | Pure TypeScript, 3x faster queries, 90% smaller bundle, no Rust engine [src5] |
| Prisma 5.x | Previous | Supported | Rust engine, larger bundle, slower cold starts |
| Drizzle ORM | 0.38+ | Alternative | 57KB runtime, best for serverless/edge, SQL-first approach |
| TypeORM 0.3.x | Current | Alternative | Active Record + Data Mapper patterns, steeper learning curve |
| Rails 8.x | Source (current) | Kamal 2, Thruster, Solid adapters — evaluate if migration is still needed |
| Rails 7.x | Source (LTS) | Hotwire, Propshaft — standard migration source |
| Rails 6.x | Source (EOL) | Webpacker — replace with Vite/esbuild in Node.js |

## When to Use / When Not to Use

| Use When | Don't Use When | Use Instead |
|---|---|---|
| I/O-heavy API serving many concurrent connections | CPU-intensive processing (video encoding, ML inference) | Go, Rust, or keep Rails with dedicated workers |
| Team is primarily JavaScript/TypeScript | Team is expert in Ruby with no JS experience | Upgrade Rails version, add Hotwire |
| Unifying frontend + backend into single language | Rails app is small, stable, and works fine | Keep Rails — migration cost exceeds benefit |
| Need real-time features (WebSockets, SSE) | Complex ActiveRecord domain model with 100+ models | Migrate incrementally or consider staying on Rails |
| Microservice extraction from Rails monolith | Strict regulatory environment requiring proven Rails stack | Add API layer in front of Rails |
| Targeting serverless/edge deployment (Cloudflare Workers, Lambda) | Rails 8 with Solid adapters already eliminated Redis dependency | Stay on Rails 8 — it solved your infra problem |
| Open source project with JS/TS community contributions [src1] | Mature Rails app with extensive test suite that works | Refactor within Rails first |

## Important Caveats

- Node.js uses a single-threaded event loop. CPU-bound operations (image resizing, PDF generation, complex calculations) will block all requests. Use worker threads (`worker_threads` module) or offload to a separate service. This is fundamentally different from Rails' multi-process model with Puma. [src3, src6]
- ActiveRecord migrations and Prisma migrations are incompatible. Once you start using Prisma Migrate, do not run `rails db:migrate` on the same database without coordination. Designate one tool as the migration authority during transition. [src5]
- NestJS 11 uses Express 5 by default (previously Express 4). Express 5 introduces breaking changes: optional route parameter syntax changed from `:name?` to `{/:name}`, `req.param()` removed, `res.send(status)` removed. Middleware written for Express 4 may need updates. Alternatively, NestJS supports Fastify as a drop-in replacement. [src4, src8]
- The Rails asset pipeline (Sprockets/Propshaft/Webpacker) has no direct equivalent in Node.js backend frameworks. If your Rails app serves HTML views with embedded assets, you need a separate frontend build tool (Vite, Webpack, esbuild) or a frontend framework (React, Vue, Next.js). [src6]
- Gems do not map 1:1 to npm packages. Some Rails gems (Devise, Pundit, ActiveStorage, Lockbox) are tightly coupled to Rails internals. Their Node.js replacements (Passport, CASL, Multer+S3, `futoin-hkdf`) require different architectural patterns. Budget extra time for these. [src1, src7]
- LinkedIn's famous "20x performance improvement" [src3] was achieved in 2012 on mobile API endpoints. Modern Rails 8 with Puma, YJIT, Thruster (HTTP/2, gzip), and Solid adapters is significantly faster than the version LinkedIn migrated from. Migration decisions should be based on current benchmarks, not decade-old case studies.
- Rails 8 (Nov 2024) introduced Solid Queue, Solid Cache, and Solid Cable — eliminating Redis dependency for background jobs, caching, and WebSockets. If Redis complexity was a migration driver, re-evaluate whether migration is still justified.
- Prisma 7 (late 2025) removed the Rust query engine and is now pure TypeScript. This resolved previous cold-start issues on serverless platforms. However, for edge runtimes with strict size limits (Cloudflare Workers), Drizzle ORM (57KB) remains the better choice over Prisma (1.6MB). [src5]

## Related Units

- [PHP to Node.js Migration](/software/migrations/php-to-nodejs/2026)
- [Express to NestJS Migration](/software/migrations/express-to-nestjs/2026)
- [JavaScript to TypeScript Migration](/software/migrations/javascript-to-typescript/2026)
- [Rails to Django Migration](/software/migrations/rails-to-django/2026)
