Skip to content

Nuxt Mini Project — Build a Full-Stack Blog Application

DodaTech Updated 2026-06-28 10 min read

In this tutorial, you will learn about Nuxt Mini Project. We cover key concepts, practical examples, and best practices to help you master this topic.

Build a complete full-stack Nuxt blog application with authentication, content management, search, comments, and deployment — combining all Nuxt concepts from this course.

In this lesson, you'll build a production-ready blog application that uses file-based routing, server routes, authentication, content management, and deployment — demonstrating every major Nuxt feature you've learned.

What You'll Learn

How to build a full-stack blog with Nuxt 3 by combining pages, layouts, components, composables, data fetching, server routes, authentication, content management, and deployment into a single cohesive application.

Why It Matters

A complete project forces you to integrate all the individual concepts you've learned into a working whole. This mirrors real-world development where features don't exist in isolation — they must work together seamlessly.

Real-World Use

This blog project structure mirrors production applications like documentation sites, personal blogs, and small marketing sites that thousands of developers build and deploy with Nuxt every day.

flowchart TD
    A[Nuxt Blog App] --> B[Pages]
    A --> C[Content]
    A --> D[Auth]
    A --> E[API]
    B --> F[Homepage]
    B --> G[Blog Posts]
    B --> H[Admin Dashboard]
    C --> I[Markdown Files]
    C --> J[Content Module]
    D --> K[Login/Logout]
    D --> L[Middleware]
    E --> M[Auth API]
    E --> N[Comments API]
    E --> O[Search API]
    style A fill:#00dc82,color:#fff

Project Structure

nuxt-blog/
├── content/                # Blog posts (markdown)
│   ├── blog/
│   │   ├── getting-started-with-nuxt.md
│   │   └── building-nuxt-apps.md
│   └── index.md
├── pages/                  # Application pages
│   ├── index.vue
│   ├── blog/
│   │   └── [...slug].vue
│   ├── admin/
│   │   ├── index.vue
│   │   └── new-post.vue
│   └── login.vue
├── layouts/
│   ├── default.vue
│   └── admin.vue
├── components/
│   ├── BlogCard.vue
│   ├── CommentSection.vue
│   ├── SearchBar.vue
│   └── ThemeToggle.vue
├── composables/
│   ├── useAuth.ts
│   ├── useComments.ts
│   └── useSearch.ts
├── server/
│   └── api/
│       ├── auth/
│       │   ├── login.post.ts
│       │   ├── logout.post.ts
│       │   └── me.get.ts
│       └── comments/
│           ├── index.get.ts
│           └── create.post.ts
├── middleware/
│   └── auth.ts
├── plugins/
│   └── error-logger.ts
├── nuxt.config.ts
└── .env

Step 1: Project Setup

npx nuxi init nuxt-blog
cd nuxt-blog
npm install @nuxt/content @nuxtjs/tailwindcss @nuxt/image
npm install bcrypt jsonwebtoken --save-dev
// nuxt.config.ts
export default defineNuxtConfig({
  modules: [
    '@nuxt/content',
    '@nuxtjs/tailwindcss',
    '@nuxt/image'
  ],
  runtimeConfig: {
    jwtSecret: '',
    public: {
      apiBase: '/api',
      siteName: 'Nuxt Blog'
    }
  },
  nitro: {
    prerender: {
      routes: ['/'],
      crawlLinks: true
    }
  }
});

Step 2: Layouts

Default layout with navigation:

<!-- layouts/default.vue -->
<template>
  <div class="min-h-screen bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
    <header class="border-b border-gray-200 dark:border-gray-700">
      <nav class="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
        <NuxtLink to="/" class="text-xl font-bold">
          {{ config.public.siteName }}
        </NuxtLink>

        <div class="flex items-center gap-4">
          <SearchBar />
          <NuxtLink to="/blog">Blog</NuxtLink>
          <NuxtLink v-if="user" to="/admin">Admin</NuxtLink>
          <NuxtLink v-if="!user" to="/login">Login</NuxtLink>
          <button v-else @click="handleLogout">Logout</button>
          <ThemeToggle />
        </div>
      </nav>
    </header>

    <main class="max-w-4xl mx-auto px-4 py-8">
      <slot />
    </main>

    <footer class="border-t border-gray-200 dark:border-gray-700 py-8 text-center text-sm">
      Built with Nuxt. Part of DodaTech tutorials.
    </footer>
  </div>
