npx @nestjs/cli new my-app --strict && npx prisma init to scaffold a NestJS 11 project with Prisma 7 ORM.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 harder to debug than Rails callbacks. [src1, src7]rails db:migrate and npx prisma migrate dev on the same database simultaneously. Designate one tool as the sole migration authority during transition. [src5]| 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] |
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.
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.
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.
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.
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.
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.
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/posts → X-Powered-By: Express header.
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.
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'));
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;
}
}
# 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 } });
}
}
// 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');
}
}
// 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;
}
}
// 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();
});
// 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
}
});
// 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
// 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
// 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
// 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
// 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
// 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
includes() for eager loading, but Prisma requires explicit include: { relation: true } on every query. Fix: add include on all queries that access relations; enable prisma.$on('query') in development. [src5]validates :email, presence: true, uniqueness: true has no direct Prisma equivalent. Fix: use class-validator decorators on DTOs and enable ValidationPipe globally in NestJS. [src2, src4]Time.zone handles time zones transparently. Node.js Date is always UTC. Fix: store everything as UTC, use dayjs or date-fns-tz for display. [src6]node --experimental-repl-await with Prisma loaded, or npx prisma studio. [src7]credentials.yml.enc uses app-specific keys. Fix: extract secrets via rails credentials:show, add to .env. For encrypted DB fields (Lockbox), replicate key derivation with futoin-hkdf. [src1]:name? becomes {/:name}), removes res.send(status), and removes req.param(). Fix: run npx @expressjs/codemod and update middleware before upgrading. [src4, src8]# 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
| Technology | Version | Status | Notes |
|---|---|---|---|
| Node.js 24.x | 24 | Current LTS (Oct 2025) | Recommended for new projects, URLPattern global, built-in TS execution |
| Node.js 22.x | 22 | Maintenance LTS until Apr 2027 | Stable choice, widely deployed, OpenSSL 3.5.2 |
| Node.js 20.x | 20 | Maintenance, EOL Apr 2026 | Minimum for this guide, upgrade recommended |
| NestJS | 11.x | Current (2025) | Express 5 default, improved logging, CacheModule v6, ParseDatePipe [src8] |
| NestJS | 10.x | Previous | Uses Express 4.x by default, stable but missing v11 improvements |
| Express | 5.x | Current stable (Oct 2024) | Breaking: route syntax, removed deprecated methods, async errors [src4] |
| Express | 4.x | Legacy | Maintenance only, most middleware compatible |
| Prisma | 7.x | Current (late 2025) | Pure TypeScript, 3x faster, 90% smaller bundle, no Rust engine [src5] |
| Prisma | 5.x | Previous | 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 | Alternative | Active Record + Data Mapper patterns, steeper learning curve |
| Rails (source) | 8.x | Current (Nov 2024) | Kamal 2, Thruster, Solid adapters — evaluate if migration still needed |
| Rails (source) | 7.x | LTS | Hotwire, Propshaft — standard migration source |
| Rails (source) | 6.x | EOL | Webpacker — replace with Vite/esbuild in Node.js |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| I/O-heavy API serving many concurrent connections | CPU-intensive processing (video encoding, ML) | 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 stay on Rails |
| Microservice extraction from Rails monolith | Strict regulatory environment requiring proven stack | Add API layer in front of Rails |
| Targeting serverless/edge deployment | Rails 8 with Solid adapters eliminated Redis dependency | Stay on Rails 8 — it solved your infra problem |
| Open source with JS/TS community contributions [src1] | Mature app with extensive test suite that works | Refactor within Rails first |
rails db:migrate on the same database without coordination. Designate one tool as the migration authority. [src5]:name? to {/:name}, req.param() removed, res.send(status) removed. Middleware for Express 4 may need updates. Alternatively, NestJS supports Fastify as a drop-in replacement. [src4, src8]