Back to Scaling Node.js Applications guides

NestJS vs. Ruby on Rails

Stanley Ulili
Updated on September 15, 2025

When you're building web applications, you'll encounter two distinct approaches to solving the same problems. NestJS brings enterprise-grade architecture and TypeScript safety to Node.js development. Ruby on Rails prioritizes rapid development through well-established conventions that eliminate common decision points.

NestJS was created because Express applications frequently became unmaintainable as they scaled. Development teams would start with clear intentions, but without structured patterns, codebases would evolve into complex, difficult-to-understand systems. NestJS addresses this by implementing Angular's architectural patterns for backend development, providing predictable structure from the start.

Ruby on Rails emerged from David Heinemeier Hansson's experience building Basecamp, where he repeatedly implemented the same patterns for common web application features. Rather than rebuilding these solutions for each project, he extracted them into a framework that handles routine tasks automatically.

The key difference lies in their approach to complexity management. NestJS requires upfront architectural decisions but provides long-term maintainability through explicit structure. Rails eliminates many architectural decisions through conventions, enabling rapid feature development but requiring discipline to maintain code organization as applications grow.

What is NestJS?

Express.js provided JavaScript developers with flexibility, but this freedom often created problems in larger applications. Teams would implement custom middleware stacks, invent unique folder structures, and develop project-specific patterns. As projects matured, these custom solutions became barriers to team productivity and code maintainability.

NestJS solves this problem by applying proven architectural patterns from Angular to backend JavaScript development. Instead of creating new organizational systems for each project, you follow established patterns that experienced developers recognize immediately.

The framework implements several key concepts that promote maintainable code:

  • Dependency injection manages component relationships explicitly
  • Decorators provide metadata for routing and validation
  • Modules organize related functionality into cohesive units
  • Guards and interceptors handle cross-cutting concerns like authentication

This structured approach reduces the cognitive load of understanding codebases and makes it easier for new team members to contribute effectively.

What is Ruby on Rails?

Before Rails, web application development required implementing basic functionality repeatedly. Authentication systems, database migrations, URL routing, and session management had to be built from scratch for each project. This redundancy slowed development and introduced inconsistencies between applications.

Rails addressed this inefficiency by providing pre-built solutions for common web application needs. The framework includes integrated tools for database management, user authentication, asset compilation, and deployment configuration.

Rails achieves productivity through several core principles:

  • Convention over configuration reduces the number of decisions developers need to make
  • DRY (Don't Repeat Yourself) promotes code reuse through shared components
  • Active Record pattern provides intuitive database interaction methods
  • Integrated generators create working code for common features

These conventions create consistency across Rails applications, making it easier to work on different projects and onboard new developers.

Framework comparison

Understanding how these frameworks approach common development tasks will help you determine which fits your project needs.

Aspect NestJS Ruby on Rails
Language TypeScript/JavaScript Ruby
Architecture Explicit dependency injection Convention over configuration
Learning Curve Steeper initial learning, familiar to Angular developers Gentler learning curve with clear conventions
Development Speed Slower initial development, faster iteration once structured Extremely fast initial development
Type Safety Compile-time type checking with TypeScript Runtime type checking, optional static analysis
Database Integration TypeORM/Prisma require explicit configuration Active Record provides built-in ORM
Testing Approach Jest with dependency mocking Built-in test framework with database fixtures
Deployment Strategy Container-friendly, requires Node.js runtime Traditional hosting, requires Ruby runtime
Community Ecosystem Growing, enterprise-focused packages Mature ecosystem with established gems
Hosting Requirements Any Node.js-compatible platform Ruby-specific hosting or containers

Your choice between these frameworks often depends on your team's background and project timeline. Teams with TypeScript experience will find NestJS's patterns familiar. Teams prioritizing rapid feature delivery may prefer Rails' integrated approach.

Getting started

Let's examine how each framework handles initial project setup to understand their different philosophies in practice.

NestJS emphasizes structure from the first command:

 
npm install -g @nestjs/cli
nest new blog-api
cd blog-api && npm run start:dev

The CLI generates a project structure that demonstrates the framework's architectural patterns:

 
src/
├── app.controller.ts    # HTTP request handler
├── app.module.ts        # Application organization
├── app.service.ts       # Business logic
└── main.ts             # Application bootstrap

Creating your first API endpoint requires understanding dependency injection and separation of concerns:

 
import { Controller, Get, Post, Body } from '@nestjs/common';
import { PostsService } from './posts.service';
import { CreatePostDto } from './dto/create-post.dto';

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

  @Get()
  getAllPosts() {
    return this.postsService.findAll();
  }

  @Post()
  createPost(@Body() postData: CreatePostDto) {
    return this.postsService.create(postData);
  }
}

