Remix 101 for Next.js Devs: Key Architectural Differences
Switching from Next.js to Remix? Start Here

If you're like me, you've probably been living comfortably in the Next.js ecosystem for a while now. The App Router, Server Components, and the familiar file-based routing have become second nature. But recently, I found myself diving into the Shopify ecosystem for a new project, and guess what? They're all-in on Remix.
At first, I'll admit, I was a bit skeptical. "Another React framework? Really?" But after spending some quality time with Remix, I've discovered it's not just different—it's refreshingly different. The way it handles server-side logic, data fetching, and forms feels both familiar and completely new at the same time.
The transition wasn't without its learning curve, though. There were moments where I found myself reaching for patterns that simply don't exist in Remix, and other times where I was amazed by how elegantly Remix solved problems I didn't even know I had.
What You'll Learn
In this article, we'll explore the key conceptual differences between Next.js and Remix that every Next.js developer needs to understand to work effectively with Remix. I've ranked these differences by their impact on your day-to-day development and how much mental adjustment they require.
We'll cover:
- Server-side logic boundaries - Where and how you write server code
- Data fetching patterns - Moving from various approaches to Remix's loader-centric model
- Routing philosophy - Single-file routes vs. Next.js's separation of concerns
- Form handling - Embracing progressive enhancement over pure client-side logic
- Dynamic routing - Similar concepts, different implementation
- Client-side data fetching - When and how to break out of the server-first pattern
- Layout patterns - Flexible outlet-based layouts vs. Next.js's structured approach
- API endpoints - Resource routes vs. traditional API routes
- HTTP method handling - Working within HTML form constraints
By the end, you'll have a solid mental model for thinking in Remix terms and understand why so many developers (and companies like Shopify) are gravitating toward this approach.
Let's dive in!
1. Server-Side Logic Location & Boundaries
Impact: HIGH | Learning Curve: MEDIUM | Philosophy: FUNDAMENTAL
This is probably the biggest mindset shift you'll encounter. In Next.js, you have multiple ways to write server-side code, and the boundaries can feel a bit blurred. Remix takes a much more opinionated approach.
Next.js 15 Approach:
- Server Components with "use server" directive
- API routes in separate files (
app/api/route.ts
) - Server Actions can be co-located in components
// app/posts/page.tsx
import { createPost } from './actions'
export default function PostsPage() {
return (
<form action={createPost}>
<input name="title" />
<button type="submit">Create</button>
</form>
)
}
// app/posts/actions.ts
'use server'
export async function createPost(formData: FormData) {
// server logic here
}
Remix Approach:
- Server logic ONLY in route files via
loader
andaction
- No "use server" directive - the boundary is file-based
- All components are client-side by default
// app/routes/posts.tsx
export const loader = async () => {
const posts = await getPosts();
return json({ posts });
};
export const action = async ({ request }) => {
const formData = await request.formData();
await createPost(formData);
return redirect('/posts');
};
export default function Posts() {
const { posts } = useLoaderData();
return (
<Form method="post">
<input name="title" />
<button type="submit">Create</button>
</Form>
);
}
The key insight here is that Remix makes the server/client boundary crystal clear: if it's in a loader
or action
, it runs on the server. Everything else is client-side.
2. Data Fetching Patterns
Impact: HIGH | Learning Curve: HIGH | Philosophy: FUNDAMENTAL
Next.js gives you multiple ways to fetch data, which can be both a blessing and a curse. Remix simplifies this with a single, consistent pattern.
Next.js 15 Approach:
- Server Components for initial data
- Client-side fetching with hooks/libraries
getServerSideProps
for pages that need it
// Server Component
export default async function Posts() {
const posts = await fetch('/api/posts').then(r => r.json());
return <div>{posts.map(post => <div key={post.id}>{post.title}</div>)}</div>;
}
Remix Approach:
loader
for GET requests (always runs on server)useLoaderData()
to consume in components- Must be JSON serializable
export const loader = async () => {
const posts = await getPosts();
return json({ posts }); // Must be serializable
};
export default function Posts() {
const { posts } = useLoaderData<typeof loader>();
return <div>{posts.map(post => <div key={post.id}>{post.title}</div>)}</div>;
}
The beauty of Remix's approach is its predictability. Every route that needs data has a loader
, and every component gets that data via useLoaderData()
. No guessing about where the data comes from or how it's fetched.
3. Routing Philosophy & File Structure
Impact: HIGH | Learning Curve: MEDIUM | Philosophy: MODERATE
Next.js separates concerns with different file types, while Remix embraces the single-file-per-route approach.
Next.js 15 Structure:
app/
layout.tsx // Layout component
page.tsx // Page component
posts/
page.tsx // Posts page
[id]/
page.tsx // Dynamic route
api/
posts/
route.ts // API endpoint
Remix Structure:
app/routes/
app.tsx // Layout route
app._index.tsx // Index route for /app
app.posts.tsx // /app/posts
app.posts.$id.tsx // /app/posts/:id
api.posts.ts // Resource route (no UI)
Remix's dot notation for nested routes takes some getting used to, but it's incredibly powerful once you grasp it. A single file can contain your layout, data loading, actions, and component—everything related to that route.
4. Form Handling & Mutations
Impact: HIGH | Learning Curve: MEDIUM | Philosophy: MODERATE
This is where Remix really shines. While Next.js has embraced Server Actions, Remix's approach to forms feels more native to the web platform.
Next.js 15 Approach:
'use server'
async function createPost(formData: FormData) {
// server logic
}
// Usage
<form action={createPost}>
<input name="title" />
<button type="submit">Create</button>
</form>
Remix Approach:
// Multiple forms on same page hitting different routes
export default function ProductsPage() {
const addFetcher = useFetcher();
const removeFetcher = useFetcher();
return (
<div>
{/* Posts to /products/add action */}
<addFetcher.Form method="post" action="/products/add">
<input name="name" />
<button type="submit">Add</button>
</addFetcher.Form>
{/* Posts to /products/remove action */}
<removeFetcher.Form method="post" action="/products/remove">
<input name="intent" value="delete" type="hidden" />
<input name="id" />
<button type="submit">Remove</button>
</removeFetcher.Form>
</div>
);
}
The useFetcher
hook is incredibly powerful—it lets you have multiple forms on the same page, each with their own loading states and error handling, all while maintaining progressive enhancement.
5. Dynamic Route Parameters
Impact: MEDIUM | Learning Curve: LOW | Philosophy: MODERATE
Both frameworks handle dynamic routes well, but the syntax differs slightly.
Next.js 15:
// app/posts/[id]/page.tsx
export default function Post({ params }: { params: { id: string } }) {
return <div>Post {params.id}</div>;
}
Remix:
// app/routes/posts.$id.tsx
export const loader = async ({ params }) => {
const post = await getPost(params.id);
return json({ post });
};
export default function Post() {
const { post } = useLoaderData();
return <div>{post.title}</div>;
}
The dollar sign syntax takes a moment to get used to, but it's quite intuitive once you're familiar with it.
6. Client-Side Data Fetching
Impact: MEDIUM | Learning Curve: LOW | Philosophy: MODERATE
Sometimes you need to fetch data on the client side. Both frameworks handle this, but Remix has some unique patterns.
Next.js 15:
'use client'
import { useState, useEffect } from 'react';
export default function Posts() {
const [posts, setPosts] = useState([]);
useEffect(() => {
fetch('/api/posts').then(r => r.json()).then(setPosts);
}, []);
return <div>{/* render posts */}</div>;
}
Remix:
export default function Posts() {
const fetcher = useFetcher();
useEffect(() => {
fetcher.load("/api/posts"); // Calls another route's loader
}, []);
return <div>{fetcher.data?.map(post => <div key={post.id}>{post.title}</div>)}</div>;
}
Remix's useFetcher
can call any route's loader, making it easy to fetch data from other routes without duplicating logic.
7. Layout Patterns
Impact: MEDIUM | Learning Curve: LOW | Philosophy: MINOR
Layouts work differently in each framework, but both are flexible.
Next.js 15:
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html>
<body>
<nav>Navigation</nav>
{children}
</body>
</html>
);
}
Remix:
// app/routes/app.tsx
import { Outlet } from "@remix-run/react";
export default function AppLayout() {
return (
<div>
<nav>Navigation</nav>
<Outlet />
</div>
);
}
Remix's outlet-based approach is more flexible—any route can become a layout just by including an <Outlet />
component.
8. Resource Routes vs API Routes
Impact: MEDIUM | Learning Curve: LOW | Philosophy: MINOR
Both frameworks support API endpoints, but they handle them differently.
Next.js 15:
// app/api/posts/route.ts
export async function GET() {
const posts = await getPosts();
return Response.json(posts);
}
export async function POST(request: Request) {
const data = await request.json();
await createPost(data);
return Response.json({ success: true });
}
Remix:
// app/routes/api.posts.ts
export const loader = async () => {
const posts = await getPosts();
return json(posts);
};
export const action = async ({ request }) => {
const formData = await request.formData();
await createPost(formData);
return json({ success: true });
};
// No default export = no UI, just data
Remix's resource routes are just regular routes without a UI component. The loader
handles GET requests, and action
handles mutations.
9. HTTP Method Handling
Impact: MEDIUM | Learning Curve: LOW | Philosophy: MINOR
This is where the web platform's limitations become apparent in Remix.
Next.js 15:
// app/api/posts/[id]/route.ts
export async function DELETE(request: Request, { params }) {
await deletePost(params.id);
return Response.json({ success: true });
}
// Client call
fetch('/api/posts/123', { method: 'DELETE' });
Remix:
// app/routes/posts.$id.tsx
export const action = async ({ request }) => {
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "delete") {
await deletePost(formData.get("id"));
return redirect("/posts");
}
};
// Form usage
<Form method="post">
<input name="intent" value="delete" type="hidden" />
<input name="id" value={post.id} type="hidden" />
<button type="submit">Delete</button>
</Form>
Since HTML forms only support GET and POST, Remix uses conventions like intent
to differentiate between different actions.
Key Takeaways for Next.js Developers
-
Server boundaries are file-based: No more
"use server"
directives. If it's in aloader
oraction
, it runs on the server. -
Data flows through loaders: Every route that needs data has a
loader
, and components consume that data viauseLoaderData()
. -
Embrace progressive enhancement: Remix's
<Form>
component works without JavaScript, then enhances the experience when JS loads. -
One file per route: A single route file can handle data loading, mutations, and UI—no need to separate concerns across multiple files.
-
useFetcher is your friend: For complex interactions, multiple forms, or cross-route communication,
useFetcher
is incredibly powerful.
The learning curve is moderate because the core React concepts remain the same, but the patterns are different enough to require some mental adjustment. Once you embrace Remix's philosophy of staying closer to web standards, you'll find it's a refreshing take on full-stack React development.
Give it a try on your next project—you might be surprised by how much you enjoy the clarity and simplicity of Remix's approach!