</template>

<script setup>
const config = useRuntimeConfig();
const { user, logout } = useAuth();

async function handleLogout() {
  await logout();
  await navigateTo('/');
}
</script>

Admin layout with sidebar:

<!-- layouts/admin.vue -->
<template>
  <div class="min-h-screen bg-gray-50 dark:bg-gray-900 flex">
    <aside class="w-64 bg-white dark:bg-gray-800 border-r p-6">
      <h2 class="font-bold mb-4">Admin Panel</h2>
      <nav class="flex flex-col gap-2">
        <NuxtLink to="/admin">Dashboard</NuxtLink>
        <NuxtLink to="/admin/new-post">New Post</NuxtLink>
      </nav>
    </aside>
    <main class="flex-1 p-8">
      <slot />
    </main>
  </div>
</template>

<script setup>
definePageMeta({
  layout: 'admin',
  middleware: ['auth']
});
</script>

Step 3: Authentication

// composables/useAuth.ts
export const useAuth = () => {
  const token = useCookie('auth_token', {
    maxAge: 60 * 60 * 24 * 7,
    secure: true,
    sameSite: 'lax'
  });

  const user = useState('user', () => null);

  async function login(email: string, password: string) {
    const { data, error } = await useFetch('/api/auth/login', {
      method: 'POST',
      body: { email, password }
    });

    if (error.value) throw new Error('Invalid credentials');
    token.value = data.value.token;
    user.value = data.value.user;
  }

  async function logout() {
    await useFetch('/api/auth/logout', { method: 'POST' });
    token.value = null;
    user.value = null;
  }

  async function fetchUser() {
    if (!token.value) return;
    const { data } = await useFetch('/api/auth/me', {
      headers: { Authorization: `Bearer ${token.value}` }
    });
    if (data.value) user.value = data.value;
  }

  // Fetch user on mount
  if (process.client) fetchUser();

  return { user, token, login, logout, fetchUser };
};

Step 4: Pages

Homepage showing recent posts:

<!-- pages/index.vue -->
<script setup>
const { data: posts } = await useAsyncData('home-posts', () => {
  return queryContent('/blog')
    .where({ published: true })
    .sort({ date: -1 })
    .limit(6)
    .find();
});
</script>

<template>
  <div>
    <section class="text-center py-16">
      <h1 class="text-4xl font-bold mb-4">Welcome to Nuxt Blog</h1>
      <p class="text-xl text-gray-600 dark:text-gray-400">
        A full-stack blog built with Nuxt 3, Nuxt Content, and Tailwind CSS.
      </p>
    </section>

    <section>
      <h2 class="text-2xl font-bold mb-6">Recent Posts</h2>
      <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        <BlogCard
          v-for="post in posts"
          :key="post._path"
          :title="post.title"
          :description="post.description"
          :date="post.date"
          :path="post._path"
        />
      </div>
    </section>
  </div>
</template>

Blog post page:

<!-- pages/blog/[...slug].vue -->
<script setup>
const { path } = useRoute();
const { data: post } = await useAsyncData(`post-${path}`, () => {
  return queryContent(path).findOne();
});

if (!post.value) {
  throw createError({
    statusCode: 404,
    message: 'Post not found'
  });
}
</script>

<template>
  <article>
    <header class="mb-8">
      <h1 class="text-3xl font-bold mb-4">{{ post.title }}</h1>
      <div class="text-gray-500 text-sm">
        <time :datetime="post.date">{{ post.date }}</time>
        <span v-if="post.author" class="ml-4">By {{ post.author }}</span>
      </div>
    </header>

    <ContentDoc />

    <CommentSection :path="path" />
  </article>
</template>

Step 5: Components

Blog card component:

