Back to Scaling Node.js Applications guides

Next.js vs Remix vs Nuxt 3: Choosing the Right Meta-Framework

Stanley Ulili
Updated on May 19, 2025

Next.js, Remix, and Nuxt 3 are popular meta-frameworks that make modern web development easier, but each takes a different approach.

Next.js is the most popular React meta-framework. It's versatile, has a large ecosystem, and offers multiple rendering options. It works well for many types of projects from small static sites to complex enterprise apps.

Remix brings a fresh take on React development. It focuses on web fundamentals and progressive enhancement. Its nested routing and data loading create responsive user experiences that work even without JavaScript.

Nuxt 3 offers similar features for Vue developers. It provides a complete framework for building fast Vue applications with server-side rendering, static generation, and powerful developer tools.

This article breaks down their differences, strengths, and ideal use cases to help you pick the right tool for your project.

What is Next.js?

Screenshot of Next.js Github page

Next.js is a React framework created by Vercel that makes it easier to build server-rendered and statically generated React apps.

Guillermo Rauch and the Vercel team first released it in 2016. Since then, Next.js has grown into a comprehensive solution that combines static and dynamic approaches. With features like the App Router and Server Components, Next.js continues to push web development forward.

Unlike basic React setups, Next.js gives you built-in infrastructure for rendering, routing, data fetching, and more without extensive configuration. It supports multiple rendering approaches from fully static to streaming server components, making it adaptable to different performance and SEO needs.

What is Remix?

Screenshot of Remix GitHub page

Remix is a React-based framework built by the team behind React Router. It focuses on web fundamentals and makes the most of the platform's built-in capabilities.

Ryan Florence and Michael Jackson launched Remix in 2021. It approaches web development by emphasizing progressive enhancement, nested routing, and data loading patterns that put user experience first. Instead of hiding web standards, Remix embraces them with its unique approach to forms, loading states, and error handling.

Remix creates a great developer experience without sacrificing user experience. It keeps responses fast by connecting data loading directly with UI components. Its architecture encourages patterns that result in responsive, resilient apps that work even when JavaScript fails.

What is Nuxt 3?

Screenshot of Nuxt GitHub page

Nuxt 3 is a Vue.js framework that offers a structured, convention-based approach to building Vue apps with server capabilities.

Completely rebuilt from previous versions, Nuxt 3 launched in 2022 and takes full advantage of Vue 3's Composition API and performance improvements. It delivers an intuitive development experience with automatic routing, layouts, and advanced features like server routes and middleware.

Nuxt 3 streamlines Vue development by providing sensible defaults while staying flexible. Its modular design supports both small projects and enterprise applications, with a growing ecosystem of modules that extend its functionality.

Next.js vs Remix vs Nuxt 3: a quick comparison

Your choice among these meta-frameworks affects both how you develop and how your app performs. Each has distinct priorities that make them better suited for different scenarios.

Here's a comparison of key differences:

Feature Next.js Remix Nuxt 3
Base UI framework React React Vue.js
Rendering strategies SSR, SSG, ISR, CSR SSR with progressive enhancement SSR, SSG, hybrid
Routing system File-based with App Router and Pages Router Nested routes with data loading File-based automatic routing
Data fetching Server Components, Route Handlers, API Routes Loaders and Actions tied to routes Server routes, Nitro engine
CSS strategy CSS Modules, styled-jsx, CSS-in-JS Route-based styling with links Global CSS, CSS Modules, Vue SFCs
Form handling Manual forms with actions Native form handling with actions Manual with composables
Error handling Error boundaries, notFound() Nested error boundaries Error pages, middleware
Developer experience Fast refresh, built-in optimization Excellent error overlay, nested boundaries Vue DevTools, development server
Deployment options Vercel optimized, supports many platforms Supports standard Node.js, Cloudflare Edge-ready, universal deployment
Meta-framework maturity Highly mature, extensive ecosystem Newer but rapidly growing Mature, recently redesigned
File size Larger bundle with React Focused on small initial JS payload Smaller with Vue 3
Learning curve Moderate, many patterns to learn Steeper initially, different mental model Gentle if familiar with Vue
Community size Largest, extensive resources Growing, excellent documentation Large Vue-based community
TypeScript support First-class TypeScript support Strong TypeScript integration Built from ground up with TypeScript

