Understanding the Next.js to Remix Migration
Migrating from Next.js to Remix means moving your application from one React-based web framework to another that embraces fundamentally different philosophies around data loading, routing, and server-side rendering. While both frameworks use React and support server-side rendering, Remix adopts the Web Fetch API standard and emphasizes progressive enhancement through native browser primitives. This migration isn't simply a find-and-replace operation — it requires restructuring how your application handles data, routes, and user interactions.
At its core, the migration involves converting Next.js page-based routing to Remix's file-based route modules, replacing getServerSideProps and getStaticProps with Remix loader functions, transforming API routes into Remix action functions, and rethinking layout components to leverage Remix's nested route layout system.
Why Migrate from Next.js to Remix?
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Understanding the motivations behind migration helps you make informed decisions about which patterns to adopt and how to prioritize your efforts. Here are the key reasons teams choose Remix over Next.js:
Web Standards-First Approach
Remix is built entirely on the Web Fetch API. Loaders receive a standard Request object and return a plain Response. This means your server-side code uses the same API as client-side fetch calls, reducing cognitive overhead and making your code portable across different JavaScript runtimes. You can test loaders with standard fetch mocking, run them in service workers, or deploy them to any platform that supports Web standards.
Elimination of Client-Side Waterfalls
Next.js applications often suffer from client-side data fetching waterfalls — the page loads, a loading spinner appears, then multiple fetch calls fire sequentially. Remix solves this by moving all data fetching to server-side loader functions that run before the page renders. The data is streamed to the client alongside the HTML, so the browser never shows a loading state for initial page loads.
Simplified Mutations with Actions
In Next.js, handling form submissions typically requires API routes, client-side fetch calls, and manual state management. Remix collapses this into action functions that handle mutations on the server, then automatically revalidate the page's data. Forms work without JavaScript, and when JavaScript loads, the experience becomes enhanced — true progressive enhancement baked into the framework.
Nested Layouts That Actually Work
Remix's nested routing system means parent route layouts remain mounted and maintain their state when navigating between child routes. This eliminates layout re-renders and state loss that plague many Next.js applications with complex navigation structures.
Step 1: Project Setup and Scaffolding
Begin by creating a new Remix project alongside your existing Next.js application. You'll migrate code incrementally rather than attempting a full rewrite in one pass.
# Create a new Remix application
npx create-remix@latest my-remix-app
cd my-remix-app
# Choose the appropriate deployment target
# Options include: Vercel, Cloudflare Pages, Netlify, or plain Node.js
Install the core dependencies you'll need during migration. Remix uses @remix-run/node for the Node.js adapter, @remix-run/react for client-side components, and @remix-run/serve for the production server if you're self-hosting.
npm install @remix-run/node @remix-run/react @remix-run/serve
npm install @remix-run/dev --save-dev
Your existing Next.js package.json scripts will need updating. Here's the comparison:
// Next.js package.json scripts
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
}
}
// Remix package.json scripts
{
"scripts": {
"dev": "remix dev",
"build": "remix build",
"start": "remix-serve build/server/index.js"
}
}
Step 2: Converting the Routing Structure
The most visible change when migrating is the routing system. Next.js uses a flat file-based routing structure where files in the pages/ directory map directly to URL paths. Remix uses a nested file-based routing system within the app/routes/ directory that supports layout nesting and parameterized segments.
Next.js Page Routes → Remix Route Files
// Next.js routing structure
pages/
index.js → /
about.js → /about
posts/
index.js → /posts
[slug].js → /posts/:slug
api/
hello.js → /api/hello
// Equivalent Remix routing structure
app/
routes/
_index.tsx → /
about.tsx → /about
posts._index.tsx → /posts
posts.$slug.tsx → /posts/:slug
api.hello.tsx → /api/hello (as route handler)
Notice several important conventions in Remix routing:
- Index routes use
_index.tsx(the underscore prevents the filename from being treated as a layout prefix) - Dynamic segments use
$paramNameinstead of Next.js bracket notation[paramName] - Nested layouts use dot notation —
posts.tsxbecomes the layout for all routes starting withposts. - API routes become regular route modules that export
loaderoractionfunctions
Layout Route Conversion
Here's a practical example of converting a Next.js layout pattern to Remix nested routes:
// Next.js: Using _app.js for shared layout
// pages/_app.js
import Navbar from '../components/Navbar';
import Footer from '../components/Footer';
export default function MyApp({ Component, pageProps }) {
return (
<div className="app-shell">
<Navbar />
<main>
<Component {...pageProps} />
</main>
<Footer />
</div>
);
}
// Remix equivalent: root.tsx as the outermost layout
// app/root.tsx
import { Links, Meta, Outlet, Scripts } from "@remix-run/react";
import Navbar from "../components/Navbar";
import Footer from "../components/Footer";
export default function Root() {
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<div className="app-shell">
<Navbar />
<main>
<Outlet />
</main>
<Footer />
</div>
<Scripts />
</body>
</html>
);
}
For nested layouts, create a parent route file that renders an <Outlet /> component. All child routes with the parent's prefix in their filename will render inside this outlet:
// app/routes/dashboard.tsx — layout route for /dashboard/*
import { Outlet, Link } from "@remix-run/react";
export default function DashboardLayout() {
return (
<div className="dashboard">
<aside>
<nav>
<Link to="/dashboard/analytics">Analytics</Link>
<Link to="/dashboard/settings">Settings</Link>
</nav>
</aside>
<section className="dashboard-content">
<Outlet />
</section>
</div>
);
}
// app/routes/dashboard.analytics.tsx — renders inside
export default function Analytics() {
return <h1>Analytics Dashboard</h1>;
}
// app/routes/dashboard.settings.tsx — also renders inside
export default function Settings() {
return <h1>Dashboard Settings</h1>;
}
This nested layout pattern preserves the sidebar state when navigating between analytics and settings — something that requires manual effort in Next.js with layout components.
Step 3: Converting Data Loading Patterns
This is the most substantial change in the migration. Next.js uses getServerSideProps and getStaticProps exported from page components. Remix uses loader functions that run on the server and return data consumed by the route component through the useLoaderData hook.
getServerSideProps → loader
// Next.js: pages/posts/[slug].js
export async function getServerSideProps(context) {
const { slug } = context.params;
const res = await fetch(`https://api.example.com/posts/${slug}`);
const post = await res.json();
if (!post) {
return { notFound: true };
}
return {
props: { post },
};
}
export default function PostPage({ post }) {
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
// Remix: app/routes/posts.$slug.tsx
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
export async function loader({ params, request }) {
const { slug } = params;
const res = await fetch(`https://api.example.com/posts/${slug}`);
if (!res.ok) {
throw new Response("Post not found", { status: 404 });
}
const post = await res.json();
return json({ post });
}
export default function PostPage() {
const { post } = useLoaderData<typeof loader>();
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
getStaticProps → loader with Cache Headers
Remix doesn't have a direct equivalent of getStaticProps for build-time static generation. Instead, you use loader functions with cache headers to achieve similar performance characteristics. For truly static data, you can implement a caching layer:
// Next.js: pages/blog.js — static generation
export async function getStaticProps() {
const posts = await fetchBlogPosts(); // runs at build time
return {
props: { posts },
revalidate: 3600, // ISR: revalidate every hour
};
}
// Remix: app/routes/blog._index.tsx — server-rendered with caching
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
export async function loader({ request }) {
const posts = await fetchBlogPosts();
return json(
{ posts },
{
headers: {
"Cache-Control": "public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400",
"CDN-Cache-Control": "public, max-age=3600",
},
}
);
}
export default function BlogPage() {
const { posts } = useLoaderData<typeof loader>();
return (
<div>
<h1>Blog Posts</h1>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
);
}
The cache headers approach works well with CDNs and edge platforms. For applications requiring true static generation at build time, consider using Remix with a static site generation plugin or deploying to platforms that support edge caching.
Multiple Data Sources in Parallel
Remix loaders can fetch multiple data sources in parallel using Promise.all, and all data resolves before the page renders — eliminating client-side waterfalls entirely:
// Next.js approach: data fetched client-side after page load
// This creates a waterfall: page → loading spinner → fetch → render
export default function Dashboard() {
const [user, setUser] = useState(null);
const [stats, setStats] = useState(null);
useEffect(() => {
fetch('/api/user').then(r => r.json()).then(setUser);
fetch('/api/stats').then(r => r.json()).then(setStats);
}, []);
if (!user || !stats) return <Loading />;
return <DashboardContent user={user} stats={stats} />;
}
// Remix approach: all data fetched server-side in parallel
// No loading state on initial navigation
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
export async function loader({ request }) {
const [user, stats] = await Promise.all([
fetchUser(request),
fetchStats(),
]);
return json({ user, stats });
}
export default function Dashboard() {
const { user, stats } = useLoaderData<typeof loader>();
return <DashboardContent user={user} stats={stats} />;
}
Step 4: Converting API Routes to Actions and Resource Routes
Next.js API routes under pages/api/ become Remix route modules that export loader functions for GET requests and action functions for mutations (POST, PUT, DELETE, PATCH). Remix unifies the concept of "page" and "API endpoint" into a single route module that can handle both rendering and data operations.
API Route → Action Function
// Next.js: pages/api/subscribe.js — standalone API route
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const { email } = req.body;
try {
await subscribeToNewsletter(email);
res.status(200).json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Subscription failed' });
}
}
// Remix: app/routes/subscribe.tsx — route with action
import { json, redirect } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
export async function action({ request }) {
const formData = await request.formData();
const email = formData.get("email");
if (!email || typeof email !== "string") {
return json({ error: "Email is required" }, { status: 400 });
}
try {
await subscribeToNewsletter(email);
return redirect("/thank-you");
} catch (error) {
return json({ error: "Subscription failed. Please try again." }, { status: 500 });
}
}
export default function SubscribePage() {
const actionData = useActionData<typeof action>();
return (
<div>
<h1>Subscribe</h1>
<Form method="post">
<input type="email" name="email" required />
<button type="submit">Subscribe</button>
</Form>
{actionData?.error && (
<p className="error">{actionData.error}</p>
)}
</div>
);
}
Resource Routes (JSON Endpoints)
For routes that only serve JSON data without rendering HTML, Remix supports resource routes. These are route files that export a loader or action but no default component:
// app/routes/api.users.tsx — resource route (no default export)
import { json } from "@remix-run/node";
export async function loader({ request }) {
const url = new URL(request.url);
const search = url.searchParams.get("search");
const users = await searchUsers(search);
return json({ users }, {
headers: {
"Access-Control-Allow-Origin": "*",
},
});
}
Resource routes are identified by filenames that don't have a corresponding UI component export. They're perfect for webhook endpoints, OAuth callbacks, and internal API surfaces.
Step 5: Handling Forms and Mutations
Remix revolutionizes form handling by embracing the browser's native <form> element. Instead of preventing default form behavior and using fetch in an onSubmit handler, Remix lets forms submit naturally to the server's action function. This means forms work before JavaScript loads, and when JavaScript hydrates, the experience becomes enhanced with optimistic UI updates and pending states.
Basic Form Conversion
// Next.js: Form with client-side fetch
import { useState } from 'react';
export default function ContactForm() {
const [status, setStatus] = useState('idle');
async function handleSubmit(e) {
e.preventDefault();
setStatus('submitting');
const res = await fetch('/api/contact', {
method: 'POST',
body: JSON.stringify({ name: e.target.name.value, message: e.target.message.value }),
headers: { 'Content-Type': 'application/json' },
});
if (res.ok) {
setStatus('success');
} else {
setStatus('error');
}
}
return (
<form onSubmit={handleSubmit}>
<input name="name" />
<textarea name="message" />
<button disabled={status === 'submitting'}>
{status === 'submitting' ? 'Sending...' : 'Send'}
</button>
{status === 'success' && <p>Sent successfully!</p>}
</form>
);
}
// Remix: Native form with action
import { Form, useActionData, useNavigation } from "@remix-run/react";
export async function action({ request }) {
const formData = await request.formData();
const name = formData.get("name");
const message = formData.get("message");
await saveContactMessage({ name, message });
return json({ success: true });
}
export default function ContactForm() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
return (
<Form method="post">
<input name="name" required />
<textarea name="message" required />
<button disabled={isSubmitting}>
{isSubmitting ? 'Sending...' : 'Send'}
</button>
{actionData?.success && <p>Sent successfully!</p>}
</Form>
);
}
Optimistic UI with useFetcher
For mutations that shouldn't trigger a navigation, use the useFetcher hook. This is the Remix equivalent of client-side mutations without page reloads:
// Next.js: Optimistic like button
function LikeButton({ postId }) {
const [liked, setLiked] = useState(false);
const [count, setCount] = useState(0);
async function toggleLike() {
// Optimistic update
setLiked(prev => !prev);
setCount(prev => liked ? prev - 1 : prev + 1);
await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
}
return (
<button onClick={toggleLike}>
{liked ? '❤️' : '🤍'} {count} likes
</button>
);
}
// Remix: Optimistic UI with useFetcher
import { useFetcher } from "@remix-run/react";
// app/routes/posts.$postId.like.tsx — resource route
export async function action({ params, request }) {
const { postId } = params;
await toggleLikeInDatabase(postId);
const newCount = await getLikeCount(postId);
return json({ liked: true, count: newCount });
}
function LikeButton({ postId, initialLiked, initialCount }) {
const fetcher = useFetcher();
// Use fetcher data if available, otherwise use initial props
const liked = fetcher.formData ? !initialLiked : initialLiked;
const count = fetcher.data?.count ?? initialCount;
return (
<fetcher.Form method="post" action={`/posts/${postId}/like`}>
<button type="submit">
{liked ? '❤️' : '🤍'} {count} likes
</button>
</fetcher.Form>
);
}
Step 6: Adapting Authentication and Middleware
Next.js uses middleware in /middleware.ts for request interception before page rendering. Remix handles authentication within loader functions, giving you per-route control over access. You can create a shared authentication utility and use it across loaders.
// Next.js: middleware.ts — global middleware
// middleware.ts
import { NextResponse } from 'next/server';
export function middleware(request) {
const token = request.cookies.get('session');
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
// Remix: Authentication in loaders with a shared utility
// app/utils/auth.server.ts
import { createCookieSessionStorage, redirect } from "@remix-run/node";
const sessionStorage = createCookieSessionStorage({
cookie: {
name: "session",
secrets: [process.env.SESSION_SECRET],
secure: process.env.NODE_ENV === "production",
},
});
export async function requireUser(request: Request) {
const session = await sessionStorage.getSession(request.headers.get("Cookie"));
const userId = session.get("userId");
if (!userId) {
throw redirect("/login");
}
return userId;
}
// app/routes/dashboard.tsx
export async function loader({ request }) {
const userId = await requireUser(request);
const dashboardData = await fetchDashboardData(userId);
return json({ dashboardData });
}
Protecting Multiple Routes
Instead of a global middleware file, create a helper that you call in every loader that needs protection. This gives you granular control — some routes might require admin access while others just need authentication:
// app/utils/auth.server.ts — extended with role checking
export async function requireAdmin(request: Request) {
const userId = await requireUser(request);
const user = await getUserById(userId);
if (user.role !== 'admin') {
throw new Response("Forbidden", { status: 403 });
}
return user;
}
// app/routes/admin.users.tsx
export async function loader({ request }) {
const admin = await requireAdmin(request);
const allUsers = await fetchAllUsers();
return json({ users: allUsers });
}
This approach is more explicit than Next.js middleware and makes security auditing easier — you can see exactly which routes enforce authentication by looking at their loader functions.
Step 7: Environment Variables and Configuration
Remix handles environment variables differently from Next.js. While Next.js automatically exposes variables prefixed with NEXT_PUBLIC_ to the client, Remix requires you to explicitly decide what gets exposed.
// Next.js: Automatic client exposure
// .env.local
NEXT_PUBLIC_API_URL=https://api.example.com // accessible in browser
DATABASE_URL=postgres://... // server-only automatically
// Remix: Explicit exposure via loader
// .env
API_URL=https://api.example.com // server-only by default
DATABASE_URL=postgres://...
// app/routes/_index.tsx — expose env vars through loader
export async function loader() {
return json({
ENV: {
API_URL: process.env.API_URL, // explicitly expose what the client needs
},
});
}
// app/root.tsx — use a context provider for env vars
import { createContext, useContext } from "react";
const EnvContext = createContext(null);
export function useEnv() {
return useContext(EnvContext);
}
// In your root loader, fetch the env and pass it down
// Then in your root component, wrap children with EnvContext.Provider
Step 8: Handling CSS and Styling Solutions
CSS Modules, Tailwind CSS, and styled-components all work differently in Remix compared to Next.js. Here's how to migrate each approach:
CSS Modules Migration
Remix supports CSS Modules through the links export in route modules. Each route can export a links function that returns stylesheet references:
// Next.js: CSS Modules (automatic)
// components/Button.module.css
.button { background: blue; color: white; }
// components/Button.jsx
import styles from './Button.module.css';
export default function Button() {
return <button className={styles.button}>Click</button>;
}
// Remix: CSS Modules via links export
// app/components/Button.module.css
.button { background: blue; color: white; }
// app/components/Button.tsx
import styles from './Button.module.css';
export default function Button() {
return <button className={styles.button}>Click</button>;
}
// app/routes/some-route.tsx — import styles at route level
import buttonStyles from "../components/Button.module.css";
export function links() {
return [
{ rel: "stylesheet", href: buttonStyles },
];
}
Tailwind CSS Setup
# Install Tailwind CSS for Remix
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init
# tailwind.config.js
export default {
content: ["./app/**/*.{ts,tsx}"],
theme: { extend: {} },
plugins: [],
};
# postcss.config.js
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
// app/root.tsx — import Tailwind in the root layout
import tailwindStyles from "../styles/tailwind.css";
export function links() {
return [
{ rel: "stylesheet", href: tailwindStyles },
];
}
Step 9: Error Handling and Boundary Concepts
Remix introduces error boundaries at the route level, replacing Next.js's _error.js pattern. Each route can export an ErrorBoundary component that catches errors thrown in its loader, action, or component:
// Next.js: Custom error page
// pages/_error.js
function ErrorPage({ statusCode, message }) {
return (
<div>
<h1>{statusCode || 'Error'}</h1>
<p>{message || 'Something went wrong'}</p>
</div>
);
}
// Remix: Route-level error boundaries
// app/routes/posts.$slug.tsx
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
export async function loader({ params }) {
const post = await fetchPost(params.slug);
if (!post) {
throw new Response("Post not found", { status: 404 });
}
return json({ post });
}
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div className="error-container">
<h1>{error.status}</h1>
<p>{error.data}</p>
<Link to="/posts">Back to all posts</Link>
</div>
);
}
return (
<div className="error-container">
<h1>Unexpected Error</h1>
<p>{error.message}</p>
</div>
);
}
export default function PostPage() {
const { post } = useLoaderData<typeof loader>();
return <article>{post.title}</article>;
}
Error boundaries in Remix are nested — an error in a child route won't affect parent layouts. The sidebar, navbar, and other layout elements remain functional even when a specific route crashes, providing a much better user experience than full-page error screens.
Step 10: Deployment and Server Configuration
The final step is deploying your migrated Remix application. The deployment model differs significantly from Next.js:
// Next.js deployment on Vercel
// Automatic: just connect your repository
// Uses serverless functions with file-system routing
// Remix deployment — build output
remix build
// Generates:
// build/server/ — server-side code (loaders, actions)
// build/client/ — client-side assets (JS bundles, CSS)
// public/ — static files
// For Node.js deployment:
// package.json
{
"scripts": {
"build": "remix build",
"start": "remix-serve build/server/index.js"
}
}
// For Cloudflare Pages / Edge deployment:
// Use @remix-run/cloudflare adapter
npm install @remix-run/cloudflare
// remix.config.js
export default {
serverModuleFormat: "esm",
serverPlatform: "neutral",
};
Remix uses adapters to target different runtime environments. Choose the adapter that matches your deployment platform:
- @remix-run/node — Standard Node.js servers (Express, bare Node)
- @remix-run/cloudflare — Cloudflare Workers and Pages
- @remix-run/deno — Deno runtime
- @remix-run/bun — Bun runtime
Best Practices for a Smooth Migration
Migrate Incrementally, Not All at Once
Don't attempt to rewrite your entire application in a single pass. Start with a single route — perhaps a simple content page or a blog post — and migrate it completely. Run both applications simultaneously during the migration period, using a reverse proxy to route traffic between them based on path prefixes.
# nginx configuration for incremental migration
location /blog/ {
proxy_pass http://remix-app:3000;
}
location / {
proxy_pass http://nextjs-app:3001;
}
Keep Business Logic Framework-Agnostic
Extract your core business logic, API calls, and utility functions into framework-agnostic modules. This makes them portable between Next.js and Remix without modification:
// lib/api/posts.ts — framework-agnostic
export async function fetchPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`);
if (!res.ok) return null;
return res.json();
}
export async function fetchAllPosts() {
const res = await fetch('https://api.example.com/posts');
return res.json();
}
// Used identically in both Next.js getServerSideProps and Remix loader
import { fetchPost } from '../lib/api/posts';
Test Loaders and Actions Independently
Since Remix loaders and actions are pure functions that receive a Request and return a Response, you can test them without any browser environment:
// test/loader.test.ts
import { loader } from "../app/routes/posts.$slug";
test("returns post data for valid slug", async () => {
const request = new Request("http://localhost/posts/hello-world");
const response = await loader({
request,
params: { slug: "hello-world" },
context: {},
});
expect(response.status).toBe(200);
const data = await response.json();
expect(data.post.title).toBeDefined();
});
Leverage Remix's Built-in Performance Features
Remix automatically handles prefetching — when a <Link> component appears in the viewport, Remix prefetches the loader data for that route. This makes navigation feel instant. Ensure all your navigation uses Remix's <Link> component rather than raw <a> tags