How to Migrate a Ruby on Rails App to Node.js (Express/NestJS)

Type: Software Reference Confidence: 0.91 Sources: 8 Verified: 2026-02-22 Freshness: quarterly

TL;DR

Constraints

Quick Reference

Rails PatternNode.js EquivalentExample
rails new myappnpx @nestjs/cli new myapp --strictScaffolds project with modules, controllers, services, strict TS [src4]
rails generate scaffold Postnest generate resource postsCreates module + controller + service + DTO + spec [src2, src4]
ActiveRecord (ORM)Prisma 7 / Drizzle / TypeORMprisma.post.findMany({ where: { published: true } }) [src5]
db:migrate / db:rollbacknpx prisma migrate devSchema-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_actionNestJS Guards + Interceptors@UseGuards(AuthGuard) on controller or route [src2]
ApplicationController filtersExpress middleware / NestJS Pipesapp.use(cors()); app.use(helmet()) [src6]
ERB / Haml viewsReact / EJS / Pug (or API-only)Most migrations go API-only; use @Render('index') for SSR [src1, src7]
Sprockets / Propshaft / WebpackerVite / esbuild (standalone)vite build — Rails 7+/8 already uses esbuild [src6]
Sidekiq (background jobs)BullMQ (Redis-backed)@Processor('email') process(job) { ... } [src1]
ActionMailerNodemailer + @nestjs-modules/mailertransporter.sendMail({ to, subject, html }) [src1]
RSpec / MinitestJest / Vitest + Supertestit('returns 200', () => request(app).get('/').expect(200)) [src1, src2]
Devise (authentication)Passport.js + @nestjs/jwt@UseGuards(JwtAuthGuard) [src1]
ActiveSupport::CallbacksNestJS Lifecycle HooksonModuleInit(), onApplicationBootstrap() [src4]
Rails.credentials / dotenv@nestjs/config + .env filesconfigService.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 (max 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 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 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 [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 [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]

# 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

Verify: You should have a document listing every model, controller action, job, mailer, and external API dependency with migration priority.

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. [src4, src5]

# 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

# 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 validations with class-validator DTOs. Prisma 7 is pure TypeScript (no Rust engine), 3.4x faster queries and 90% smaller bundle. [src1, src5]

// 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/posts.service.ts — explicit, no hidden callbacks
@Injectable()
export class PostsService {
  constructor(private prisma: PrismaService) {}

  async create(dto: CreatePostDto, authorId: number) {
    return this.prisma.post.create({
      data: { ...dto, authorId },
    });
  }
}

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. [src2, src4, src8]

@Controller('api/v1/posts')
export class PostsController {
  constructor(private readonly postsService: PostsService) {}

  @Get()
  findAll(@Query('page') page = 1, @Query('limit') limit = 20) {
    return this.postsService.findAll(+page, +limit);
  }

  @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);
  }
}

Verify: curl http://localhost:3001/api/v1/posts returns same JSON shape as Rails on port 3000.

5. Migrate background jobs and mailers

Replace Sidekiq workers with BullMQ processors. Replace ActionMailer with Nodemailer. Both use Redis as the queue backend. If migrating from Rails 8 Solid Queue, BullMQ is the direct replacement. [src1]

@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',
      context: { name: job.data.name },
    });
  }
}

Verify: redis-cli LLEN bull:email:wait shows queued jobs.

6. Set up reverse proxy for incremental cutover

Run both Rails (port 3000) and NestJS (port 3001) behind nginx. Route migrated endpoints to Node.js, everything else stays on Rails. [src1, src3]

# Migrated endpoints → Node.js
location /api/v1/posts {
  proxy_pass http://127.0.0.1:3001;
  proxy_set_header Host $host;
  proxy_set_header X-Real-IP $remote_addr;
}

# Everything else → Rails (shrinks over time)
location / {
  proxy_pass http://127.0.0.1:3000;
  proxy_set_header Host $host;
}

Verify: curl -I http://myapp.com/api/v1/postsX-Powered-By: Express header.

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. Verify all endpoints return identical responses. Remove Rails app when all traffic flows through Node.js. [src1, src2]

@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) {
    return { access_token: this.jwtService.sign({ sub: user.id, email: user.email }) };
  }
}

Verify: Login with existing Rails credentials returns valid JWT; JWT 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 (39 lines)

// 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();
const prisma = new PrismaClient();
app.use(express.json());

// GET /api/posts — equivalent of PostsController#index
app.get('/api/posts', async (req: Request, res: Response) => {
  const page = parseInt(req.query.page as string) || 1;
  const limit = parseInt(req.query.limit as string) || 20;
  const posts = await prisma.post.findMany({
    where: { published: true },
    include: { author: { select: { id: true, name: true } } },
    skip: (page - 1) * limit,
    take: limit,
    orderBy: { createdAt: 'desc' },
  });
  res.json({ data: posts, page, limit });
});

// POST /api/posts — equivalent of PostsController#create
app.post('/api/posts', authMiddleware, async (req: Request, res: Response) => {
  const { title, body, published } = req.body;
  const post = await prisma.post.create({
    data: { title, body, published: published ?? false, authorId: (req as any).userId },
  });
  res.status(201).json({ data: post });
});

// Error handling middleware — replaces Rails rescue_from
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Internal server error' });
});

app.listen(3001, () => console.log('Server running on port 3001'));

NestJS 11: Full module with dependency injection (closest to Rails)

