Build a Full-Stack Web Application with Nuxt 4 and Vue 3

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

This tutorial walks you through building a full-stack web application with Nuxt 4 and Vue 3. You will build a task management app (TaskFlow) with authentication, API routes, and a PostgreSQL database via Prisma. By the end, you will have a complete production-ready application.

Learning Objectives

By the end of this tutorial, you will be able to:

  • Create and configure a Nuxt 4 project with TypeScript
  • Master the file-based routing system in Nuxt
  • Build reactive Vue 3 components with the Composition API
  • Create server API routes with Nitro
  • Integrate Prisma ORM for database management
  • Implement simple session-based authentication
  • Deploy your application to production

Prerequisites

Before starting, make sure you have:

  • Node.js 20+ installed on your machine
  • pnpm (recommended package manager for Nuxt)
  • PostgreSQL installed locally or a cloud service (Neon, Supabase)
  • Basic knowledge of JavaScript/TypeScript
  • Familiarity with Vue.js fundamentals (components, reactivity)
  • A code editor (VS Code with the Volar extension recommended)

What You Will Build

A TaskFlow application — a full-stack task manager with:

  • User registration and login
  • Create, edit, and delete tasks
  • Filter by status (to do, in progress, done)
  • Responsive interface with Nuxt UI design system
  • Secure server-side REST API

Step 1: Initialize the Nuxt 4 Project

Start by creating a new Nuxt 4 project:

pnpm dlx nuxi@latest init taskflow-app
cd taskflow-app

When the CLI prompts you for options, select:

  • Package manager: pnpm
  • Initialize git: Yes

Then install dependencies and start the development server:

pnpm install
pnpm dev

Your application is accessible at http://localhost:3000.

Project Structure

Here is the basic structure of your Nuxt 4 project:

taskflow-app/
├── app/
│   ├── components/     # Reusable Vue components
│   ├── composables/    # Reusable logic (hooks)
│   ├── layouts/        # Page layouts
│   ├── pages/          # Pages (auto-routing)
│   └── app.vue         # Root component
├── server/
│   ├── api/            # API routes
│   ├── middleware/      # Server middleware
│   └── utils/          # Server utilities
├── prisma/
│   └── schema.prisma   # Database schema
├── nuxt.config.ts      # Nuxt configuration
├── package.json
└── tsconfig.json

Nuxt 4 adopts a new directory structure with the app/ folder containing all client code. This clear separation between client (app/) and server (server/) improves code organization.

Step 2: Configure Nuxt 4

Update your nuxt.config.ts with the required modules:

// nuxt.config.ts
export default defineNuxtConfig({
  future: {
    compatibilityVersion: 4,
  },
 
  devtools: { enabled: true },
 
  modules: [
    '@nuxt/ui',
    '@nuxt/fonts',
  ],
 
  runtimeConfig: {
    sessionSecret: process.env.SESSION_SECRET || 'dev-secret-change-in-production',
    databaseUrl: process.env.DATABASE_URL,
    public: {
      appName: 'TaskFlow',
    },
  },
 
  compatibilityDate: '2026-03-01',
})

Install the Nuxt UI and Fonts modules:

pnpm add @nuxt/ui @nuxt/fonts

Create a .env file at the project root:

DATABASE_URL="postgresql://user:password@localhost:5432/taskflow"
SESSION_SECRET="your-super-secure-secret-here"

Step 3: Set Up Prisma and the Database

Install Prisma and initialize it:

pnpm add -D prisma
pnpm add @prisma/client
pnpm dlx prisma init

Define your database schema in prisma/schema.prisma:

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}
 
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
 