This example demonstrates NestJS's approach to code organization. The controller handles HTTP requests but delegates business logic to the service. Data validation occurs through DTOs (Data Transfer Objects). Dependencies are injected through constructor parameters. This separation creates predictable code structure but requires understanding these patterns before you can implement features effectively.

Rails gets you building features immediately:

 
gem install rails
rails new blog_app
cd blog_app && rails server

Rails generates a complete web application with all necessary components:

 
rails generate scaffold Post title:string content:text published:boolean
rails db:migrate

This single command creates a working web interface with database integration, including:

  • Database migration files
  • ActiveRecord model with validations
  • Controller with full CRUD operations
  • HTML views for all actions
  • URL routing configuration

The generated controller demonstrates Rails' approach:

 
class PostsController < ApplicationController
  before_action :set_post, only: [:show, :edit, :update, :destroy]

  def index
    @posts = Post.published.order(created_at: :desc)
  end

  def create
    @post = Post.new(post_params)

    if @post.save
      redirect_to @post, notice: 'Post was successfully created.'
    else
      render :new
    end
  end

  private

  def post_params
    params.require(:post).permit(:title, :content, :published)
  end
end

Rails provides working functionality immediately without requiring architectural decisions. The framework makes assumptions about how you want to structure your application and provides sensible defaults for common patterns.

Database integration

Database interaction reveals the core differences between these frameworks most clearly.

NestJS treats database integration as a service that requires explicit configuration. Using TypeORM, you define entities and repositories:

 
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, CreateDateColumn } from 'typeorm';
import { User } from '../users/user.entity';

@Entity()
export class Post {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  title: string;

  @Column('text')
  content: string;

  @ManyToOne(() => User, user => user.posts)
  author: User;

  @Column({ default: false })
  published: boolean;

  @CreateDateColumn()
  createdAt: Date;
}

Database queries use repository methods with explicit type safety:

 
@Injectable()
export class PostsService {
  constructor(
    @InjectRepository(Post) private postRepository: Repository<Post>
  ) {}

  async findPublished(): Promise<Post[]> {
    return this.postRepository.find({
      where: { published: true },
      relations: ['author'],
      order: { createdAt: 'DESC' }
    });
  }

  async createPost(postData: CreatePostDto, authorId: number): Promise<Post> {
    const post = this.postRepository.create({
      ...postData,
      author: { id: authorId }
    });
    return this.postRepository.save(post);
  }
}

Many NestJS developers prefer Prisma for better TypeScript integration:

 
model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String
  published Boolean  @default(false)
  authorId  Int
  author    User     @relation(fields: [authorId], references: [id])
  createdAt DateTime @default(now())
}
 
async findPostsWithAuthors() {
  return this.prisma.post.findMany({
    where: { published: true },
    include: { author: true },
    orderBy: { createdAt: 'desc' }
  });
}

Rails integrates database operations into the framework's core patterns through Active Record:

 
class Post < ApplicationRecord
  belongs_to :user
  has_many_attached :images

  validates :title, presence: true, length: { minimum: 5 }
  validates :content, presence: true

  scope :published, -> { where(published: true) }
  scope :recent, ->(limit = 10) { order(created_at: :desc).limit(limit) }

  def excerpt(length = 200)
    content.truncate(length)
  end
end

Database queries use methods that read like natural language:

 
# Find published posts with authors
@posts = Post.includes(:user)
             .published
             .recent

