Understanding the Migration: Next.js vs Nuxt
Both Next.js and Nuxt are powerful frameworks built on top of React and Vue respectively, offering server-side rendering, static site generation, file-based routing, and a rich ecosystem. While they share many concepts, the underlying philosophies, APIs, and project structures differ. Migrating from Next.js to Nuxt is a strategic decision often driven by a preference for Vue's reactivity system, the desire for a more opinionated and convention-driven architecture, or simply a team's skill set shift. This guide walks you through a complete, step-by-step migration process, covering everything from project setup to state management, data fetching, and deployment.
Preparing for Migration: Auditing Your Next.js App
π Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Before writing a single line of Nuxt code, perform a thorough audit of your existing Next.js project. Identify all custom pages, API routes, middleware, layout patterns, and third-party integrations. Pay special attention to:
- Routing structure β pages/ directory, dynamic segments, catch-all routes.
- Data fetching methods β
getStaticProps,getServerSideProps,getStaticPaths, client-sideuseEffectfetches. - Styling approach β CSS Modules, Tailwind, styled-components, or global CSS.
- State management β React Context, Redux, Zustand, etc.
- Authentication & middleware β custom middleware in
middleware.ts. - Custom server logic β Express or Fastify custom server.
- API routes β files under
pages/api.
Document these pieces; you'll map each one to its Nuxt equivalent. This audit prevents surprises and ensures a feature-complete migration.
Step-by-Step Migration Process
1. Initialize a New Nuxt Project
Create a fresh Nuxt 3 project using the official starter. This gives you a clean, working foundation. Youβll copy over your application logic gradually.
# Using npx
npx nuxi@latest init my-nuxt-app
# Or with pnpm
pnpm dlx nuxi@latest init my-nuxt-app
cd my-nuxt-app
Install any additional dependencies you plan to reuse (e.g., Tailwind, Pinia, VueUse). The project comes with Nuxt 3's default directory structure:
my-nuxt-app/
βββ app.vue # main app entry (replaces _app.js)
βββ nuxt.config.ts # unified configuration
βββ pages/ # file-based routing
βββ components/ # auto-imported Vue components
βββ public/ # static assets
βββ server/ # server routes & middleware
βββ .env # environment variables
2. Configure nuxt.config.ts β Mapping next.config.js
Next.js configuration is split across next.config.js and sometimes next.config.mjs. Nuxt centralizes everything in nuxt.config.ts. Here's a comparison of common settings:
// next.config.js (old)
module.exports = {
reactStrictMode: true,
images: {
domains: ['example.com'],
},
env: {
CUSTOM_KEY: 'my-value',
},
redirects: async () => [
{ source: '/old', destination: '/new', permanent: true },
],
};
// nuxt.config.ts (new)
export default defineNuxtConfig({
// Strict mode equivalent is devtools feature, but no direct flag
// Enable Vue reactivity transform if needed (optional)
// images: use @nuxt/image module
modules: ['@nuxt/image'],
runtimeConfig: {
public: {
customKey: 'my-value', // accessible on client and server
},
},
routeRules: {
'/old': { redirect: '/new' },
},
});
Move redirects and rewrites into routeRules. Environment variables become runtimeConfig. For image optimization, install @nuxt/image module. Tailwind, if used, goes into modules array via @nuxtjs/tailwindcss.
3. Migrate the Pages Directory β Routing
Both frameworks use a pages/ directory with file-based routing. The conventions are nearly identical, but Nuxt uses Vue components (.vue files) instead of React components (.jsx). The biggest difference is the component syntax itself. Hereβs how a typical Next.js page maps to a Nuxt page:
// Next.js: pages/about.js
export default function About() {
return About Us
Welcome!
;
}
// Nuxt: pages/about.vue
<template>
<div>
<h1>About Us</h1>
<p>Welcome!</p>
</div>
</template>
<script setup>
// Component logic here (optional)
</script>
Dynamic routes translate directly:
// Next.js: pages/posts/[id].js
// Nuxt: pages/posts/[id].vue
// Catch-all: Next.js: pages/[...slug].js
// Nuxt: pages/[...slug].vue
Nested layouts in Next.js (using layout.tsx) become Nuxt layouts. Move your layout components to layouts/ directory and reference them with <NuxtLayout> or by setting layout: 'name' in page metadata. For example:
// layouts/default.vue
<template>
<div>
<TheHeader />
<slot />
<TheFooter />
</div>
</template>
Then in a page:
<script setup>
definePageMeta({
layout: 'default',
});
</script>
4. Migrate Data Fetching β From getStaticProps to Composables
Next.js offers getStaticProps, getServerSideProps, and getStaticPaths. Nuxt replaces these with powerful composables like useAsyncData, useFetch, and useLazyAsyncData. These work seamlessly on both server and client, and integrate with Nuxtβs hydration.
- getStaticProps β
useAsyncDatawithssr: true(default) inside a page setup. - getServerSideProps β same composable, Nuxt automatically runs on server per request if you donβt pre-render.
- getStaticPaths β Nuxt uses
definePageMetawithvalidateor dynamic routing, plususeAsyncDatafor data. For static generation, setnitro.prerender.routesin config.
Example: fetching a list of posts at build time.
// Next.js: pages/blog.js
export async function getStaticProps() {
const res = await fetch('https://api.example.com/posts');
const posts = await res.json();
return { props: { posts } };
}
export default function Blog({ posts }) { /* render */ }
// Nuxt: pages/blog.vue
<script setup>
const { data: posts } = await useAsyncData('posts', () =>
$fetch('https://api.example.com/posts')
);
</script>
<template>
<div v-for="post in posts" :key="post.id">{{ post.title }}</div>
</template>
The $fetch helper is Nuxtβs built-in HTTP client that works server-side without CORS issues. For client-side only fetching (like in a component), use useLazyAsyncData or plain useFetch with lazy: true.
For dynamic routes requiring data, combine useAsyncData with useRoute():
// Nuxt: pages/posts/[id].vue
<script setup>
const route = useRoute();
const { data: post } = await useAsyncData(`post-${route.params.id}`, () =>
$fetch(`https://api.example.com/posts/${route.params.id}`)
);
</script>
5. Convert API Routes to Nuxt Server Routes
Next.js API routes live under pages/api. In Nuxt, server logic moves to server/api/ or server/routes/. The file name becomes the endpoint. The handler receives an event object with getQuery, readBody, etc., instead of Express-like req/res.
// Next.js: pages/api/hello.js
export default function handler(req, res) {
res.status(200).json({ message: 'Hello' });
}
// Nuxt: server/api/hello.ts
export default defineEventHandler(() => {
return { message: 'Hello' };
});
For POST with body:
// Next.js
export default async function handler(req, res) {
const body = req.body;
// process
res.status(201).json({ id: 1 });
}
// Nuxt
export default defineEventHandler(async (event) => {
const body = await readBody(event);
// process
setResponseStatus(event, 201);
return { id: 1 };
});
Middleware in Next.js (middleware.ts) translates to server/middleware/ in Nuxt. The file runs on every request and can modify the response or check authentication.
// Next.js: middleware.ts
import { NextResponse } from 'next/server';
export function middleware(request) {
if (!request.cookies.get('token')) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
// Nuxt: server/middleware/auth.ts
export default defineEventHandler((event) => {
const { token } = parseCookies(event);
if (!token) {
return sendRedirect(event, '/login');
}
});
6. Styling and CSS Modules
Nuxt supports global CSS, CSS Modules, Tailwind, and any preprocessor out of the box. If you used CSS Modules in Next.js (styles.module.css), rename them to .module.css and import them in the <script> or <style> block. Vueβs scoped styles often replace CSS Modules entirely, giving component-scoped CSS without extra file extensions.
// Next.js component with CSS Modules
import styles from './Button.module.css';
export default function Button() {
return ;
}
// Nuxt component with scoped styles
<template>
<button class="primary">Click</button>
</template>
<style scoped>
.primary {
background-color: blue;
}
</style>
To use Tailwind, install the module and add it to nuxt.config.ts:
npm install @nuxtjs/tailwindcss
// nuxt.config.ts
modules: ['@nuxtjs/tailwindcss']
Then your Tailwind classes work directly in templates. Global CSS files can be imported via the css property in config.
7. State Management β From React Context/Redux to Pinia
Nuxt has first-class support for Pinia, the Vue equivalent of Redux/Zustand. If you used React Context or Redux, migrate the store logic to Pinia stores. Pinia provides a more intuitive API with actions, getters, and state. Nuxt auto-imports stores defined in stores/ directory.
// Next.js (React Context) β UserProvider.js
const UserContext = createContext();
export function UserProvider({ children }) {
const [user, setUser] = useState(null);
return {children} ;
}
// Nuxt: stores/user.ts
export const useUserStore = defineStore('user', () => {
const user = ref(null);
function setUser(newUser) {
user.value = newUser;
}
return { user, setUser };
});
Then use it in any component without imports:
<script setup>
const userStore = useUserStore();
</script>
For larger state with Redux, consider Pinia modules. The pattern maps naturally.
8. Migrate Components β React to Vue Translation
Convert each React component to a Vue single-file component (.vue). The mental model is similar, but syntax changes:
- Props:
definePropsin<script setup>. - Events:
$emitin template, ordefineEmits. - Slots:
<slot>instead ofchildrenprop. - Lifecycle:
onMounted,onUnmountedfrom Vue.
Example conversion of a button component:
// Next.js: components/Button.jsx
export default function Button({ variant, onClick, children }) {
return ;
}
// Nuxt: components/Button.vue
<template>
<button :class="`btn-${variant}`" @click="$emit('click')">
<slot />
</button>
</template>
<script setup>
defineProps({
variant: String,
});
defineEmits(['click']);
</script>
Nuxt auto-imports components from the components/ directory, so you don't need to manually import them.
9. Authentication and Protected Routes
Next.js middleware often handles route protection. In Nuxt, you can use server middleware (as shown) for API protection, and for client-side page guards, leverage useRouter in a layout or a global navigation guard. Nuxt provides a pages/ middleware concept using definePageMeta with middleware property, similar to Next.js middleware.ts but page-specific.
// Nuxt page middleware: middleware/auth.ts
export default defineNuxtRouteMiddleware((to, from) => {
const user = useUserStore();
if (!user.isLoggedIn) {
return navigateTo('/login');
}
});
// In a page
<script setup>
definePageMeta({
middleware: 'auth',
});
</script>
Place the middleware file in middleware/ directory (not server). This runs on client side during navigation and on server during initial render if SSR.
Best Practices for a Smooth Migration
- Migrate incrementally: Start with the layout, then pages, then components, and finally data fetching. Test each page after conversion.
- Use Nuxt devtools: They provide visual insight into routes, composables, and performance.
- Keep the old Next.js project running for reference until the Nuxt app passes all tests.
- Leverage auto-imports: Donβt manually import components or composables; Nuxt handles it, reducing boilerplate.
- Adapt, donβt rewrite unnecessarily: If a React pattern has a direct Vue equivalent, use it. For example,
useStatebecomesreforreactive. - Handle environment variables via
runtimeConfiginstead ofprocess.env. This ensures type safety and client-safe exposure. - Test SSR thoroughly: Use
nuxt generateandnuxt previewto simulate production. Ensure no client-only code breaks server rendering. - Update deployment pipeline: Nuxt can deploy to Vercel, Netlify, Node.js servers, or static hosting. Adjust your build commands (
nuxt buildornuxt generate).
Conclusion
Migrating from Next.js to Nuxt is a structured, manageable process when approached systematically. By mapping routing, data fetching, server logic, and component patterns one by one, you can fully transform a React-based application into a Vue-powered Nuxt app without losing functionality or performance. The key is to treat the migration as a refactor with a new framework, not a complete rewrite from scratch. With Nuxtβs powerful composables, auto-imports, and unified configuration, your final codebase will likely be more concise and convention-driven. Start with a thorough audit, follow the steps outlined above, and leverage best practices to ensure a seamless transition.