model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String
  password  String
  tasks     Task[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
 
model Task {
  id          String     @id @default(cuid())
  title       String
  description String?
  status      TaskStatus @default(TODO)
  priority    Priority   @default(MEDIUM)
  userId      String
  user        User       @relation(fields: [userId], references: [id], onDelete: Cascade)
  createdAt   DateTime   @default(now())
  updatedAt   DateTime   @updatedAt
}
 
enum TaskStatus {
  TODO
  IN_PROGRESS
  DONE
}
 
enum Priority {
  LOW
  MEDIUM
  HIGH
}

Apply the migrations:

pnpm dlx prisma migrate dev --name init

Create a server utility to access the Prisma client:

// server/utils/prisma.ts
import { PrismaClient } from '@prisma/client'
 
const prisma = new PrismaClient()
 
export default prisma

Step 4: Create API Routes

Nuxt uses the Nitro engine for server routes. Create your API endpoints.

Registration Route

// server/api/auth/register.post.ts
import bcrypt from 'bcryptjs'
import prisma from '~/server/utils/prisma'
 
export default defineEventHandler(async (event) => {
  const body = await readBody(event)
 
  if (!body.email || !body.password || !body.name) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Email, name, and password are required',
    })
  }
 
  const existingUser = await prisma.user.findUnique({
    where: { email: body.email },
  })
 
  if (existingUser) {
    throw createError({
      statusCode: 409,
      statusMessage: 'An account with this email already exists',
    })
  }
 
  const hashedPassword = await bcrypt.hash(body.password, 12)
 
  const user = await prisma.user.create({
    data: {
      email: body.email,
      name: body.name,
      password: hashedPassword,
    },
    select: {
      id: true,
      email: true,
      name: true,
    },
  })
 
  const session = await useSession(event, {
    password: useRuntimeConfig().sessionSecret,
  })
  await session.update({ userId: user.id })
 
  return user
})

Login Route

// server/api/auth/login.post.ts
import bcrypt from 'bcryptjs'
import prisma from '~/server/utils/prisma'
 
export default defineEventHandler(async (event) => {
  const body = await readBody(event)
 
  if (!body.email || !body.password) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Email and password are required',
    })
  }
 
  const user = await prisma.user.findUnique({
    where: { email: body.email },
  })
 
  if (!user || !(await bcrypt.compare(body.password, user.password))) {
    throw createError({
      statusCode: 401,
      statusMessage: 'Invalid credentials',
    })
  }
 
  const session = await useSession(event, {
    password: useRuntimeConfig().sessionSecret,
  })
  await session.update({ userId: user.id })
 
  return {
    id: user.id,
    email: user.email,
    name: user.name,
  }
})

Server Authentication Middleware

// server/middleware/auth.ts
import prisma from '~/server/utils/prisma'
 
export default defineEventHandler(async (event) => {
  const protectedRoutes = ['/api/tasks']
 
  const isProtected = protectedRoutes.some((route) =>
    event.path?.startsWith(route)
  )
 
  if (!isProtected) return
 
  const session = await useSession(event, {
    password: useRuntimeConfig().sessionSecret,
  })
 
  if (!session.data?.userId) {
    throw createError({
      statusCode: 401,
      statusMessage: 'Not authenticated',
    })
  }
 
  const user = await prisma.user.findUnique({
    where: { id: session.data.userId as string },
    select: { id: true, email: true, name: true },
  })
 
  if (!user) {
    throw createError({
      statusCode: 401,
      statusMessage: 'User not found',
    })
  }
 
  event.context.user = user
})

Task CRUD Operations

// server/api/tasks/index.get.ts
import prisma from '~/server/utils/prisma'
 
export default defineEventHandler(async (event) => {
  const user = event.context.user
  const query = getQuery(event)
 
  const where: any = { userId: user.id }
 
  if (query.status) {
    where.status = query.status
  }
 
  const tasks = await prisma.task.findMany({
    where,
    orderBy: { createdAt: 'desc' },
  })
 
  return tasks
})
// server/api/tasks/index.post.ts
import prisma from '~/server/utils/prisma'
 
