
Using Vue plainly or adding Nuxt to it: side-by-side comparison of building the same features in plain Vue and Nuxt, so you can see exactly what the framework gives you.
Abdelrahman Awad
April 29, 2026
Nuxt adds a lot of conventions and tooling on top of Vue. But how much does it actually do for you? In this post, we'll build the same set of features in both plain Vue (with Vite) and Nuxt, side by side. The goal isn't to declare a winner. It's to make the tradeoffs visible so you can pick the right tool for your project.
We'll implement four things that most apps need:
Let's see what each looks like in both setups.
In a standard Vue + Vite project, you install vue-router and configure routes manually:
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router';
import Home from '../pages/Home.vue';
import About from '../pages/About.vue';
import BlogList from '../pages/blog/BlogList.vue';
import BlogPost from '../pages/blog/BlogPost.vue';
import DefaultLayout from '../layouts/DefaultLayout.vue';
const routes = [
{
path: '/',
component: DefaultLayout,
children: [
{ path: '', component: Home },
{ path: 'about', component: About },
{ path: 'blog', component: BlogList },
{ path: 'blog/:slug', component: BlogPost },
],
},
];
export const router = createRouter({
history: createWebHistory(),
routes,
});
You register it in your app entry:
// src/main.ts
import { createApp } from 'vue';
import App from './App.vue';
import { router } from './router';
createApp(App).use(router).mount('#app');
And your layout component needs an explicit <router-view>:
<!-- src/layouts/DefaultLayout.vue -->
<template>
<div>
<nav>
<router-link to="/">Home</router-link>
<router-link to="/about">About</router-link>
<router-link to="/blog">Blog</router-link>
</nav>
<main>
<router-view />
</main>
</div>
</template>
Every new page means updating the routes array, importing the component, and making sure the path is correct.
In Nuxt, you create files in the pages/ directory:
pages/
index.vue
about.vue
blog/
index.vue
[slug].vue
That's it. No router config, no imports, no registration. Nuxt generates the routes from the file structure. Dynamic segments use square brackets ([slug].vue).
For the layout, create a file in layouts/:
<!-- layouts/default.vue -->
<template>
<div>
<nav>
<NuxtLink to="/">Home</NuxtLink>
<NuxtLink to="/about">About</NuxtLink>
<NuxtLink to="/blog">Blog</NuxtLink>
</nav>
<main>
<slot />
</main>
</div>
</template>
Nuxt applies the default layout automatically. If you need a different layout for a specific page, you set it with definePageMeta:
<!-- pages/about.vue -->
<script setup lang="ts">
definePageMeta({
layout: 'minimal',
});
</script>
What Nuxt handles for you: route generation, dynamic params, layout system, <NuxtLink> with automatic prefetching, route middleware hooks.
This is where the difference becomes significant.
Vue itself has no SSR story. If you want server-side rendering, you need to set up a Node server (often with vite-plugin-ssr or a custom Express setup), handle the render-to-string pipeline, serialize state, and inject it into the HTML for hydration.
Here's a simplified version of what client-side fetching looks like:
<!-- src/pages/blog/BlogPost.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const post = ref<BlogPost | null>(null);
const loading = ref(true);
const error = ref<string | null>(null);
onMounted(async () => {
try {
const response = await fetch(`/api/posts/${route.params.slug}`);
post.value = await response.json();
} catch (e) {
error.value = 'Failed to load post';
} finally {
loading.value = false;
}
});
</script>
<template>
<div>
<p v-if="loading">Loading...</p>
<p v-else-if="error">{{ error }}</p>
<article v-else-if="post">
<h1>{{ post.title }}</h1>
<div v-html="post.content" />
</article>
</div>
</template>
This only runs on the client. The page loads empty and fills in after the fetch completes. There's no SSR, no SEO, and a visible loading flash.
If you do want SSR with plain Vue, you're looking at configuring @vue/server-renderer, managing a separate server entry, handling state serialization with something like pinia or a custom solution, and dealing with hydration mismatches yourself.
The same feature in Nuxt:
<!-- pages/blog/[slug].vue -->
<script setup lang="ts">
const route = useRoute();
const { data: post, error } = await useAsyncData(
`post-${route.params.slug}`,
() => $fetch(`/api/posts/${route.params.slug}`)
);
</script>
<template>
<article v-if="post">
<h1>{{ post.title }}</h1>
<div v-html="post.content" />
</article>
<p v-else-if="error">{{ error.message }}</p>
</template>
useAsyncData runs on the server during SSR. The result is serialized into the HTML payload and hydrated on the client. No loading flash, full SEO support, and no extra server setup.
You can also use useFetch as a shorthand when you don't need a custom key:
<script setup lang="ts">
const route = useRoute();
const { data: post } = await useFetch(`/api/posts/${route.params.slug}`);
</script>
What Nuxt handles for you: SSR execution, payload serialization, client hydration, deduplication of requests, caching with unique keys, reactive refetching with watch.
Every component, composable, and utility needs an explicit import:
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import ProductCard from '../components/ProductCard.vue';
import { formatCurrency } from '../utils/format';
const route = useRoute();
const count = ref(0);
const formatted = computed(() => formatCurrency(count.value));
</script>
<template>
<ProductCard :price="formatted" />
</template>
This is fine for small projects but gets verbose fast. You can configure unplugin-auto-import and unplugin-vue-components to reduce it, but that's additional setup and config.
Components in components/, composables in composables/, and utilities in utils/ are all auto-imported:
<!-- pages/index.vue -->
<script setup lang="ts">
const route = useRoute();
const count = ref(0);
const formatted = computed(() => formatCurrency(count.value));
</script>
<template>
<ProductCard :price="formatted" />
</template>
No import statements. Vue's composition API (ref, computed, watch, etc.), Nuxt utilities (useRoute, useFetch, useAsyncData), and your own components and composables are all available automatically.
Nuxt generates type declarations for auto-imports, so you still get full TypeScript support in your editor.
Vite uses .env files with a VITE_ prefix:
# .env
VITE_API_BASE=https://api.example.com
Access them with import.meta.env:
const apiBase = import.meta.env.VITE_API_BASE;
Everything with the VITE_ prefix is exposed to the client bundle. There's no built-in way to have server-only variables unless you set up your own server.
Nuxt uses runtimeConfig in nuxt.config.ts:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
secretApiKey: '', // Server only
public: {
apiBase: '', // Available on client and server
},
},
});
Override values with environment variables:
# .env
NUXT_SECRET_API_KEY=sk-12345
NUXT_PUBLIC_API_BASE=https://api.example.com
Access them with useRuntimeConfig():
// In a server route
const config = useRuntimeConfig();
console.log(config.secretApiKey); // Only available server-side
// In a component
const config = useRuntimeConfig();
console.log(config.public.apiBase); // Available everywhere
What Nuxt handles for you: clear separation between server-only and public config, automatic env variable mapping with the NUXT_ prefix, type-safe access through useRuntimeConfig().
Use plain Vue + Vite when:
Use Nuxt when:
Worth mentioning: you can use Nuxt in SPA mode by setting ssr: false in nuxt.config.ts. This gives you the DX benefits (auto-imports, file-based routing, layouts) without server-side rendering. It's a solid choice when you want Nuxt's conventions but don't need SSR.
// nuxt.config.ts
export default defineNuxtConfig({
ssr: false,
});
useAsyncData and useFetch remove a lot of boilerplate and complexityWe compared the same features in plain Vue and Nuxt to make the tradeoffs concrete. Nuxt adds conventions and built-in solutions for problems that plain Vue leaves up to you. Whether that's worth it depends on what your project actually needs.
I hope this post has been helpful. Please let me know if you have any questions or comments. Happy coding!
Get the latest news and updates on developer certifications. Content is updated regularly, so please make sure to bookmark this page or sign up to get the latest content directly in your inbox.

Plain Vue or going Meta?
Using Vue plainly or adding Nuxt to it: side-by-side comparison of building the same features in plain Vue and Nuxt, so you can see exactly what the framework gives you.
Abdelrahman Awad
Apr 29, 2026

The Laravel Request Lifecycle, Step by Step
Follow a Laravel HTTP request from start to finish, exploring when the container is built, service providers run, and controllers executeādemystifying the framework step by step.
Steve McDougall
Apr 29, 2026

Nullish Coalescing Operator
Understand the difference between || and ?? in JavaScript, and learn how the nullish coalescing operator avoids common pitfalls with falsy values like 0, empty strings, and false.
Martin Ferret
Apr 28, 2026
We can help you recruit Certified Developers for your organization or project. The team has helped many customers employ suitable resources from a pool of 100s of qualified Developers.
Let us help you get the resources you need.