Routing and navigation

The routing system forms the foundation of any web framework. It determines how users navigate and how you structure your applications.

Next.js uses a file-based routing system that has evolved into two different approaches: the traditional Pages Router and the newer App Router. This makes routes intuitive to create but offers different development patterns depending on which router you choose:

 
// Pages Router - pages/users/[id].js
import { useRouter } from 'next/router';

export default function UserProfile() {
  const router = useRouter();
  const { id } = router.query;

  return <div>User Profile: {id}</div>;
}

// App Router - app/users/[id]/page.js
export default function UserProfile({ params }) {
  return <div>User Profile: {params.id}</div>;
}

Next.js routing is easy to understand but has different rendering and data-fetching capabilities depending on whether you use the Pages or App Router. The App Router introduces concepts like server components and parallel routes that offer powerful features but with a steeper learning curve.

Remix takes a different approach with nested routing that directly connects UI components to their data needs. This hierarchical structure allows for more sophisticated user experiences where parent and child routes can load and refresh independently:

app/routes/users.jsx
export default function Users() {
  return (
    <div>
      <h1>Users</h1>
      <nav>
        <Link to="new">New User</Link>
      </nav>

      {/* Child routes render here */}
      <Outlet />
    </div>
  );
}

// app/routes/users.$id.jsx - Child route
export default function UserProfile() {
  const { id } = useParams();
  const data = useLoaderData();

  return <div>User Profile: {id}</div>;
}

// Data loading tied directly to the route
export async function loader({ params }) {
  return fetchUser(params.id);
}

Remix encourages breaking down applications into nested components that manage their own data loading and changes. This creates a more intuitive mental model for complex applications and enables progressive enhancement where parts of the page can update independently.

Nuxt 3 simplifies routing with an automatic file-based system inspired by Next.js but with Vue's composition API. Pages are automatically registered based on their location in the file structure:

 
<!-- pages/users/[id].vue -->
<script setup>
const route = useRoute();
const { data: user } = await useFetch(`/api/users/${route.params.id}`);
</script>

<template>
  <div>
    <h1>User Profile: {{ route.params.id }}</h1>
    <p>Name: {{ user.name }}</p>
  </div>
</template>

Nuxt 3's Vue-based approach makes routing feel more integrated with the component structure. The framework automatically generates routes from files, handling dynamic segments and nested layouts while providing helpful composables like useRoute to access route information.

Data fetching

How these frameworks handle data loading significantly impacts both your development experience and your application's performance.

Next.js offers multiple ways to fetch data, with big differences between the Pages Router and App Router approaches. The App Router introduces React Server Components, which can fetch data directly within components without client-side JavaScript:

 
// App Router - Server Component
// app/users/page.js
async function getUsers() {
  const res = await fetch('https://api.example.com/users');
  return res.json();
}

