Your First Remix App: File-Based Routing vs Next.js Approach
Your First Remix App: File-Based Routing vs Next.js Approach
As a React developer who's spent years working with Next.js, I was initially skeptical when Remix entered the scene. Another React framework? Really? But after building several production applications with Remix, I've come to appreciate its unique approach to full-stack development. If you're comfortable with Next.js but curious about Remix, this guide will walk you through building your first Remix app while highlighting the key differences in their routing philosophies.
Why Remix is Different: The Full-Stack React Philosophy
Before diving into code, it's crucial to understand Remix's core philosophy. While Next.js started as a React framework and gradually added full-stack features, Remix was designed from the ground up as a full-stack web framework that happens to use React for the UI.
The fundamental difference lies in how each framework thinks about data:
- Next.js approach: Client-side state management with API routes as separate endpoints
- Remix approach: Server-side data loading and mutations tightly coupled with your components
This philosophical difference becomes apparent in their routing systems, which we'll explore in detail.
Setting Up Your First Remix Project (Remix 2.0)
Let's start by creating a new Remix project. Remix 2.0 introduced significant improvements, including better TypeScript support and enhanced developer experience.
npx create-remix@latest my-first-remix-app
cd my-first-remix-app
npm install
When prompted, choose:
- Deploy target: Remix App Server (for local development)
- TypeScript: Yes
- Install dependencies: Yes
Your initial project structure will look like this:
my-first-remix-app/
├── app/
│ ├── entry.client.tsx
│ ├── entry.server.tsx
│ ├── root.tsx
│ └── routes/
│ └── _index.tsx
├── public/
├── remix.config.js
└── package.json
Compare this to a Next.js project structure:
my-nextjs-app/
├── pages/
│ ├── api/
│ ├── _app.tsx
│ └── index.tsx
├── public/
├── next.config.js
└── package.json
Notice how Remix uses an app
directory with a routes
folder, while Next.js traditionally uses a pages
directory (though Next.js 13+ also supports an app
directory with different conventions).
File-Based Routing: Remix vs Next.js Side-by-Side
This is where things get interesting. Both frameworks use file-based routing, but their approaches differ significantly.
Next.js Routing Example
In Next.js, you might structure routes like this:
pages/
├── index.tsx # /
├── about.tsx # /about
├── blog/
│ ├── index.tsx # /blog
│ └── [slug].tsx # /blog/[slug]
└── api/
└── posts.ts # /api/posts
Remix Routing Example
Remix uses a more flexible naming convention:
app/routes/
├── _index.tsx # /
├── about.tsx # /about
├── blog._index.tsx # /blog
├── blog.$slug.tsx # /blog/[slug]
└── blog.new.tsx # /blog/new
The key differences:
- Dot notation: Remix uses dots to create nested routes
- Underscore prefixes:
_index.tsx
represents index routes - Dollar sign parameters:
$slug
instead of[slug]
for dynamic segments - No separate API directory: API functionality is built into route files
Let's build a practical example to demonstrate these concepts.
Building Your First Route with Data Loading
Let's create a blog post listing page that fetches data server-side. This will showcase Remix's unique approach to data loading.
First, create app/routes/blog._index.tsx
:
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, Link } from "@remix-run/react";
// This is the server-side data loading function
export async function loader({ request }: LoaderFunctionArgs) {
// Simulate fetching blog posts from an API or database
const posts = [
{ id: 1, title: "Getting Started with Remix", slug: "getting-started-remix" },
{ id: 2, title: "Remix vs Next.js Routing", slug: "remix-vs-nextjs-routing" },
{ id: 3, title: "Full-Stack React with Remix", slug: "fullstack-react-remix" },
];
return json({ posts });
}
export default function BlogIndex() {
const { posts } = useLoaderData<typeof loader>();
return (
<div className="max-w-4xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-8">Blog Posts</h1>
<div className="space-y-4">
{posts.map((post) => (
<div key={post.id} className="border rounded-lg p-4">
<h2 className="text-xl font-semibold mb-2">
<Link
to={`/blog/${post.slug}`}
className="text-blue-600 hover:underline"
>
{post.title}
</Link>
</h2>
</div>
))}
</div>
</div>
);
}
Now, let's compare this to the equivalent Next.js approach:
// Next.js pages/blog/index.tsx
import { GetServerSideProps } from 'next';
import Link from 'next/link';
interface BlogPost {
id: number;
title: string;
slug: string;
}
interface Props {
posts: BlogPost[];
}
export default function BlogIndex({ posts }: Props) {
return (
<div className="max-w-4xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-8">Blog Posts</h1>
<div className="space-y-4">
{posts.map((post) => (
<div key={post.id} className="border rounded-lg p-4">
<h2 className="text-xl font-semibold mb-2">
<Link href={`/blog/${post.slug}`}>
<a className="text-blue-600 hover:underline">
{post.title}
</a>
</Link>
</h2>
</div>
))}
</div>
</div>
);
}
export const getServerSideProps: GetServerSideProps = async () => {
const posts = [
{ id: 1, title: "Getting Started with Remix", slug: "getting-started-remix" },
{ id: 2, title: "Remix vs Next.js Routing", slug: "remix-vs-nextjs-routing" },
{ id: 3, title: "Full-Stack React with Remix", slug: "fullstack-react-remix" },
];
return { props: { posts } };
};
Key differences:
- Loader vs getServerSideProps: Remix's loader function is more straightforward and returns JSON directly
- Type safety: Remix's
useLoaderData<typeof loader>()
provides automatic type inference - Component location: In Remix, the loader and component are in the same file
Forms and Actions: The Remix Way
This is where Remix really shines. Let's add a form to create new blog posts. Create app/routes/blog.new.tsx
:
import { json, redirect, type ActionFunctionArgs } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const title = formData.get("title");
const content = formData.get("content");
// Validation
const errors: { title?: string; content?: string } = {};
if (!title || typeof title !== "string" || title.length < 3) {
errors.title = "Title must be at least 3 characters long";
}
if (!content || typeof content !== "string" || content.length < 10) {
errors.content = "Content must be at least 10 characters long";
}
if (Object.keys(errors).length > 0) {
return json({ errors }, { status: 400 });
}
// Simulate saving to database
console.log("Saving post:", { title, content });
return redirect("/blog");
}
export default function NewBlogPost() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
return (
<div className="max-w-2xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-8">Create New Blog Post</h1>
<Form method="post" className="space-y-6">
<div>
<label htmlFor="title" className="block text-sm font-medium mb-2">
Title
</label>
<input
type="text"
id="title"
name="title"
className="w-full border rounded-lg px-3 py-2"
aria-invalid={actionData?.errors?.title ? true : undefined}
/>
{actionData?.errors?.title && (
<p className="text-red-600 text-sm mt-1">{actionData.errors.title}</p>
)}
</div>
<div>
<label htmlFor="content" className="block text-sm font-medium mb-2">
Content
</label>
<textarea
id="content"
name="content"
rows={10}
className="w-full border rounded-lg px-3 py-2"
aria-invalid={actionData?.errors?.content ? true : undefined}
/>
{actionData?.errors?.content && (
<p className="text-red-600 text-sm mt-1">{actionData.errors.content}</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="bg-blue-600 text-white px-4 py-2 rounded-lg disabled:opacity-50"
>
{isSubmitting ? "Creating..." : "Create Post"}
</button>
</Form>
</div>
);
}
The equivalent Next.js implementation would require:
- A separate API route (
pages/api/blog.ts
) - Client-side form handling with
useState
- Manual loading states and error handling
- Client-side navigation after success
Remix's approach eliminates much of this boilerplate by leveraging web standards like HTML forms and HTTP methods.
Styling Your App: CSS vs Tailwind Integration
Remix provides several options for styling. Let's set up Tailwind CSS, which works great with both frameworks but has slightly different setup procedures.
Install Tailwind CSS:
npm install -D tailwindcss @types/node
npx tailwindcss init -p
Update your tailwind.config.js
:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./app/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {},
},
plugins: [],
}
Create app/tailwind.css
:
@tailwind base;
@tailwind components;
@tailwind utilities;
Update app/root.tsx
to include the styles:
import type { LinksFunction } from "@remix-run/node";
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
import stylesheet from "./tailwind.css";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: stylesheet },
];
export default function App() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
Deployment Options: Vercel vs Fly.io vs Railway
Remix's deployment flexibility is one of its strengths. Unlike Next.js, which is tightly coupled to Vercel's infrastructure, Remix can deploy anywhere Node.js runs.
Vercel Deployment
Create remix.config.js
:
/** @type {import('@remix-run/dev').AppConfig} */
export default {
ignoredRouteFiles: ["**/.*"],
serverAdapter: "@remix-run/vercel",
};
Add to package.json
:
{
"scripts": {
"build": "remix build",
"dev": "remix dev"
}
}
Fly.io Deployment
Remix works excellently with Fly.io. Generate a Fly.io configuration:
fly launch
This creates a fly.toml
file and Dockerfile automatically configured for your Remix app.
Railway Deployment
Railway offers one-click Remix deployment. Simply connect your GitHub repository, and Railway will automatically detect and deploy your Remix app.
When to Choose Remix Over Next.js in 2025
After building applications with both frameworks, here's my practical advice on when to choose each:
Choose Remix when:
- You want simpler full-stack development without separate API routes
- Your app is form-heavy or requires complex server-side interactions
- You prefer web standards over framework-specific abstractions
- You need deployment flexibility across different platforms
- You're building a content-heavy site that benefits from server-side rendering
Choose Next.js when:
- You're building a heavily client-side application (SPA-like)
- You need advanced features like ISR (Incremental Static Regeneration)
- You're already invested in the Vercel ecosystem
- You need extensive third-party integrations and plugins
- You're working with a team already familiar with Next.js
As noted in recent discussions about AI-powered development tools, the choice of framework increasingly matters less than understanding the underlying concepts. Both Remix and Next.js are excellent choices for modern React development.
Getting Started with Your First Remix App
Now that you understand the core differences, here's your action plan:
- Start small: Build a simple blog or todo app to understand Remix's data flow
- Focus on forms: Practice with Remix's action functions and form handling
- Experiment with nested routing: Use Remix's dot notation to create complex layouts
- Try different deployment targets: Deploy the same app to Vercel, Fly.io, and Railway to understand the differences
The learning curve from Next.js to Remix is gentler than you might expect. The core React concepts remain the same; you're primarily learning new patterns for data loading and form handling.
Remix's approach to full-stack development feels more cohesive and closer to traditional web development patterns. Whether that's better for your specific use case depends on your project requirements and team preferences.
Ready to dive deeper? Start by building a simple CRUD application using the patterns we've covered. The transition from Next.js to Remix might just change how you think about full-stack React development.
Looking to integrate Remix into your next project? At Bedda.tech, we help teams adopt modern web frameworks and build scalable full-stack applications. Contact us to discuss your specific needs.