export default defineEventHandler(async (event) => {
  const user = event.context.user
  const body = await readBody(event)
 
  if (!body.title) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Title is required',
    })
  }
 
  const task = await prisma.task.create({
    data: {
      title: body.title,
      description: body.description || null,
      priority: body.priority || 'MEDIUM',
      userId: user.id,
    },
  })
 
  return task
})
// server/api/tasks/[id].patch.ts
import prisma from '~/server/utils/prisma'
 
export default defineEventHandler(async (event) => {
  const user = event.context.user
  const id = getRouterParam(event, 'id')
  const body = await readBody(event)
 
  const task = await prisma.task.findFirst({
    where: { id, userId: user.id },
  })
 
  if (!task) {
    throw createError({
      statusCode: 404,
      statusMessage: 'Task not found',
    })
  }
 
  const updated = await prisma.task.update({
    where: { id },
    data: {
      title: body.title ?? task.title,
      description: body.description ?? task.description,
      status: body.status ?? task.status,
      priority: body.priority ?? task.priority,
    },
  })
 
  return updated
})
// server/api/tasks/[id].delete.ts
import prisma from '~/server/utils/prisma'
 
export default defineEventHandler(async (event) => {
  const user = event.context.user
  const id = getRouterParam(event, 'id')
 
  const task = await prisma.task.findFirst({
    where: { id, userId: user.id },
  })
 
  if (!task) {
    throw createError({
      statusCode: 404,
      statusMessage: 'Task not found',
    })
  }
 
  await prisma.task.delete({ where: { id } })
 
  return { success: true }
})

Install bcryptjs for password hashing:

pnpm add bcryptjs
pnpm add -D @types/bcryptjs

Step 5: Create the Authentication Composable

Create a composable to manage client-side authentication state:

// app/composables/useAuth.ts
interface User {
  id: string
  email: string
  name: string
}
 
export function useAuth() {
  const user = useState<User | null>('auth-user', () => null)
  const isAuthenticated = computed(() => !!user.value)
 
  async function login(email: string, password: string) {
    const data = await $fetch<User>('/api/auth/login', {
      method: 'POST',
      body: { email, password },
    })
    user.value = data
    return data
  }
 
  async function register(name: string, email: string, password: string) {
    const data = await $fetch<User>('/api/auth/register', {
      method: 'POST',
      body: { name, email, password },
    })
    user.value = data
    return data
  }
 
  async function logout() {
    await $fetch('/api/auth/logout', { method: 'POST' })
    user.value = null
    navigateTo('/login')
  }
 
  async function fetchUser() {
    try {
      const data = await $fetch<User>('/api/auth/me')
      user.value = data
    } catch {
      user.value = null
    }
  }
 
  return {
    user,
    isAuthenticated,
    login,
    register,
    logout,
    fetchUser,
  }
}

Add the missing session routes:

// server/api/auth/me.get.ts
export default defineEventHandler(async (event) => {
  const user = event.context.user
  if (!user) {
    throw createError({ statusCode: 401, statusMessage: 'Not authenticated' })
  }
  return user
})
// server/api/auth/logout.post.ts
export default defineEventHandler(async (event) => {
  const session = await useSession(event, {
    password: useRuntimeConfig().sessionSecret,
  })
  await session.clear()
  return { success: true }
})

Step 6: Create the Main Layout

Define a layout with a navigation bar:

<!-- app/layouts/default.vue -->
<script setup lang="ts">
const { user, isAuthenticated, logout } = useAuth()
const config = useRuntimeConfig()
</script>
 