export default async function UsersPage() {
  const users = await getUsers();

  return (
    <div>
      <h1>Users</h1>
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

For Pages Router or client components, Next.js provides hooks and functions like getServerSideProps, getStaticProps, and SWR for client-side data fetching:

 
// Pages Router - pages/users.js
import useSWR from 'swr';

export default function UsersPage({ initialData }) {
  const { data: users } = useSWR('/api/users', fetcher, { 
    fallbackData: initialData 
  });

  return (
    <div>
      <h1>Users</h1>
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

export async function getStaticProps() {
  const users = await fetchUsers();
  return { props: { initialData: users }, revalidate: 60 };
}

Next.js gives you flexibility but requires understanding different data fetching patterns depending on your rendering strategy and router choice.

Remix takes a more unified approach by connecting data loading directly to routes through loader and action functions. This pattern aligns data requirements with UI components:

 
// app/routes/users.jsx
import { useLoaderData, useFetcher } from '@remix-run/react';

export async function loader() {
  const users = await fetchUsers();
  return users;
}

export async function action({ request }) {
  const formData = await request.formData();
  return createUser({
    name: formData.get('name'),
    email: formData.get('email')
  });
}

export default function Users() {
  const users = useLoaderData();
  const fetcher = useFetcher();

  return (
    <div>
      <h1>Users</h1>

      <fetcher.Form method="post">
        <input name="name" placeholder="Name" />
        <input name="email" placeholder="Email" />
        <button type="submit">Add User</button>
      </fetcher.Form>

      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

Remix's data loading approach creates a clear connection between routes and their data needs, while also handling changes through forms and actions. This enables optimistic UI updates and progressive enhancement where forms work even without JavaScript.

Nuxt 3 offers composables like useFetch, useAsyncData, and server routes for data handling, creating a unified approach across server and client:

 
<!-- pages/users.vue -->
<script setup>
// Server-side data fetching with automatic caching
const { data: users } = await useFetch('/api/users');

// Function to add a new user
async function addUser(userData) {
  const { data } = await useFetch('/api/users', {
    method: 'POST',
    body: userData
  });

  // Refresh the user list
  await refreshNuxtData('users');
}
</script>

<template>
  <div>
    <h1>Users</h1>

    <form @submit.prevent="addUser(formData)">
      <input v-model="formData.name" placeholder="Name" />
      <input v-model="formData.email" placeholder="Email" />
      <button type="submit">Add User</button>
    </form>

    <ul>
      <li v-for="user in users" :key="user.id">
        {{ user.name }}
      </li>
    </ul>
  </div>
</template>

Nuxt 3 uses Vue's reactivity system alongside its useFetch and useAsyncData composables to create a seamless data fetching experience. The built-in API routes using the Nitro server engine let you define backend logic that works across hosting platforms.

Server-side capabilities

All three frameworks excel at server rendering, but they handle server-side logic differently.

Next.js offers multiple server-side capabilities that have evolved over time. The App Router introduces React Server Components, Route Handlers, and Middleware, while still supporting API Routes from the Pages Router:

 
// Server Component - app/users/page.js
export default async function UsersPage() {
  const users = await db.users.findMany();
  return (
    <div>
      {users.map(user => (
        <UserCard key={user.id} user={user} />
      ))}
    </div>
  );
}

// Route Handler - app/api/users/route.js
export async function GET() {
  const users = await db.users.findMany();
  return Response.json(users);
}

export async function POST(request) {
  const data = await request.json();
  const user = await db.users.create({ data });
  return Response.json(user);
}

// Middleware - middleware.js
export default function middleware(request) {
  const token = request.cookies.get('token');
  if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
    return Response.redirect(new URL('/login', request.url));
  }
}

Next.js provides flexible server capabilities but has different patterns across its routers. Server Components mark a significant shift in how you can build React applications, pushing more logic to the server for better performance.

Remix focuses on web fundamentals with server-side handling directly tied to routes. Loaders and actions process data and changes for each route:

 
// app/routes/users.jsx
export async function loader({ request }) {
  // Check authentication
  const userId = await requireAuth(request);

  // Get data based on URL and request
  const url = new URL(request.url);
  const search = url.searchParams.get('search') || '';

  return db.users.findMany({
    where: {
      name: { contains: search }
    }
  });
}

export async function action({ request }) {
  // Handle form submissions
  const formData = await request.formData();
  const intent = formData.get('intent');

  if (intent === 'create') {
    return db.users.create({
      data: {
        name: formData.get('name'),
        email: formData.get('email')
      }
    });
  }

  if (intent === 'delete') {
    return db.users.delete({
      where: { id: formData.get('id') }
    });
  }
}

Remix's approach makes server-side logic feel like a natural extension of routing, with a unified API for handling both GET and mutation requests. This enables sophisticated patterns while maintaining progressive enhancement.

Nuxt 3 introduces the Nitro server engine with server routes, middleware, and plugins that work consistently across deployment platforms:

 
// server/api/users.js
export default defineEventHandler(async (event) => {
  // Get all users
  return await db.users.findMany();
});

// server/api/users/[id].js
export default defineEventHandler(async (event) => {
  const id = event.context.params.id;

  // Handle different HTTP methods
  if (event.node.req.method === 'GET') {
    return await db.users.findUnique({ where: { id } });
  }

  if (event.node.req.method === 'DELETE') {
    return await db.users.delete({ where: { id } });
  }

  if (event.node.req.method === 'PUT') {
    const body = await readBody(event);
    return await db.users.update({
      where: { id },
      data: body
    });
  }
});

// server/middleware/auth.js
export default defineEventHandler((event) => {
  const token = getCookie(event, 'token');
  if (!token && event.path.startsWith('/dashboard')) {
    return sendRedirect(event, '/login');
  }
});

Nuxt 3's Nitro engine provides a consistent server experience with automatic hot module reloading for server code, while supporting serverless, edge, and traditional Node.js deployments. The API is designed to be intuitive while using modern JavaScript features.

Styling and CSS strategies

Each framework handles styling differently, reflecting their design philosophies.

Next.js gives you multiple styling options without forcing a specific approach. It supports Global CSS, CSS Modules, Sass, styled-jsx, and various CSS-in-JS libraries:

 
// CSS Modules - styles/User.module.css
.card {
  border: 1px solid #eaeaea;
  border-radius: 10px;
  padding: 20px;
  margin: 10px 0;
}

// Using CSS Modules - components/UserCard.js
import styles from '../styles/User.module.css';

export default function UserCard({ user }) {
  return (
    <div className={styles.card}>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

// Using styled-jsx (built-in)
export default function AnotherComponent() {
  return (
    <div className="container">
      <p>Styled with styled-jsx</p>

      <style jsx>{`
        .container {
          background: #f0f0f0;
          padding: 20px;
        }
        p {
          color: blue;
        }
      `}</style>
    </div>
  );
}

Next.js's flexibility lets you choose styling approaches that match your preferences, but doesn't provide strong opinions on best practices.

Remix takes a web-standard approach with route-based CSS imports that are managed through links, ensuring styles are loaded only when needed:

 
// app/routes/users.jsx
import styles from '~/styles/users.css';

export function links() {
  return [{ rel: "stylesheet", href: styles }];
}

export default function Users() {
  return (
    <div className="users-container">
      <h1 className="users-heading">Users</h1>
      {/* ... */}
    </div>
  );
}

Remix's approach to CSS reflects its commitment to web standards, using link tags to manage styles. This gives you fine-grained control over which styles load for each route while avoiding unnecessary CSS in the initial payload.

Nuxt 3 embraces Vue's component-scoped styling while adding support for global CSS, preprocessors, and CSS modules:

 
<!-- components/UserCard.vue -->
<template>
  <div class="user-card">
    <h2>{{ user.name }}</h2>
    <p>{{ user.email }}</p>
  </div>
</template>

<style scoped>
.user-card {
  border: 1px solid #eaeaea;
  border-radius: 10px;
  padding: 20px;
  margin: 10px 0;
}

h2 {
  color: #333;
  margin-bottom: 5px;
}
</style>

Nuxt 3 leverages Vue's Single File Components to create a clean separation of concerns, where styles can be scoped to components. This prevents style leakage while maintaining a clear organization of code.

Form handling and mutations

The way a framework manages user interactions and updates data shows what it prioritizes and how it's built.

Next.js takes a flexible approach to form handling that varies between the Pages and App Router. With the App Router, you can handle forms using Server Actions:

 
// app/users/create/page.js
export default function CreateUserPage() {
  async function createUser(formData) {
    'use server';

    const name = formData.get('name');
    const email = formData.get('email');

    await db.users.create({ data: { name, email } });
    redirect('/users');
  }

  return (
    <form action={createUser}>
      <input name="name" placeholder="Name" required />
      <input name="email" placeholder="Email" required />
      <button type="submit">Create User</button>
    </form>
  );
}

In the Pages Router, forms typically submit to API routes or use client-side libraries like React Hook Form to manage state. This flexibility allows for multiple approaches but requires more manual setup.

Remix embraces HTML forms as a fundamental part of web development, enhancing them with JavaScript when available:

 
// app/routes/users.new.jsx
import { Form, useActionData, useTransition } from '@remix-run/react';

export async function action({ request }) {
  const formData = await request.formData();
  const name = formData.get('name');
  const email = formData.get('email');

  const errors = {};
  if (!name) errors.name = 'Name is required';
  if (!email) errors.email = 'Email is required';
  if (Object.keys(errors).length) {
    return { errors };
  }

  await db.users.create({ data: { name, email } });
  return redirect('/users');
}

export default function NewUser() {
  const actionData = useActionData();
  const transition = useTransition();
  const isSubmitting = transition.state === 'submitting';

  return (
    <Form method="post">
      <div>
        <label>
          Name: <input name="name" />
        </label>
        {actionData?.errors?.name && (
          <p className="error">{actionData.errors.name}</p>
        )}
      </div>

      <div>
        <label>
          Email: <input name="email" type="email" />
        </label>
        {actionData?.errors?.email && (
          <p className="error">{actionData.errors.email}</p>
        )}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Creating...' : 'Create User'}
      </button>
    </Form>
  );
}

Remix's approach creates a seamless form handling experience that works without JavaScript (progressive enhancement) while adding enhanced features like validation feedback and loading states when JavaScript is available.

Nuxt 3 provides Vue-based form handling with composables for managing API interactions:

 
<!-- pages/users/create.vue -->
<script setup>
const { data, error, execute } = useAsyncData('createUser', async () => {
  // This won't run immediately since we set immediate: false
  return await $fetch('/api/users', {
    method: 'POST',
    body: formData.value
  });
}, { immediate: false });

const formData = ref({
  name: '',
  email: ''
});

const errors = ref({});

async function submitForm() {
  // Validate
  errors.value = {};
  if (!formData.value.name) errors.value.name = 'Name is required';
  if (!formData.value.email) errors.value.email = 'Email is required';
  if (Object.keys(errors.value).length) return;

  // Submit form
  await execute();

  if (data.value && !error.value) {
    // Success - redirect
    navigateTo('/users');
  }
}
</script>

<template>
  <form @submit.prevent="submitForm">
    <div>
      <label>
        Name: <input v-model="formData.name" />
      </label>
      <p v-if="errors.name" class="error">{{ errors.name }}</p>
    </div>

    <div>
      <label>
        Email: <input v-model="formData.email" type="email" />
      </label>
      <p v-if="errors.email" class="error">{{ errors.email }}</p>
    </div>

    <button type="submit" :disabled="$fetch.pending">
      {{ $fetch.pending ? 'Creating...' : 'Create User' }}
    </button>
  </form>
</template>

Nuxt 3 uses Vue's reactivity and form binding features, creating a smooth developer experience for handling form state, validation, and submission. While it doesn't focus on progressive enhancement like Remix, it provides a streamlined approach for Vue developers.

Error handling and loading states

Handling of errors and loading states is a crucial part of a framework’s design, shaping how intuitive it feels for both developers and end users.

Next.js has different error handling approaches depending on the router. The App Router introduces error.js and loading.js files that create error boundaries and loading states at the route level:

app/users/error.js
'use client';

import { useEffect } from 'react';

export default function Error({ error, reset }) {
  useEffect(() => {
    console.error(error);
  }, [error]);

  return (
    <div className="error-container">
      <h2>Something went wrong!</h2>
      <button onClick={() => reset()}>Try again</button>
    </div>
  );
}

// app/users/loading.js
export default function Loading() {
  return <div className="loading-spinner">Loading users...</div>;
}

The App Router's approach creates a clean separation of concerns, where loading and error states are placed with the routes they affect. This structure makes it easy to add consistent error and loading handling across your application.

Remix uses nested error boundaries tied directly to routes, allowing errors to be handled at the appropriate level of the UI hierarchy:

app/routes/users.jsx
import { Outlet, useCatch, useLoaderData } from '@remix-run/react';

export async function loader() {
  const users = await fetchUsers();
  if (!users) throw new Response('Not Found', { status: 404 });
  return users;
}

export default function Users() {
  const users = useLoaderData();

  return (
    <div>
      <h1>Users</h1>
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
      <Outlet />
    </div>
  );
}

// Handle errors thrown in the loader or action
export function CatchBoundary() {
  const caught = useCatch();

  if (caught.status === 404) {
    return <div>No users found</div>;
  }

  return (
    <div>
      <h1>Error {caught.status}</h1>
      <p>{caught.data}</p>
    </div>
  );
}

// Handle unexpected errors
export function ErrorBoundary({ error }) {
  return (
    <div>
      <h1>Error!</h1>
      <p>{error.message}</p>
    </div>
  );
}

Remix's approach to error handling integrates tightly with its nested routing system, letting errors be captured at the right level of the UI. This creates a more resilient user experience where an error in one part of the page doesn't break the entire application.

Nuxt 3 provides error handling through error pages, middleware, and composable hooks:

 
<!-- error.vue (global error page) -->
<script setup>
const props = defineProps({
  error: Object
});

function handleError() {
  clearError({ redirect: '/' });
}
</script>

<template>
  <div>
    <h1>{{ error.statusCode }}</h1>
    <p>{{ error.message }}</p>
    <button @click="handleError">Go back home</button>
  </div>
</template>

<!-- In components or pages -->
<script setup>
const { data, error, pending } = await useFetch('/api/users');

// Handle component-level errors
watch(error, (newError) => {
  if (newError) {
    console.error('Failed to fetch users:', newError);
  }
});
</script>

<template>
  <div>
    <div v-if="pending">Loading users...</div>
    <div v-else-if="error">
      Failed to load users: {{ error.message }}
    </div>
    <ul v-else>
      <li v-for="user in data" :key="user.id">
        {{ user.name }}
      </li>
    </ul>
  </div>
</template>

Nuxt 3 provides multiple levels of error handling, from global error pages to component-level error states. The composables like useFetch include built-in loading and error states, making it straightforward to create responsive user interfaces.

Final thoughts

In this article, I compared Next.js, Remix, and Nuxt 3 to help you choose the right framework for your web project.

Next.js is a top choice for React, offering flexibility and a strong ecosystem, though its latest features have a learning curve.

Remix focuses on web standards and performance. It works well even without JavaScript, making it great for user-first apps.

Nuxt 3 offers a smooth experience for Vue developers with modern tools and solid conventions.

Choose Next.js for React and flexibility, Remix for performance and web fundamentals, and Nuxt 3 if you prefer Vue.

All three are great options. Your best choice depends on your project and team.

Got an article suggestion? Let us know
Next article
Running Node.js Apps with PM2 (Complete Guide)
Learn the key features of PM2 and how to use them to deploy, manage, and scale your Node.js applications in production
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

Make your mark

Join the writer's program

Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.

Write for us
Writer of the month
Marin Bezhanov
Marin is a software engineer and architect with a broad range of experience working...
Build on top of Better Stack

Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.

community@betterstack.com

or submit a pull request and help us build better products for everyone.

See the full list of amazing projects on github