<!-- components/BlogCard.vue -->
<template>
  <NuxtLink
    :to="path"
    class="block p-6 rounded-lg border border-gray-200 dark:border-gray-700
           hover:shadow-lg transition-shadow"
  >
    <h3 class="text-lg font-semibold mb-2">{{ title }}</h3>
    <p class="text-gray-600 dark:text-gray-400 text-sm mb-4">
      {{ description }}
    </p>
    <time class="text-xs text-gray-500">{{ date }}</time>
  </NuxtLink>
</template>

<script setup>
defineProps({
  title: String,
  description: String,
  date: String,
  path: String
});
</script>

Comment section:

<!-- components/CommentSection.vue -->
<template>
  <div class="mt-12 border-t pt-8">
    <h2 class="text-xl font-bold mb-6">Comments</h2>

    <form v-if="user" @submit.prevent="submitComment" class="mb-8">
      <textarea
        v-model="newComment"
        placeholder="Write a comment..."
        class="w-full p-3 border rounded-lg dark:bg-gray-800"
        rows="3"
        required
      />
      <button
        type="submit"
        class="mt-2 px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600"
        :disabled="!newComment.trim()"
      >
        Post Comment
      </button>
    </form>

    <p v-else class="text-gray-500 mb-8">
      <NuxtLink to="/login" class="underline">Log in</NuxtLink> to leave a comment.
    </p>

    <div v-for="comment in comments" :key="comment.id" class="mb-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
      <div class="font-semibold text-sm">{{ comment.author }}</div>
      <p class="mt-1">{{ comment.text }}</p>
      <time class="text-xs text-gray-500 mt-2 block">{{ comment.createdAt }}</time>
    </div>
  </div>
</template>

<script setup>
defineProps({ path: String });
const { user } = useAuth();
const { comments, addComment } = useComments();
const newComment = ref('');
const { path } = defineProps({ path: String });

const { data: comments } = await useFetch(`/api/comments?path=${encodeURIComponent(path)}`);

async function submitComment() {
  if (!newComment.value.trim()) return;
  await addComment(path, newComment.value);
  newComment.value = '';
}
</script>

Step 6: Server Routes

Comments API:

// server/api/comments/index.get.ts
import { readFile, writeFile } from 'node:fs/promises';

const COMMENTS_FILE = './data/comments.json';

export default defineEventHandler(async (event) => {
  const query = getQuery(event);
  const path = query.path as string;

  try {
    const data = await readFile(COMMENTS_FILE, 'utf-8');
    const allComments = JSON.parse(data);
    return allComments.filter(c => c.path === path) || [];
  } catch {
    return [];
  }
});
// server/api/comments/create.post.ts
import { readFile, writeFile } from 'node:fs/promises';

export default defineEventHandler(async (event) => {
  const { path, text } = await readBody(event);
  const token = getHeader(event, 'authorization')?.replace('Bearer ', '');

  if (!token) {
    throw createError({ statusCode: 401, message: 'Not authenticated' });
  }

  // Verify token and get user
  const decoded = jwt.verify(token, process.env.JWT_SECRET);

  const comment = {
    id: Date.now().toString(),
    path,
    author: decoded.email,
    text,
    createdAt: new Date().toISOString()
  };

  // Store comment
  const data = await readFile('./data/comments.json', 'utf-8').catch(() => '[]');
  const comments = JSON.parse(data);
  comments.push(comment);
  await writeFile('./data/comments.json', JSON.stringify(comments, null, 2));

  return comment;
});

Step 7: Search Functionality

// composables/useSearch.ts
export const useSearch = () => {
  const query = ref('');
  const results = ref([]);

  async function search(searchQuery: string) {
    if (!searchQuery.trim()) {
      results.value = [];
      return;
    }

    const { data } = await useFetch('/api/search', {
      query: { q: searchQuery }
    });

    results.value = data.value || [];
  }

  return { query, results, search };
};
// server/api/search.get.ts
export default defineEventHandler(async (event) => {
  const query = getQuery(event);
  const searchTerm = (query.q as string || '').toLowerCase();

  // Search content files
  const posts = await queryContent('/blog')
    .where({ published: true })
    .find();

  return posts.filter(post => {
    const title = post.title?.toLowerCase() || '';
    const description = post.description?.toLowerCase() || '';
    return title.includes(searchTerm) || description.includes(searchTerm);
  }).slice(0, 10);
});

