Next.js for Rails Developers: The Mental Model Shift
Your orientation to Next.js from a Rails perspective. Maps the mental model you already have (routes, controllers, views, models) onto Next.js's completely different architecture. Covers what Next.js actually is, how file-based routing replaces routes.rb, where controllers went, and why the folder structure is the architecture.
You know Rails. You can trace a request from URL to rendered page in your sleep: routes.rb → controller → model → view. MVC is burned into your brain.
Now you're looking at a Next.js codebase, and... where is everything? There's no routes file. No controllers folder. No clear separation between "backend" and "frontend." It feels like someone took Rails, threw it in a blender, and scattered the pieces across folders named things like [formId].
This tutorial is your orientation. By the end, you'll have a mental map that lets you navigate any Next.js codebase with the same confidence you have in Rails.
The Fundamental Difference: What Even Is Next.js?
In Rails, you have a server framework. It receives HTTP requests, processes them, and sends back HTML (or JSON). The browser is just a display layer. React might exist on the frontend, but it's separate - maybe you have a Rails API + React SPA, or you sprinkle React components into your ERB views.
Next.js flips this. It's a React framework - meaning React (a UI library) is the foundation, and Next.js adds the server capabilities on top. The mental shift:
Rails: Server framework that can render HTML → browser displays it
Next.js: React framework that can run on the server AND the client
Here's why this matters: In Next.js, everything is a component. Your "pages" are components. Your "layouts" are components. The line between "server code" and "client code" isn't about different files in different places - it's about which components run where.
This is weird if you're coming from Rails. In Rails, app/controllers is server code. Period. app/javascript is client code. Period. In Next.js, a single component file might have code that runs on the server during initial render AND code that runs on the client after the page loads.
We'll dig into that split in the next tutorial. For now, just hold onto this: Next.js is React with server superpowers, not a server with React sprinkled in.
Where Did routes.rb Go? File-Based Routing
In Rails, routing is explicit. You open config/routes.rb and declare your routes:
# Rails routes.rb
Rails.application.routes.draw do
resources :forms do
resources :submissions
member do
post :publish
end
end
get '/dashboard', to: 'dashboard#index'
end
This is powerful and flexible. You can see ALL your routes in one file. You can use constraints, namespaces, custom paths - full control.
Next.js takes a radically different approach: the folder structure IS your routes.
Look at form-flow's app directory:
app/
├── page.tsx → /
├── dashboard/
│ └── page.tsx → /dashboard
├── form/
│ └── [formId]/
│ ├── page.tsx → /form/:formId
│ ├── design/
│ │ └── page.tsx → /form/:formId/design
│ ├── logic/
│ │ └── page.tsx → /form/:formId/logic
│ └── submissions/
│ └── page.tsx → /form/:formId/submissions
└── api/
└── forms/
├── route.ts → GET/POST /api/forms
└── [formId]/
├── route.ts → GET/PUT/DELETE /api/forms/:formId
└── publish/
└── route.ts → POST /api/forms/:formId/publish
See the pattern? Folders become URL segments. page.tsx files become visitable routes. route.ts files become API endpoints.
The square brackets [formId] are dynamic segments - equivalent to :formId in Rails. When you visit /form/abc123, Next.js captures abc123 as the formId parameter.
This feels constraining at first. "What if I want /forms/:id AND /f/:id to hit the same page?" In Rails, trivial - just add another route. In Next.js, you'd need to create two folder structures or use middleware rewrites.
But here's the trade-off you get: you can never have a route that doesn't correspond to a real file. In Rails, you can define a route to a controller action that doesn't exist and get a runtime error. In Next.js, if the file exists, the route works. The filesystem is the source of truth.
Where Did Controllers Go? The Great Dissolution
This is where Rails developers get confused. In Rails, you have a clean separation:
app/
├── controllers/ ← handles requests, coordinates response
│ └── forms_controller.rb
├── models/ ← data and business logic
│ └── form.rb
└── views/ ← presentation
└── forms/
└── show.html.erb
Request comes in → controller fetches data → passes to view → view renders HTML. Clean layers.
In Next.js, the controller is dissolved into the components and API routes.
Let's trace a request through form-flow. When you visit /dashboard:
Rails would do:
1. Router matches /dashboard → DashboardController#index
2. Controller: @forms = current_user.forms
3. View renders dashboard/index.html.erb with @forms
Next.js does:
1. Folder structure matches /dashboard → app/dashboard/page.tsx
2. The page.tsx component IS the controller AND the view
Look at app/dashboard/page.tsx:
'use client'
export default function DashboardPage() {
const router = useRouter()
const handleNewForm = async () => {
const newForm = await api.createForm({...})
router.push(`/form/${newForm.id}`)
}
return (
<div className="px-28">
{/* ... header ... */}
<Button onClick={handleNewForm}>+ New Form</Button>
<FormsList />
</div>
)
}
There's no controller setting @forms. Instead:
- The component renders UI directly (view responsibility)
- Click handlers call API functions (controller-like coordination)
- Child components like <FormsList /> fetch their own data
The "controller logic" is distributed. Some of it lives in the page component. Some in child components. Some in custom hooks. Some in API route handlers.
This isn't chaos - it's a different organizing principle. Instead of organizing by architectural layer (controllers, models, views), Next.js organizes by feature/route. Everything related to /dashboard lives in or near app/dashboard/.
API Routes: Your "Backend" in Next.js
"Okay," you might be thinking, "but where do I put actual server-side logic? Database queries? Authentication checks?"
That's what the app/api folder is for. These are API routes - serverless functions that handle HTTP requests just like Rails controller actions.
Look at app/api/forms/route.ts:
export async function POST(request: NextRequest) {
// This is like FormsController#create
const session = await getServerSession(authOptions)
if (!session?.user?.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const json = await request.json()
const validatedData = formSchema.parse(json)
const form = await prisma.form.create({
data: { /* ... */ }
})
return NextResponse.json(form)
}
export async function GET() {
// This is like FormsController#index
const session = await getServerSession(authOptions)
// ... fetch and return forms
}
Each exported function name corresponds to an HTTP method. POST handles POST requests, GET handles GET requests, etc. The file path determines the URL: app/api/forms/route.ts → /api/forms.
Dynamic routes work the same way. app/api/forms/[formId]/route.ts handles /api/forms/:formId:
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ formId: string }> }
) {
const formId = (await params).formId // This is like params[:form_id] in Rails
// ... fetch specific form
}
export async function PUT(request: NextRequest, { params }) {
// FormsController#update equivalent
}
export async function DELETE(request: NextRequest, { params }) {
// FormsController#destroy equivalent
}
Key insight: In Rails, one controller file might have 7 actions (index, show, new, create, edit, update, destroy). In Next.js, these are split across multiple files by URL structure, with HTTP methods as function names within each file.
The Mental Map: Rails → Next.js
Here's your translation guide:
| Rails Concept | Next.js Equivalent |
|---|---|
config/routes.rb |
Folder structure in app/ |
resources :forms |
app/api/forms/route.ts + app/api/forms/[formId]/route.ts |
| Controller action | Exported function in route.ts (GET, POST, etc.) |
@variable in controller |
Props passed to components, or data fetched in components |
| ERB view | React component (.tsx file) |
application.html.erb layout |
app/layout.tsx |
Partials (_form.html.erb) |
React components imported into pages |
link_to |
<Link href="/path"> component |
redirect_to |
redirect('/path') or router.push('/path') |
params[:id] |
params.formId (from route params) |
request.body |
await request.json() |
The layout.tsx File: Your application.html.erb
In Rails, application.html.erb wraps every page:
<!DOCTYPE html>
<html>
<head>...</head>
<body>
<%= yield %>
</body>
</html>
In Next.js, app/layout.tsx does the same thing:
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<Providers>
{children} {/* This is like <%= yield %> */}
</Providers>
<Toaster />
</body>
</html>
);
}
The children prop is where the page content goes - exactly like <%= yield %> in Rails layouts.
You can also have nested layouts. If you created app/dashboard/layout.tsx, it would wrap all pages under /dashboard/*, with the root layout still wrapping everything. Rails can do this with content_for blocks, but it's more manual.
What About Models?
Here's an interesting thing: Next.js has no opinion about your data layer.
Rails gives you ActiveRecord baked in. Next.js says "use whatever you want." Form-flow uses Prisma (an ORM), but you could use raw SQL, Drizzle, or even fetch from a separate API.
The database access happens in API routes or Server Components (more on those next tutorial). You'll see patterns like:
import { prisma } from '@/lib/prisma'
// In an API route
const forms = await prisma.form.findMany({
where: { userId: user.id },
include: { blocks: true }
})
This is similar to ActiveRecord, but it's not built into Next.js - it's a separate library the project chose to use.
Try It Yourself
Open form-flow and trace a request manually:
- Start at the URL
/form/abc123/design - Find the corresponding file (hint: check
app/form/[formId]/design/) - Look at what that
page.tsxrenders - Find where the form data comes from (hint: look for API calls or data fetching)
- Trace one of those API calls to its handler in
app/api/
This is the same exercise you'd do in Rails (URL → routes.rb → controller → model → view), just with different landmarks.
Summary
- Next.js is React with server capabilities, not a server with React added. Everything is components.
- File-based routing: The folder structure IS your routes. No
routes.rb- if the file exists, the route works. - Controllers are dissolved: Page components and API routes split what Rails controllers do. Pages handle rendering, API routes handle data operations.
- API routes (
route.tsfiles) are your serverless backend. Export functions named after HTTP methods (GET, POST, PUT, DELETE). - Layouts (
layout.tsx) work likeapplication.html.erb, withchildrenbeing<%= yield %>. - No built-in ORM: Use whatever data layer you want. Form-flow uses Prisma.
The biggest mindset shift: stop looking for the Rails patterns. Next.js organizes code by route/feature rather than by architectural layer. Once you accept that, navigation becomes intuitive.
Next up: We'll tackle the concept that truly has no Rails equivalent - Server Components vs Client Components. That's where Next.js gets genuinely weird and powerful.
Questions & Answers
[Questions and answers will be added here as the learner asks them during the tutorial]