# Create a new post
@post = current_user.posts.create(
  title: "New Post",
  content: "Post content here",
  published: true
)

# Complex queries with conditions
@featured_posts = Post.joins(:user)
                     .where("posts.created_at > ? AND users.verified = ?", 
                            1.week.ago, true)
                     .published

Rails migrations provide version control for database schema changes:

 
class AddTagsToPosts < ActiveRecord::Migration[7.0]
  def change
    create_table :tags do |t|
      t.string :name, null: false
      t.timestamps
    end

    create_join_table :posts, :tags
    add_index :tags, :name, unique: true
  end
end

The contrast demonstrates each framework's priorities. NestJS requires explicit understanding of database operations but provides compile-time safety and clear dependency management. Rails hides database complexity behind intuitive Ruby methods, enabling faster development but potentially obscuring performance implications.

Authentication and authorization

Authentication implementation showcases how these frameworks balance security with developer productivity.

NestJS implements authentication through explicit service layers and guard mechanisms:

 
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { AuthService } from './auth.service';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private authService: AuthService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: process.env.JWT_SECRET,
    });
  }

  async validate(payload: any) {
    const user = await this.authService.validateUser(payload.sub);
    if (!user) {
      throw new UnauthorizedException('Token validation failed');
    }
    return user;
  }
}

Route protection uses decorators that make security requirements visible:

 
@Controller('posts')
export class PostsController {
  @Get('my-posts')
  @UseGuards(JwtAuthGuard)
  getUserPosts(@Request() req) {
    return this.postsService.findByUser(req.user.id);
  }

  @Delete(':id')
  @UseGuards(JwtAuthGuard, OwnershipGuard)
  deletePost(@Param('id') id: string, @Request() req) {
    return this.postsService.deleteUserPost(id, req.user.id);
  }
}

Custom guards can implement complex authorization logic:

 
@Injectable()
export class OwnershipGuard implements CanActivate {
  constructor(private postsService: PostsService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const postId = request.params.id;
    const userId = request.user.id;

    const post = await this.postsService.findOne(postId);
    return post && post.author.id === userId;
  }
}

Rails integrates authentication into the framework's conventional patterns:

 
class User < ApplicationRecord
  has_secure_password
  has_many :posts, dependent: :destroy

  validates :email, presence: true, uniqueness: true
  validates :password, length: { minimum: 8 }, if: :password_digest_changed?

  def generate_jwt_token
    JWT.encode({ user_id: id, exp: 24.hours.from_now.to_i }, 
               Rails.application.secrets.secret_key_base)
  end
end

Controller-level authentication uses before-action callbacks:

 
class ApplicationController < ActionController::Base
  before_action :authenticate_user!

  private

  def authenticate_user!
    token = request.headers['Authorization']&.split(' ')&.last
    return render_unauthorized unless token

    begin
      decoded_token = JWT.decode(token, Rails.application.secrets.secret_key_base)
      @current_user = User.find(decoded_token[0]['user_id'])
    rescue JWT::DecodeError, ActiveRecord::RecordNotFound
      render_unauthorized
    end
  end

  def current_user
    @current_user
  end
end

Authorization happens through straightforward conditional logic:

 
class PostsController < ApplicationController
  before_action :set_post, only: [:show, :edit, :update, :destroy]
  before_action :require_ownership, only: [:edit, :update, :destroy]

  def create
    @post = current_user.posts.build(post_params)

    if @post.save
      render json: @post, status: :created
    else
      render json: @post.errors, status: :unprocessable_entity
    end
  end

  private

  def require_ownership
    unless @post.user == current_user
      render json: { error: 'Unauthorized' }, status: :forbidden
    end
  end
end

The authentication approaches reflect each framework's core values. NestJS provides explicit, testable security components with clear separation of concerns. Rails integrates security into existing patterns, making it accessible to developers without specialized security knowledge.

Testing strategies

Testing approaches reveal how these frameworks prioritize code quality and maintainability.

