Matthew Hodge
Full Stack Developer

Nuxt 3 Server Routes and API Endpoints

One of the underrated features of Nuxt 3 is the built-in server engine, Nitro. You can build a full API right inside your Nuxt project without reaching for a separate Express or Laravel backend — and for a lot of projects, that's all you need.

In this post, I'll walk through how server routes work, covering GET and POST handlers, reading request bodies, middleware, and connecting to a database.


How Server Routes Work

Any file you drop inside server/api/ becomes an API endpoint automatically — no router configuration needed. The file name maps to the route, and the HTTP method comes from the filename suffix.

server/
└── api/
    ├── users/
    │   ├── index.get.ts        → GET  /api/users
    │   ├── index.post.ts       → POST /api/users
    │   └── [id].get.ts         → GET  /api/users/:id
    └── health.get.ts           → GET  /api/health

Each file exports a default handler function using defineEventHandler.


A Simple GET Endpoint

// server/api/health.get.ts

export default defineEventHandler(() => {
    return {
        status: 'ok',
        timestamp: new Date().toISOString(),
    }
})

Hit http://localhost:3000/api/health and you get:

{
    "status": "ok",
    "timestamp": "2025-12-03T10:00:00.000Z"
}

Nitro automatically serialises the returned object as JSON with the right content-type header. No res.json(), no boilerplate.


Reading Route Parameters

// server/api/users/[id].get.ts

export default defineEventHandler(async (event) => {
    const id = getRouterParam(event, 'id')

    // In a real app, query your database here
    const user = await getUserById(Number(id))

    if (! user) {
        throw createError({
            statusCode: 404,
            statusMessage: 'User not found',
        })
    }

    return user
})

getRouterParam pulls :id from the URL. createError returns a proper HTTP error response — Nitro handles the rest.


Reading Query String Parameters

// server/api/users/index.get.ts

export default defineEventHandler(async (event) => {
    const query = getQuery(event)

    const page    = Number(query.page) || 1
    const perPage = Number(query.per_page) || 15
    const search  = String(query.search || '')

    // Use these to paginate your DB query
    return {
        data: [],
        page,
        per_page: perPage,
    }
})

getQuery parses the query string into an object. GET /api/users?page=2&search=john gives you { page: '2', search: 'john' }.


Handling POST Requests

// server/api/users/index.post.ts

import { z } from 'zod'

const createUserSchema = z.object({
    name:  z.string().min(2),
    email: z.string().email(),
})

export default defineEventHandler(async (event) => {
    const body = await readBody(event)

    const result = createUserSchema.safeParse(body)

    if (! result.success) {
        throw createError({
            statusCode: 422,
            statusMessage: 'Validation failed',
            data: result.error.flatten().fieldErrors,
        })
    }

    const { name, email } = result.data

    // Create the user in your database
    const user = await createUser({ name, email })

    setResponseStatus(event, 201)
    return user
})

readBody parses the request body as JSON. I'm using Zod for validation here — it's the standard for Nuxt 3 server routes. If you haven't used it, it's well worth the install:

npm install zod

Server Middleware

Files in server/middleware/ run on every request before it hits a handler. Useful for auth checks, logging, or setting headers.

// server/middleware/auth.ts

export default defineEventHandler(async (event) => {
    // Only protect /api/admin routes
    if (! event.path.startsWith('/api/admin')) {
        return
    }

    const token = getHeader(event, 'authorization')?.replace('Bearer ', '')

    if (! token) {
        throw createError({
            statusCode: 401,
            statusMessage: 'Unauthorised',
        })
    }

    // Verify the token and attach the user to the event context
    const user = await verifyToken(token)

    if (! user) {
        throw createError({
            statusCode: 401,
            statusMessage: 'Invalid token',
        })
    }

    event.context.user = user
})

The authenticated user is attached to event.context.user and available in any handler that runs after this middleware.


Using the Context in Handlers

// server/api/admin/dashboard.get.ts

export default defineEventHandler(async (event) => {
    const user = event.context.user

    return {
        message: `Welcome back, ${user.name}`,
        stats: await getDashboardStats(user.id),
    }
})

Clean and simple. No passing the user around manually.


Connecting to a Database with Drizzle

Nitro doesn't ship with a database layer, but Drizzle ORM is a great pairing. Install it and your driver of choice:

npm install drizzle-orm mysql2
npm install -D drizzle-kit

Create a database client as a Nitro plugin so it's initialised once:

// server/plugins/database.ts

import { drizzle } from 'drizzle-orm/mysql2'
import mysql from 'mysql2/promise'

declare module 'nitropack' {
    interface NitroApp {
        db: ReturnType<typeof drizzle>
    }
}