<template>
  <div class="min-h-screen bg-gray-50 dark:bg-gray-900">
    <header class="bg-white dark:bg-gray-800 shadow-sm">
      <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
        <div class="flex justify-between items-center h-16">
          <NuxtLink to="/" class="text-xl font-bold text-primary">
            {{ config.public.appName }}
          </NuxtLink>
 
          <nav class="flex items-center gap-4">
            <template v-if="isAuthenticated">
              <span class="text-sm text-gray-600 dark:text-gray-300">
                {{ user?.name }}
              </span>
              <UButton
                variant="ghost"
                color="red"
                @click="logout"
              >
                Logout
              </UButton>
            </template>
            <template v-else>
              <UButton to="/login" variant="ghost">Login</UButton>
              <UButton to="/register" color="primary">Sign Up</UButton>
            </template>
          </nav>
        </div>
      </div>
    </header>
 
    <main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
      <slot />
    </main>
  </div>
</template>

Step 7: Build the Pages

Home Page

<!-- app/pages/index.vue -->
<script setup lang="ts">
const { isAuthenticated } = useAuth()
</script>
 
<template>
  <div class="text-center py-20">
    <h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">
      Manage Your Tasks Efficiently
    </h1>
    <p class="text-lg text-gray-600 dark:text-gray-300 mb-8 max-w-2xl mx-auto">
      TaskFlow is a simple and powerful task manager.
      Organize, prioritize, and track the progress of your projects.
    </p>
    <div class="flex gap-4 justify-center">
      <UButton
        v-if="!isAuthenticated"
        to="/register"
        size="lg"
        color="primary"
      >
        Get Started Free
      </UButton>
      <UButton
        v-if="isAuthenticated"
        to="/dashboard"
        size="lg"
        color="primary"
      >
        Go to Dashboard
      </UButton>
    </div>
  </div>
</template>

Login Page

<!-- app/pages/login.vue -->
<script setup lang="ts">
definePageMeta({ layout: 'default' })
 
const { login } = useAuth()
const error = ref('')
const loading = ref(false)
 
const form = reactive({
  email: '',
  password: '',
})
 
async function handleSubmit() {
  error.value = ''
  loading.value = true
  try {
    await login(form.email, form.password)
    navigateTo('/dashboard')
  } catch (e: any) {
    error.value = e.data?.statusMessage || 'Login error'
  } finally {
    loading.value = false
  }
}
</script>
 
<template>
  <div class="max-w-md mx-auto mt-16">
    <UCard>
      <template #header>
        <h2 class="text-2xl font-bold text-center">Login</h2>
      </template>
 
      <form @submit.prevent="handleSubmit" class="space-y-4">
        <UAlert
          v-if="error"
          color="red"
          :title="error"
          variant="subtle"
        />
 
        <UFormGroup label="Email">
          <UInput
            v-model="form.email"
            type="email"
            placeholder="you@email.com"
            required
          />
        </UFormGroup>
 
        <UFormGroup label="Password">
          <UInput
            v-model="form.password"
            type="password"
            placeholder="Your password"
            required
          />
        </UFormGroup>
 
        <UButton
          type="submit"
          block
          :loading="loading"
        >
          Sign In
        </UButton>
      </form>
 
      <template #footer>
        <p class="text-center text-sm text-gray-500">
          No account yet?
          <NuxtLink to="/register" class="text-primary font-medium">
            Sign up
          </NuxtLink>
        </p>
      </template>
    </UCard>
  </div>
</template>

Registration Page

<!-- app/pages/register.vue -->
<script setup lang="ts">
definePageMeta({ layout: 'default' })
 
const { register } = useAuth()
const error = ref('')
const loading = ref(false)
 
const form = reactive({
  name: '',
  email: '',
  password: '',
  confirmPassword: '',
})
 
async function handleSubmit() {
  if (form.password !== form.confirmPassword) {
    error.value = 'Passwords do not match'
    return
  }
 
  error.value = ''
  loading.value = true
  try {
    await register(form.name, form.email, form.password)
    navigateTo('/dashboard')
  } catch (e: any) {
    error.value = e.data?.statusMessage || 'Registration error'
  } finally {
    loading.value = false
  }
}
</script>
 
<template>
  <div class="max-w-md mx-auto mt-16">
    <UCard>
      <template #header>
        <h2 class="text-2xl font-bold text-center">Sign Up</h2>
      </template>
 
      <form @submit.prevent="handleSubmit" class="space-y-4">
        <UAlert
          v-if="error"
          color="red"
          :title="error"
          variant="subtle"
        />
 
        <UFormGroup label="Name">
          <UInput
            v-model="form.name"
            placeholder="Your name"
            required
          />
        </UFormGroup>
 
        <UFormGroup label="Email">
          <UInput
            v-model="form.email"
            type="email"
            placeholder="you@email.com"
            required
          />
        </UFormGroup>
 
        <UFormGroup label="Password">
          <UInput
            v-model="form.password"
            type="password"
            placeholder="Minimum 8 characters"
            required
            minlength="8"
          />
        </UFormGroup>
 
        <UFormGroup label="Confirm Password">
          <UInput
            v-model="form.confirmPassword"
            type="password"
            placeholder="Repeat your password"
            required
          />
        </UFormGroup>
 
        <UButton
          type="submit"
          block
          :loading="loading"
        >
          Create Account
        </UButton>
      </form>
 
      <template #footer>
        <p class="text-center text-sm text-gray-500">
          Already have an account?
          <NuxtLink to="/login" class="text-primary font-medium">
            Sign in
          </NuxtLink>
        </p>
      </template>
    </UCard>
  </div>
</template>

Dashboard Page

<!-- app/pages/dashboard.vue -->
<script setup lang="ts">
definePageMeta({
  middleware: 'auth',
})
 
interface Task {
  id: string
  title: string
  description: string | null
  status: 'TODO' | 'IN_PROGRESS' | 'DONE'
  priority: 'LOW' | 'MEDIUM' | 'HIGH'
  createdAt: string
}
 
const activeFilter = ref<string | null>(null)
 
const { data: tasks, refresh } = await useFetch<Task[]>('/api/tasks', {
  query: computed(() => ({
    status: activeFilter.value || undefined,
  })),
})
 
const showCreateModal = ref(false)
 
const newTask = reactive({
  title: '',
  description: '',
  priority: 'MEDIUM' as 'LOW' | 'MEDIUM' | 'HIGH',
})
 
async function createTask() {
  await $fetch('/api/tasks', {
    method: 'POST',
    body: {
      title: newTask.title,
      description: newTask.description || null,
      priority: newTask.priority,
    },
  })
  newTask.title = ''
  newTask.description = ''
  newTask.priority = 'MEDIUM'
  showCreateModal.value = false
  refresh()
}
 
async function updateTaskStatus(taskId: string, status: string) {
  await $fetch(`/api/tasks/${taskId}`, {
    method: 'PATCH',
    body: { status },
  })
  refresh()
}
 
async function deleteTask(taskId: string) {
  await $fetch(`/api/tasks/${taskId}`, {
    method: 'DELETE',
  })
  refresh()
}
 
const filters = [
  { label: 'All', value: null },
  { label: 'To Do', value: 'TODO' },
  { label: 'In Progress', value: 'IN_PROGRESS' },
  { label: 'Done', value: 'DONE' },
]
 
const priorityColors = {
  LOW: 'green',
  MEDIUM: 'yellow',
  HIGH: 'red',
} as const
 
const statusLabels = {
  TODO: 'To Do',
  IN_PROGRESS: 'In Progress',
  DONE: 'Done',
} as const
</script>
 