NestJS emphasizes unit testing with comprehensive dependency mocking:

 
describe('PostsService', () => {
  let service: PostsService;
  let mockRepository: jest.Mocked<Repository<Post>>;

  beforeEach(async () => {
    const mockRepo = {
      find: jest.fn(),
      findOne: jest.fn(),
      create: jest.fn(),
      save: jest.fn(),
      delete: jest.fn(),
    };

    const module = await Test.createTestingModule({
      providers: [
        PostsService,
        {
          provide: getRepositoryToken(Post),
          useValue: mockRepo
        }
      ],
    }).compile();

    service = module.get<PostsService>(PostsService);
    mockRepository = module.get(getRepositoryToken(Post));
  });

  it('should find published posts', async () => {
    const mockPosts = [
      { id: 1, title: 'Test Post', published: true },
      { id: 2, title: 'Another Post', published: true }
    ];

    mockRepository.find.mockResolvedValue(mockPosts as Post[]);

    const result = await service.findPublished();

    expect(mockRepository.find).toHaveBeenCalledWith({
      where: { published: true },
      relations: ['author'],
      order: { createdAt: 'DESC' }
    });
    expect(result).toEqual(mockPosts);
  });
});

Integration tests verify complete request flows:

 
describe('PostsController (e2e)', () => {
  let app: INestApplication;

  beforeEach(async () => {
    const moduleFixture = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  it('/posts (GET)', () => {
    return request(app.getHttpServer())
      .get('/posts')
      .expect(200)
      .expect((res) => {
        expect(Array.isArray(res.body)).toBe(true);
      });
  });
});

Rails provides integrated testing tools that work with the framework's conventions:

 
# test/models/post_test.rb
class PostTest < ActiveSupport::TestCase
  test "should validate presence of title" do
    post = Post.new(content: "Some content", published: true)
    assert_not post.valid?
    assert_includes post.errors[:title], "can't be blank"
  end

  test "should create post with valid attributes" do
    post = posts(:published_post) # fixture reference
    assert post.valid?
    assert post.published?
  end

  test "published scope should return only published posts" do
    published_count = Post.where(published: true).count
    assert_equal published_count, Post.published.count
  end
end

Controller tests verify HTTP interactions:

 
# test/controllers/posts_controller_test.rb
class PostsControllerTest < ActionDispatch::IntegrationTest
  setup do
    @user = users(:john)
    @post = posts(:published_post)
  end

  test "should get index" do
    get posts_url
    assert_response :success
    assert_includes response.body, @post.title
  end

  test "should create post when authenticated" do
    sign_in @user

    assert_difference('Post.count') do
      post posts_url, params: { 
        post: { 
          title: 'New Post', 
          content: 'Post content',
          published: true 
        }
      }
    end

    assert_redirected_to post_url(Post.last)
  end

  test "should require authentication for create" do
    assert_no_difference('Post.count') do
      post posts_url, params: { 
        post: { title: 'Unauthorized Post' }
      }
    end

    assert_redirected_to login_url
  end
end

Rails fixtures provide consistent test data:

 
# test/fixtures/posts.yml
published_post:
  title: "Published Post"
  content: "This post is published"
  published: true
  user: john

draft_post:
  title: "Draft Post"
  content: "This post is a draft"
  published: false
  user: jane

The testing approaches align with each framework's architecture. NestJS promotes isolated unit tests that verify individual components, supporting maintainable code through explicit dependencies. Rails emphasizes integration tests that verify complete feature functionality, ensuring the application works correctly from the user's perspective.

Background processing

Background job handling demonstrates how these frameworks approach asynchronous task processing.

NestJS uses Bull queues with Redis for sophisticated job management:

 
// email.module.ts
import { BullModule } from '@nestjs/bull';

@Module({
  imports: [
    BullModule.registerQueue({
      name: 'email-processing',
      redis: {
        host: 'localhost',
        port: 6379,
      },
    }),
  ],
  providers: [EmailService, EmailProcessor],
})
export class EmailModule {}

Job processors handle background tasks with explicit configuration:

 
import { Processor, Process } from '@nestjs/bull';
import { Job } from 'bull';

@Processor('email-processing')
export class EmailProcessor {
  constructor(private emailService: EmailService) {}

  @Process('welcome-email')
  async sendWelcomeEmail(job: Job<{ userId: number }>) {
    const { userId } = job.data;

    try {
      const user = await this.userService.findById(userId);
      await this.emailService.sendWelcomeEmail(user.email, user.name);

      console.log(`Welcome email sent successfully to user ${userId}`);
    } catch (error) {
      console.error(`Failed to send welcome email to user ${userId}:`, error);
      throw error; // This will trigger job retry
    }
  }

  @Process('newsletter')
  async sendNewsletter(job: Job<{ userIds: number[]; newsletterId: string }>) {
    const { userIds, newsletterId } = job.data;

    for (const userId of userIds) {
      await this.emailService.sendNewsletter(userId, newsletterId);
    }
  }
}

Services queue jobs with detailed options:

 
@Injectable()
export class UserService {
  constructor(@InjectQueue('email-processing') private emailQueue: Queue) {}

  async createUser(userData: CreateUserDto): Promise<User> {
    const user = await this.userRepository.save(userData);

    // Queue welcome email with retry configuration
    await this.emailQueue.add('welcome-email', 
      { userId: user.id }, 
      {
        delay: 5000,           // Wait 5 seconds before processing
        attempts: 3,           // Retry up to 3 times on failure
        backoff: 'exponential' // Use exponential backoff for retries
      }
    );

    return user;
  }
}

Rails traditionally uses background job libraries like Sidekiq with Active Job integration:

 
# app/jobs/application_job.rb
class ApplicationJob < ActiveJob::Base
  retry_on StandardError, wait: :exponentially_longer, attempts: 3
  discard_on ActiveJob::DeserializationError
end
app/jobs/welcome_email_job.rb
class WelcomeEmailJob < ApplicationJob
  queue_as :default

  def perform(user)
    UserMailer.welcome_email(user).deliver_now
    Rails.logger.info "Welcome email sent to #{user.email}"
  rescue => e
    Rails.logger.error "Failed to send welcome email to #{user.email}: #{e.message}"
    raise e # Re-raise to trigger retry mechanism
  end
end
app/jobs/newsletter_job.rb
class NewsletterJob < ApplicationJob
  queue_as :newsletters

  def perform(newsletter_id)
    newsletter = Newsletter.find(newsletter_id)
    newsletter.subscribers.find_each do |user|
      UserMailer.newsletter(user, newsletter).deliver_now
    end
  end
end

Rails controllers queue jobs using simple method calls:

 
class UsersController < ApplicationController
  def create
    @user = User.new(user_params)

    if @user.save
      # Queue welcome email to be sent asynchronously
      WelcomeEmailJob.perform_later(@user)

      render json: @user, status: :created
    else
      render json: @user.errors, status: :unprocessable_entity
    end
  end
end

Scheduled jobs use declarative syntax:

 
# app/jobs/cleanup_job.rb
class CleanupJob < ApplicationJob
  def perform
    # Remove unconfirmed users after 30 days
    User.where('created_at < ? AND confirmed_at IS NULL', 30.days.ago)
        .destroy_all

    # Clean up old log files
    Dir.glob(Rails.root.join('log', '*.log.*')).each do |file|
      File.delete(file) if File.mtime(file) < 7.days.ago
    end
  end
end

# config/schedule.rb (using whenever gem)
every 1.day, at: '2:00 am' do
  runner "CleanupJob.perform_later"
end

Both approaches provide reliable background processing, but with different complexity trade-offs. NestJS requires explicit Redis configuration and provides fine-grained control over job processing. Rails integrates background jobs into existing patterns with minimal configuration requirements.

Final thoughts

This article covered the key differences between NestJS and Ruby on Rails, showing how each framework approaches web development through different priorities and patterns.

Both frameworks create production-ready applications that can scale effectively. NestJS scales through architectural discipline and explicit component boundaries. Rails scales through caching strategies, database optimization, and horizontal deployment patterns.

Consider building a small prototype in both frameworks to evaluate which feels more natural for your specific use case. The framework that enables you to write maintainable code efficiently is the correct choice for your project.

Got an article suggestion? Let us know
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.