Full script: nestjs-full-module-with-dependency-injection-close.ts (62 lines)

// Input:  HTTP requests to /api/v1/posts (mirrors Rails resource routes)
// Output: JSON responses with validation, auth guards, pagination

// src/posts/posts.module.ts
@Module({
  imports: [PrismaModule],
  controllers: [PostsController],
  providers: [PostsService],
  exports: [PostsService],
})
export class PostsModule {}

// src/posts/posts.service.ts
@Injectable()
export class PostsService {
  constructor(private prisma: PrismaService) {}

  async create(dto: CreatePostDto, authorId: number) {
    return this.prisma.post.create({
      data: { ...dto, authorId },
      include: { author: true },
    });
  }

  async findAll(page = 1, limit = 20) {
    const [data, total] = await this.prisma.$transaction([
      this.prisma.post.findMany({
        where: { published: true },
        include: { author: { select: { id: true, name: true, email: true } } },
        skip: (page - 1) * limit,
        take: limit,
        orderBy: { createdAt: 'desc' },
      }),
      this.prisma.post.count({ where: { published: true } }),
    ]);
    return { data, total, page, limit, totalPages: Math.ceil(total / limit) };
  }

  async findOne(id: number) {
    const post = await this.prisma.post.findUnique({ where: { id }, include: { author: true } });
    if (!post) throw new NotFoundException(`Post #${id} not found`);
    return post;
  }
}

ActiveRecord to Prisma 7: Schema and query migration

# 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
// NODE.JS: Prisma 7 schema + service (replaces ActiveRecord model)

// 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[]
  tags      Tag[]     @relation("PostTags")
  createdAt DateTime  @default(now()) @map("created_at")
  updatedAt DateTime  @updatedAt @map("updated_at")
  @@map("posts")
}

// src/posts/posts.service.ts — explicit service replaces scopes + callbacks
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 },
      orderBy: { createdAt: 'desc' },
      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

// BAD — trying to replicate Rails before_save/after_create with ORM hooks
// 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

// 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 } });
    await this.notifications.notifyAuthor(authorId, 'Post created!'); // explicit
    return post;
  }
}

Wrong: Using synchronous Rails-style middleware chains

// 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

// 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();
    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

// BAD — directly translating every Rails concern, helper, module into separate files
// Results in over-engineering and unnecessary abstraction

// app/concerns/sluggable.ts, publishable.ts, 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

// GOOD — NestJS module with focused files; concerns become utility functions

// src/posts/
//   posts.module.ts      (module declaration)
//   posts.controller.ts  (routes + request handling)
//   posts.service.ts     (business logic — 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

// BAD — "big bang" rewrite: stop all Rails development, rewrite everything [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"
// Month 9: Project abandoned

Correct: Strangler fig pattern with parallel operation

// 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

Wrong: Ignoring Express 5 breaking changes in NestJS 11

// BAD — NestJS 11 defaults to Express 5, but using Express 4 patterns
// Express 5 changed route parameter syntax and removed deprecated methods

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

// 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

Diagnostic Commands

# 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 TS runtime)
npx prisma version

Version History & Compatibility

TechnologyVersionStatusNotes
Node.js 24.x24Current LTS (Oct 2025)Recommended for new projects, URLPattern global, built-in TS execution
Node.js 22.x22Maintenance LTS until Apr 2027Stable choice, widely deployed, OpenSSL 3.5.2
Node.js 20.x20Maintenance, EOL Apr 2026Minimum for this guide, upgrade recommended
NestJS11.xCurrent (2025)Express 5 default, improved logging, CacheModule v6, ParseDatePipe [src8]
NestJS10.xPreviousUses Express 4.x by default, stable but missing v11 improvements
Express5.xCurrent stable (Oct 2024)Breaking: route syntax, removed deprecated methods, async errors [src4]
Express4.xLegacyMaintenance only, most middleware compatible
Prisma7.xCurrent (late 2025)Pure TypeScript, 3x faster, 90% smaller bundle, no Rust engine [src5]
Prisma5.xPreviousRust engine, larger bundle, slower cold starts
Drizzle ORM0.38+Alternative57KB runtime, best for serverless/edge, SQL-first approach
TypeORM0.3.xAlternativeActive Record + Data Mapper patterns, steeper learning curve
Rails (source)8.xCurrent (Nov 2024)Kamal 2, Thruster, Solid adapters — evaluate if migration still needed
Rails (source)7.xLTSHotwire, Propshaft — standard migration source
Rails (source)6.xEOLWebpacker — replace with Vite/esbuild in Node.js

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
I/O-heavy API serving many concurrent connectionsCPU-intensive processing (video encoding, ML)Go, Rust, or keep Rails with dedicated workers
Team is primarily JavaScript/TypeScriptTeam is expert in Ruby with no JS experienceUpgrade Rails version, add Hotwire
Unifying frontend + backend into single languageRails app is small, stable, and works fineKeep Rails — migration cost exceeds benefit
Need real-time features (WebSockets, SSE)Complex ActiveRecord domain model with 100+ modelsMigrate incrementally or stay on Rails
Microservice extraction from Rails monolithStrict regulatory environment requiring proven stackAdd API layer in front of Rails
Targeting serverless/edge deploymentRails 8 with Solid adapters eliminated Redis dependencyStay on Rails 8 — it solved your infra problem
Open source with JS/TS community contributions [src1]Mature app with extensive test suite that worksRefactor within Rails first

Important Caveats

Related Units