<template>
  <div>
    <div class="flex justify-between items-center mb-8">
      <h1 class="text-2xl font-bold text-gray-900 dark:text-white">
        My Tasks
      </h1>
      <UButton
        color="primary"
        icon="i-heroicons-plus"
        @click="showCreateModal = true"
      >
        New Task
      </UButton>
    </div>
 
    <!-- Filters -->
    <div class="flex gap-2 mb-6">
      <UButton
        v-for="filter in filters"
        :key="filter.label"
        :variant="activeFilter === filter.value ? 'solid' : 'ghost'"
        size="sm"
        @click="activeFilter = filter.value"
      >
        {{ filter.label }}
      </UButton>
    </div>
 
    <!-- Task List -->
    <div class="space-y-3">
      <UCard
        v-for="task in tasks"
        :key="task.id"
      >
        <div class="flex items-start justify-between">
          <div class="flex-1">
            <div class="flex items-center gap-2 mb-1">
              <h3
                class="font-medium"
                :class="task.status === 'DONE' ? 'line-through text-gray-400' : ''"
              >
                {{ task.title }}
              </h3>
              <UBadge :color="priorityColors[task.priority]" size="xs">
                {{ task.priority }}
              </UBadge>
            </div>
            <p v-if="task.description" class="text-sm text-gray-500">
              {{ task.description }}
            </p>
          </div>
 
          <div class="flex items-center gap-2">
            <USelect
              :model-value="task.status"
              :options="[
                { label: 'To Do', value: 'TODO' },
                { label: 'In Progress', value: 'IN_PROGRESS' },
                { label: 'Done', value: 'DONE' },
              ]"
              size="sm"
              @update:model-value="updateTaskStatus(task.id, $event)"
            />
            <UButton
              icon="i-heroicons-trash"
              color="red"
              variant="ghost"
              size="sm"
              @click="deleteTask(task.id)"
            />
          </div>
        </div>
      </UCard>
 
      <div
        v-if="!tasks?.length"
        class="text-center py-12 text-gray-500"
      >
        <p class="text-lg mb-2">No tasks found</p>
        <p class="text-sm">Click "New Task" to get started</p>
      </div>
    </div>
 
    <!-- Create Modal -->
    <UModal v-model="showCreateModal">
      <UCard>
        <template #header>
          <h3 class="text-lg font-medium">New Task</h3>
        </template>
 
        <form @submit.prevent="createTask" class="space-y-4">
          <UFormGroup label="Title" required>
            <UInput
              v-model="newTask.title"
              placeholder="Task title"
              required
            />
          </UFormGroup>
 
          <UFormGroup label="Description">
            <UTextarea
              v-model="newTask.description"
              placeholder="Optional description"
            />
          </UFormGroup>
 
          <UFormGroup label="Priority">
            <USelect
              v-model="newTask.priority"
              :options="[
                { label: 'Low', value: 'LOW' },
                { label: 'Medium', value: 'MEDIUM' },
                { label: 'High', value: 'HIGH' },
              ]"
            />
          </UFormGroup>
 
          <div class="flex justify-end gap-2">
            <UButton
              variant="ghost"
              @click="showCreateModal = false"
            >
              Cancel
            </UButton>
            <UButton type="submit" color="primary">
              Create
            </UButton>
          </div>
        </form>
      </UCard>
    </UModal>
  </div>
</template>

Step 8: Client-Side Authentication Middleware

Create a navigation middleware to protect pages:

// app/middleware/auth.ts
export default defineNuxtRouteMiddleware(async (to) => {
  const { isAuthenticated, fetchUser } = useAuth()
 
  await fetchUser()
 
  if (!isAuthenticated.value) {
    return navigateTo('/login')
  }
})

Step 9: Add Validation with Zod

Install Zod for server-side data validation:

pnpm add zod

Create a validation utility:

// server/utils/validate.ts
import { z } from 'zod'
 
export const createTaskSchema = z.object({
  title: z.string().min(1, 'Title is required').max(200),
  description: z.string().max(1000).optional(),
  priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).default('MEDIUM'),
})
 
export const updateTaskSchema = z.object({
  title: z.string().min(1).max(200).optional(),
  description: z.string().max(1000).nullable().optional(),
  status: z.enum(['TODO', 'IN_PROGRESS', 'DONE']).optional(),
  priority: z.enum(['LOW', 'MEDIUM', 'HIGH']).optional(),
})
 