export default defineNitroPlugin(async (nitroApp) => {
    const connection = await mysql.createConnection({
        host:     process.env.DB_HOST,
        user:     process.env.DB_USERNAME,
        password: process.env.DB_PASSWORD,
        database: process.env.DB_DATABASE,
    })

    nitroApp.db = drizzle(connection)
})

Access it in a handler:

// server/api/posts/index.get.ts

import { posts } from '~/server/schema'

export default defineEventHandler(async (event) => {
    const db = useNitroApp().db

    const allPosts = await db.select().from(posts).limit(20)

    return allPosts
})

Calling Server Routes from Components

From the frontend, use useFetch or $fetch — both are built into Nuxt and handle the base URL automatically:

<script setup>
const { data: users, pending, error } = await useFetch('/api/users')
</script>

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

On the server during SSR, Nuxt short-circuits the HTTP call and calls the handler directly — no actual network request. On the client it goes over HTTP as you'd expect.


Final Thoughts

Nuxt 3 server routes are genuinely one of its best features for full-stack work. You get a typed, file-based API with middleware support, built-in error handling, and seamless integration with your Vue components — all without spinning up a separate backend.

For smaller projects or internal tools, this alone might be all you need. For larger apps, you can still reach for Laravel or another API if you need the full power, but don't underestimate how far Nuxt's server layer can take you.


Creating your own blog with modern web technologies has never been easier. In this tutorial, we'll walk through setting up a blog using Nuxt 3, Nuxt Content module for markdown support, and Tailwind CSS for styling. This powerful combination gives you a fast, SEO-friendly blog with an excellent developer experience.

GitHub Repo

Prerequisites

Before starting, make sure you have:

  • Node.js (version 18 or newer)
  • npm or yarn
  • Basic knowledge of Vue.js

Step 1: Create a New Nuxt Project

First, let's create a new Nuxt project:

npx nuxi@latest init your-blog
cd your-blog

This command creates a new Nuxt 3 project in the your-blog directory. After the initialization completes, navigate into the project folder.

Step 2: Install Dependencies

Next, we need to install the Nuxt Content module for managing markdown content and Tailwind CSS:

npm install @nuxt/content
npm install tailwindcss @tailwindcss/vite

Step 3: Configure Nuxt

Update your nuxt.config.ts file to include the necessary modules and configurations:

// filepath: nuxt.config.ts
import tailwindcss from "@tailwindcss/vite";

export default defineNuxtConfig({
  compatibilityDate: "2024-11-01",
  devtools: { enabled: true },
  modules: ['@nuxt/content'],  
  vite: {
    plugins: [
      tailwindcss(),
    ],
  },
});

Step 4: Configure Content Module

Create a content.config.ts file in the root of your project:

// filepath: content.config.ts
import { defineCollection, defineContentConfig } from '@nuxt/content'

export default defineContentConfig({
  collections: {
    content: defineCollection({
      type: 'page',
      source: '**/*.md'
    }),    
    blog: defineCollection({
      source: 'blog/*.md',
      type: 'page',
    })
  }
})

This configures the Nuxt Content module to recognize all markdown files as content pages.

Step 5: Create App Layout

First, let's create the main app layout. Create an app.vue file in the root directory:

// filepath: app.vue
<template>
  <NuxtLayout>
    <NuxtPage />
  </NuxtLayout>
</template>

This simple template uses Nuxt's layout system and renders the current page.

Step 6: Create Home Page

Create an index.vue file in the pages directory:

// filepath: pages/index.vue
<script setup lang="ts">
const { data: home } = await useAsyncData(() => queryCollection('content').path('/').first())

useSeoMeta({
  title: home.value?.title,
  description: home.value?.description
})
</script>

<template>
  <ContentRenderer v-if="home" :value="home" />
  <div v-else>Home not found</div>
</template>

This page fetches and renders content from a markdown file at the root of the content directory and uses the metadata for SEO.

Step 7: Set Up Default Layout

Create a default layout file:

// filepath: layouts/default.vue
<template>
  <div>
    <header class="bg-gray-900 text-white p-4">
      <div class="container mx-auto">
        <h1 class="text-xl font-bold">My Nuxt Blog</h1>
        <nav class="mt-2">
          <NuxtLink to="/" class="mr-4 hover:underline">Home</NuxtLink>
          <NuxtLink to="/blog" class="hover:underline">Blog</NuxtLink>
        </nav>
      </div>
    </header>

    <main class="container mx-auto p-4">
      <slot />
    </main>

    <footer class="bg-gray-200 p-4 mt-8">
      <div class="container mx-auto text-center text-gray-700">
        © {{ new Date().getFullYear() }} My Blog
      </div>
    </footer>
  </div>