Step 8: Deployment

// nuxt.config.ts — production build
export default defineNuxtConfig({
  nitro: {
    preset: 'netlify',
    prerender: {
      routes: ['/', '/blog'],
      crawlLinks: true
    }
  }
});
# Build and deploy
npm run build
npx netlify deploy --prod --dir=.output/public

Verify the deployment by visiting your Netlify URL. The blog should serve static pages instantly, with dynamic features (auth, comments) handled by Serverless functions.

What You've Built

This project combines every major Nuxt concept from this course:

Feature Nuxt Concept File
Pages File-based routing pages/ directory
Layouts Layout system layouts/
Components Vue components components/
Composables Shared state composables/
Content @nuxt/content content/
Auth Cookies, server routes server/api/auth/
API Nitro server routes server/api/
Middleware Route protection middleware/auth.ts
SEO Meta tags Each page
Styling Tailwind CSS Templates
Deployment Nitro presets nuxt.config.ts

Practice Questions

  1. How does the project structure separate concerns between content and code? Answer: Content lives in the content/ directory as markdown files. Code lives in pages/, components/, and composables/. The content module bridges them by making markdown queryable from Vue components.

  2. What is the role of the auth middleware in the admin layout? Answer: It checks for a valid auth token before rendering any admin page. Unauthenticated users are redirected to the login page, protecting the admin panel from unauthorized access.

  3. How does the comment system combine client and server features? Answer: The client component collects user input and sends it to a server route via POST. The server validates the auth token, stores the comment, and returns it. The GET endpoint retrieves comments for a given page.

  4. Why do we use prerender in nuxt.config.ts for deployment? Answer: It tells Nitro to generate static HTML for listed routes during SSG. Public blog pages are served as static files for speed, while dynamic features (auth, comments) remain serverless.

Challenge

Extend the blog with: tag-based filtering (click a tag to see all posts with that tag), post analytics using a server route that logs page views, a "like" feature on blog posts stored in a JSON file, pagination on the homepage (load more posts), an RSS feed generated at build time, and incremental static regeneration for updated content.

Mini Project Recap

You've built a full-stack Nuxt blog application that includes:

  • Content management with @nuxt/content and markdown files
  • Authentication with JWT tokens and cookie-based sessions
  • Server routes for comments, search, and auth
  • Route middleware for protected admin pages
  • Components for reusable UI (BlogCard, SearchBar, CommentSection)
  • Composables for shared state and logic (useAuth, useSearch)
  • Layouts for distinct page structures (default, admin)
  • Styling with Tailwind CSS and dark mode
  • Deployment to Netlify with static generation and serverless functions

This project structure is production-ready and can be extended with additional features like image optimization, RSS feeds, analytics, and more.

FAQ

How do I add an RSS feed to this blog?

: Create a server/routes/feed.xml.ts that generates XML from your content files. Set the content type to application/xml. The feed regenerates on each build or request.

Can I use a database instead of JSON files for comments?

: Yes. Replace readFile/writeFile with database queries using SQLite, PostgreSQL, or a database client. Use connection pooling for serverless environments.

How do I add image uploads to the admin panel?

: Create a server route that accepts multipart form data, validates the file type and size, saves to a storage service (S3, Cloudinary, or local uploads directory), and returns the URL.

What is the best way to add analytics?

: Use a server route that logs page views with timestamps and referrer data. For production, integrate with Plausible, Umami, or Fathom for privacy-focused analytics.

How do I handle multiple authors?

: Add an author field to the auth system. Store the author ID with each post. Filter content by author in the admin panel. The blog post pages display the author name from frontmatter.

What's Next

Congratulations on completing the Nuxt course. You now have a solid understanding of building full-stack applications with Nuxt 3. To continue your learning journey, explore Vue.js for deeper component concepts, TypeScript for type-safe development, or check out other framework guides for Astro, Remix, Solid.js, Preact, and Gatsby.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro