Plain Vue or going Meta?

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

Abdelrahman Awad

April 29, 2026

Using Plain Vue vs Using Nuxt

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.

What We're Building

We'll implement four things that most apps need:

  1. File-based routing with a nested layout
  2. Server-side data fetching with hydration
  3. Auto-imported components and utilities
  4. Environment variable handling

Let's see what each looks like in both setups.

Routing

Plain Vue

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.

Nuxt

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.

Data Fetching with SSR

This is where the difference becomes significant.

Plain Vue

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.

Nuxt

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.

Auto-Imports

Plain Vue

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.

Nuxt

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.

Environment Variables

Plain Vue (Vite)

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

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().

When to Use Which

Use plain Vue + Vite when:

  • You're building a client-side SPA where SEO doesn't matter (admin panels, internal tools, dashboards)
  • You want full control over every piece of the stack
  • The project is small and you don't need SSR, file-based routing, or auto-imports
  • You're integrating Vue into an existing app as a widget or micro-frontend

Use Nuxt when:

  • You need SSR or static site generation for SEO
  • You want file-based routing and layouts without manual configuration
  • You're building a content site, marketing site, or any public-facing app
  • Your team benefits from conventions over configuration
  • You want built-in API routes, middleware, and server utilities

The Middle Ground

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,
});

    

Key Takeaways (the important part)

  • Plain Vue gives you full control but requires manual setup for routing, SSR, and auto-imports
  • Nuxt handles routing, SSR, data fetching, auto-imports, and environment config out of the box
  • The biggest practical difference is SSR data fetching. useAsyncData and useFetch remove a lot of boilerplate and complexity
  • Nuxt works fine as an SPA too. You don't have to use SSR to benefit from the framework
  • Pick based on your project needs, not on what feels more "proper"

Conclusion

We 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!

More certificates.dev articles

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.

Looking for Certified Developers?

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.

Contact Us
Customer Testimonial for Hiring
like a breath of fresh air
Everett Owyoung
Everett Owyoung
Head of Talent for ThousandEyes
(a Cisco company)