</template>

Step 8: Configure Tailwind CSS

Create a CSS file to import Tailwind:

// filepath: assets/css/main.css
@import "tailwindcss";

Then, create a tailwind.config.js file:

// filepath: tailwind.config.js
module.exports = {
  content: [
    './components/**/*.{vue,js}',
    './layouts/**/*.vue',
    './pages/**/*.vue',
    './plugins/**/*.{js,ts}',
    './app.vue',
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

Finally, update nuxt.config.ts to include your CSS:

// filepath: nuxt.config.ts
import tailwindcss from "@tailwindcss/vite";

export default defineNuxtConfig({
  compatibilityDate: "2024-11-01",
  devtools: { enabled: true },
  modules: ['@nuxt/content'],
  css: ['~/assets/css/main.css'],
  vite: {
    plugins: [
      tailwindcss(),
    ],
  },
});

Step 9: Create Blog Content

Now it's time to add some content. Create a content directory in the root of your project and add markdown files:

// filepath: content/index.md
---
title: Welcome to My Blog
description: This is my personal blog built with Nuxt 3 and Content
---

# Welcome to My Blog

This is the homepage of my blog built with Nuxt 3 and the Content module.

## Recent Posts

Check out my latest articles in the blog section.
// filepath: content/blog/first-post.md
---
title: My First Blog Post
description: This is my first blog post using Nuxt Content
date: 2023-01-15
---

# My First Blog Post

Welcome to my first blog post! This post is written in Markdown and rendered using Nuxt Content.

## Features of Markdown

- **Bold text**
- *Italic text*
- [Links](https://nuxt.com)
- Lists like this one

// filepath: content/blog/second-post.md
---
title: My Second Blog Post
description: This is my second blog post using Nuxt Content
date: 2023-01-15
---

# My Second Blog Post

Welcome to my second blog post! This post is written in Markdown and rendered using Nuxt Content.

## Features of Markdown

- **Bold text**
- *Italic text*
- [Links](https://nuxt.com)
- Lists like this one

Step 10: Create a Blog Listing Page

Create a page to list all blog posts:

// filepath: pages/blog/index.vue
<script setup lang="ts">
const { data: posts } = await useAsyncData('blog', () => queryCollection('blog').all())
  
useSeoMeta({
  title: 'Blog',
  description: 'All blog posts'
})
</script>

<template>
  <div>
    <h1 class="text-3xl font-bold mb-6">Blog Posts</h1>
    
    <div v-if="posts && posts.length">
      <div v-for="post in posts" :key="post.path" class="mb-6 p-4 border border-gray-200 rounded-lg">
        <h2 class="text-xl font-semibold">
          <NuxtLink :to="post.path" class="text-blue-600 hover:underline">
            {{ post.title }}
          </NuxtLink>
        </h2>
        <div class="text-gray-500 text-sm mt-1" v-if="post.date">
          {{ new Date(post.date).toLocaleDateString() }}
        </div>
        <p class="mt-2">{{ post.description }}</p>
        <NuxtLink :to="post.path" class="text-blue-600 hover:underline mt-2 inline-block">
          Read more
        </NuxtLink>
      </div>
    </div>
    <p v-else>No blog posts found.</p>
  </div>
</template>

Step 11: Create a Blog Post Dynamic Page

Create a dynamic route to handle individual blog posts:

// filepath: pages/blog/[...slug].vue
<script setup lang="ts">
const route = useRoute()
const { data: post } = await useAsyncData(route.path, () => {
  return queryCollection('blog').path(route.path).first()
})

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

useSeoMeta({
  title: post.value.title,
  description: post.value.description
})
</script>

<template>
  <article>
    <ContentRenderer v-if="post" :value="post">
      <template #empty>
        <p>No content found.</p>
      </template>
    </ContentRenderer>
    
    <div class="mt-8">
      <NuxtLink to="/blog" class="text-blue-600 hover:underline">
        ← Back to all posts
      </NuxtLink>
    </div>
  </article>
</template>

Step 12: Running Your Blog

Start the development server:

npm run dev

Your blog should now be running at http://localhost:3000.

Conclusion

You now have a functional blog built with Nuxt 3, Content, and Tailwind CSS! This setup provides:

  • Fast performance with Nuxt 3's server-side rendering
  • Easy content management with markdown files
  • Beautiful styling with Tailwind CSS
  • Great SEO with automatic metadata handling

With this foundation in place, you can easily extend your blog with additional features to make it uniquely yours.

For more information, check out the official documentation: Nuxt 3, Nuxt Content, Query Collection, Tailwind CSS