export const loginSchema = z.object({
  email: z.string().email('Invalid email'),
  password: z.string().min(1, 'Password is required'),
})
 
export const registerSchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z.string().email('Invalid email'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
})

Then update your task creation route to use validation:

// server/api/tasks/index.post.ts (updated)
import prisma from '~/server/utils/prisma'
import { createTaskSchema } from '~/server/utils/validate'
 
export default defineEventHandler(async (event) => {
  const user = event.context.user
  const body = await readBody(event)
 
  const result = createTaskSchema.safeParse(body)
  if (!result.success) {
    throw createError({
      statusCode: 400,
      statusMessage: result.error.issues[0].message,
    })
  }
 
  const task = await prisma.task.create({
    data: {
      ...result.data,
      description: result.data.description || null,
      userId: user.id,
    },
  })
 
  return task
})

Step 10: Test Your Application

Start the development server:

pnpm dev

Test the complete flow:

  1. Navigate to http://localhost:3000 — the home page displays
  2. Sign up by clicking "Sign Up" and filling out the form
  3. Create a task from the dashboard
  4. Change the status of a task using the dropdown
  5. Filter tasks by status
  6. Delete a task with the trash button

Make sure PostgreSQL is running and your DATABASE_URL is correct before starting the application. If using a cloud service like Neon, verify that your machine's IP address is allowed.

Step 11: Prepare for Production

Build Configuration

Update nuxt.config.ts for production:

// nuxt.config.ts (production additions)
export default defineNuxtConfig({
  // ... existing configuration
 
  nitro: {
    preset: 'node-server',
    compressPublicAssets: true,
  },
 
  app: {
    head: {
      title: 'TaskFlow - Task Manager',
      meta: [
        { name: 'description', content: 'Modern and efficient task management application' },
      ],
    },
  },
})

Build and Run

# Production build
pnpm build
 
# Run in production
node .output/server/index.mjs

Deploy with Docker

Create a Dockerfile:

FROM node:20-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
 
FROM base AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm dlx prisma generate
RUN pnpm build
 
FROM base AS production
WORKDIR /app
COPY --from=build /app/.output .output
COPY --from=build /app/node_modules/.prisma node_modules/.prisma
COPY --from=build /app/prisma prisma
 
ENV NODE_ENV=production
EXPOSE 3000
 
CMD ["node", ".output/server/index.mjs"]
# Build and run
docker build -t taskflow .
docker run -p 3000:3000 --env-file .env taskflow

Troubleshooting

Error "Cannot find module @prisma/client"

Make sure you ran pnpm dlx prisma generate after installing dependencies.

Sessions do not persist

Check that SESSION_SECRET is defined in your .env file. In production, use a long, random secret.

Database connection error

Verify your DATABASE_URL and ensure PostgreSQL is accessible. For cloud services, check firewall rules.

Nuxt UI components not rendering

Make sure @nuxt/ui is in the modules list in nuxt.config.ts and dependencies are installed.

Next Steps

Now that your application is working, you can:

  • Add categories to organize tasks by project
  • Implement drag-and-drop with a Kanban board (vue-draggable)
  • Add email notifications for overdue tasks
  • Integrate OAuth with Google or GitHub via nuxt-auth-utils
  • Add tests with Vitest and Testing Library
  • Set up selective SSR to optimize performance

Conclusion

You have built a complete full-stack web application with Nuxt 4 and Vue 3. This tutorial covered:

  • The new Nuxt 4 project structure with the app/ directory
  • The automatic file-based routing system
  • API routes with the Nitro engine
  • Database management with Prisma ORM
  • The Vue 3 Composition API for reactive components
  • Data validation with Zod
  • Deployment with Docker

Nuxt 4 provides a remarkable full-stack development experience thanks to its seamless server-side integration and rich module ecosystem. Whether you are building an MVP or an enterprise application, Nuxt 4 is a solid choice for your Vue.js projects.


Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles