Building Enterprise SaaS: Ruby on Rails + Next.js Architecture
Why Rails + Next.js?
When we started building MailCopilot, we could have gone with the trendy all-JavaScript stack. Instead, we chose Ruby on Rails + Next.js, and here's why.
The Stack
Backend: Ruby on Rails
Frontend: Next.js (React)
Database: PostgreSQL
Caching: Redis
The Architecture
Multi-Tenant from Day One
Every table has a `tenant_id`:
```ruby
class Message < ApplicationRecord
belongs_to :tenant
belongs_to :mailbox
# Automatically scope all queries to current tenant
acts_as_tenant :tenant
end
```
This gives us:
API Design
Rails serves a JSON API, Next.js consumes it:
```ruby
Rails: api/v1/messages_controller.rb
class Api::V1::MessagesController < ApplicationController
def index
messages = current_tenant.messages
.includes(:mailbox, :classification, :draft_response)
.order(received_at: :desc)
.page(params[:page])
render json: messages, serializer: MessageSerializer
end
end
```
```typescript
// Next.js: lib/api/messages.ts
export async function getMessages(page = 1) {
const response = await fetch(`/api/v1/messages?page=${page}`)
return response.json()
}
```
Background Jobs
Heavy lifting happens asynchronously with Sidekiq:
```ruby
class InboxSyncJob < ApplicationJob
queue_as :high_priority
def perform(mailbox_id)
mailbox = Mailbox.find(mailbox_id)
# Fetch new emails from Gmail/Outlook
gmail = GmailService.new(mailbox)
messages = gmail.fetch_messages(since: mailbox.last_sync_at)
# Process each message
messages.each do |email_data|
message = Message.create_from_email(mailbox, email_data)
# Queue AI classification
ClassifyMessageJob.perform_later(message.id)
end
mailbox.update(last_sync_at: Time.current)
end
end
```
Real-Time Updates
Action Cable (Rails) + WebSockets (Next.js):
```ruby
Rails: channels/inbox_channel.rb
class InboxChannel < ApplicationCable::Channel
def subscribed
stream_for current_tenant
end
end
Broadcast updates
InboxChannel.broadcast_to(tenant, {
type: 'new_message',
message: MessageSerializer.new(message).as_json
})
```
```typescript
// Next.js: hooks/useInboxSubscription.ts
export function useInboxSubscription() {
useEffect(() => {
const cable = createConsumer(WS_URL)
const subscription = cable.subscriptions.create('InboxChannel', {
received: (data) => {
if (data.type === 'new_message') {
addMessage(data.message)
}
}
})
return () => subscription.unsubscribe()
}, [])
}
```
Key Design Decisions
1. Monolith Backend, Not Microservices
Why?
When we'll split:
2. JSON API, Not GraphQL
Why JSON API?
GraphQL trade-offs:
3. Server-Side Rendering (SSR) for Marketing Pages
Public pages use Next.js SSR:
Authenticated app is client-side:
4. Database Choices
Why PostgreSQL?
Not using:
Performance Optimizations
1. Query Optimization
N+1 Queries Prevention:
```ruby
Bad
messages = Message.all
messages.each do |message|
puts message.classification.category # N+1 query
end
Good
messages = Message.includes(:classification).all
messages.each do |message|
puts message.classification.category # Single query
end
```
Database Indexes:
```ruby
add_index :messages, [:tenant_id, :received_at]
add_index :messages, [:mailbox_id, :processed]
add_index :classifications, [:message_id, :category]
```
2. Caching Strategy
Redis Caching:
```ruby
class Message < ApplicationRecord
def ai_summary
Rails.cache.fetch("message_summary_#{id}", expires_in: 1.hour) do
OpenAI.generate_summary(body)
end
end
end
```
Fragment Caching:
```ruby
View caching
<% cache message do %>
<%= render message %>
<% end %>
```
3. Background Processing
Queue Priorities:
```ruby
class EmailSyncJob < ApplicationJob
queue_as :high_priority
end
class AnalyticsJob < ApplicationJob
queue_as :low_priority
end
```
Batch Processing:
```ruby
Message.unprocessed.find_in_batches(batch_size: 100) do |batch|
ClassifyBatchJob.perform_later(batch.map(&:id))
end
```
Security Considerations
1. Tenant Isolation
Row-Level Security:
```ruby
class ApplicationController < ActionController::API
before_action :set_current_tenant
private
def set_current_tenant
ActsAsTenant.current_tenant = current_user.tenant
end
end
```
2. API Authentication
JWT Tokens:
```ruby
def encode_token(payload)
JWT.encode(payload, Rails.application.secrets.secret_key_base)
end
def decode_token(token)
JWT.decode(token, Rails.application.secrets.secret_key_base)[0]
end
```
3. Rate Limiting
Rack Attack:
```ruby
Rack::Attack.throttle('api/ip', limit: 100, period: 1.minute) do |req|
req.ip if req.path.start_with?('/api/')
end
```
Deployment
Production Stack
CI/CD Pipeline
```yaml
.github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Run Tests
run: bundle exec rspec
- name: Deploy to Production
run: cap production deploy
```
Lessons Learned
1. Rails is Still Relevant
Despite the hype around newer frameworks:
2. Next.js is Worth the Learning Curve
Modern React with Next.js provides:
3. Don't Over-Engineer Early
We started simple:
Scale when you need to, not before.
Want to Build Something Similar?
We've built three production SaaS applications with this stack. If you're planning a new project, [let's discuss](/contact) how we can help.
*Questions about this architecture? [Reach out](/contact) — we love talking about technical decisions.*