Major update to our Free Plan: now with 1 GB of Postgres storage per project, 100 projects included

Building a Nuxt.js app with a Vercel and Neon branching workflow

Automate database branching for every preview deployment with the Vercel-Managed Neon integration

Nuxt.js is an open-source framework built on Vue.js. It adds rendering options to Vue, including default universal rendering (SSR) for fast initial loads and strong SEO, and client-side rendering for highly interactive applications. Nuxt also supports advanced strategies like hybrid rendering to mix modes per-route.

As your Nuxt application grows, you need a way to test features that change the database schema without touching your live application. The integration between Vercel and Neon handles this. Vercel is a deployment platform, and Neon is a complete set of cloud backend primitives built around Lakebase Postgres. Together, they give every preview deployment its own database branch.

When you enable the integration, every time you push a new feature branch, Vercel automatically creates a preview deployment. Simultaneously, Neon creates an isolated copy of your database just for that branch. You can develop and test with realistic data without touching your live application.

In this guide, you will learn how to:

  • Set up a Nuxt.js project and connect it to a GitHub repository.
  • Install and configure the Vercel-Managed Neon integration.
  • Define a database schema and manage migrations with Drizzle ORM.
  • Build a full-stack Todo application with Nuxt.js server routes.
  • Test the automated branching workflow: Push a new feature branch and see Vercel and Neon automatically create a sandboxed environment with its own database branch.
  • Safely merge schema changes into production.

Prerequisites

Before you begin, ensure you have the following:

  • Node.js v18.0 or later.
  • A Vercel account
  • A Neon account. The Vercel integration can create one for you if you don't have one.
  1. Initialize Nuxt.js project

    First, create a new Nuxt.js application and push it to a GitHub repository, which Vercel will use for deployments.

    1. Create a Nuxt.js app: Open your terminal and run the following command:

      npm create nuxt nuxt-neon-vercel-todo

      When prompted, choose the following options:

      • Package manager: npm
      • Initialize git repository: Yes
      • Install official modules: No

      You should see output similar to this:

      > npm create nuxt nuxt-neon-vercel-todo
      Need to install the following packages:
      create-nuxt@3.26.0
      Ok to proceed? (y) y
      
             .d$b.
            i$$A$$L  .d$b
          .$$F` `$$L.$$A$$.
         j$$'    `4$$:` `$$.
        j$$'     .4$:    `$$.
       j$$`     .$$:      `4$L
      :$$:____.d$$:  _____.:$$:
      `4$$$$$$$$P` .i$$$$$$$$P`
      
      ℹ Welcome to Nuxt!                                                                                            nuxi 9:46:56 AM
      ℹ Creating a new project in nuxt-neon-vercel-todo.                                                            nuxi 9:46:58 AM
      
      ✔ Which package manager would you like to use?
      npm
      ◐ Installing dependencies...                                                                                   nuxi 9:47:02 AM
      npm WARN deprecated node-domexception@1.0.0: Use your platform's native DOMException instead
      npm WARN deprecated @types/parse-path@7.1.0: This is a stub types definition. parse-path provides its own type definitions, so you do not need this installed.
      
      > postinstall
      > nuxt prepare
      
      ✔ Types generated in .nuxt                                                                                    nuxi 9:48:15 AM
      
      added 751 packages, and audited 753 packages in 1m
      
      171 packages are looking for funding
      run `npm fund` for details
      
      found 0 vulnerabilities
      ✔ Installation completed.                                                                                     nuxi 9:48:15 AM
      
      ✔ Initialize git repository?
      Yes
      ℹ Initializing git repository...                                                                              nuxi 9:48:18 AM
      
      Initialized empty Git repository in /nuxt-neon-vercel-todo/.git/
      
      ✔ Would you like to install any of the official modules?
      No
    2. Navigate into the project directory

      cd nuxt-neon-vercel-todo
    3. Push the initial project to a GitHub repository:

      Create a new repository on GitHub (e.g., nuxt-neon-vercel-todo). Then, run the following commands in your terminal, replacing <your-github-repo-url> with your repository's URL.

      git add .
      git commit -m "Initial commit"
      git remote add origin <your-github-repo-url>
      git push -u origin main

    Next, you'll set up the Vercel-Managed Neon integration. This step provisions a database on Neon and automatically injects the DATABASE_URL environment variable into your Nuxt.js application. This setup covers both local development and Vercel deployments.

  2. Create a Vercel project

    1. Log in to the Vercel dashboard.

    2. Click on Add New and select Project.

    3. Select the GitHub repository you created earlier (nuxt-neon-vercel-todo).

    4. Vercel will automatically detect that this is a Nuxt.js application and configure the build settings accordingly.

    5. Click Deploy to create your Vercel project.

      Create Nuxt.js project in Vercel

  3. Configure the Vercel Neon integration

    Install the Neon integration from the Vercel Marketplace. This process will create a Neon project and link it to your Vercel account.

    1. Navigate to the Neon integration page on the Vercel Marketplace and click Install.
    2. Accept the terms, pick a region and plan, then name your database. (Remember: a "Database" in Vercel is a Project in Neon.)
  4. Connect the database to your Vercel project

    With the integration installed, you need to connect your nuxt-neon-vercel-todo Vercel project to your new Neon database. This is where you enable the automated branching feature.

    1. From the Storage tab in your Vercel dashboard, select your Neon database.
    2. Click the Connect Project tab and select your nuxt-neon-vercel-todo project from the dropdown.
    3. Enable Preview Branches: Under Advanced Options, in the Deployments Configuration section, make sure the Preview toggle is enabled. This step tells Vercel to create a new Neon database branch for every Preview Deployment. Connect Vercel Project and enable Preview deployments
    4. Click Connect.

    Vercel will now automatically add the DATABASE_URL environment variable to your project, making it available in Production, Preview, and Development environments.

    Now that your Vercel project is connected to Neon, you can start defining your database schema and managing migrations.

  5. Add database schema and migrations

    To manage your database schema and migrations, you will use Drizzle ORM with the Neon serverless driver. Drizzle ORM gives you type-safe schema definitions and migrations.

    1. Install Drizzle dependencies

      npm install drizzle-orm @neondatabase/serverless
      npm install -D drizzle-kit
    2. Define the schema:

      Create a new file at server/db/schema.ts to define the todos table.

      import { pgTable, serial, text, boolean, timestamp } from 'drizzle-orm/pg-core';
      
      export const todos = pgTable('todos', {
        id: serial('id').primaryKey(),
        text: text('text').notNull(),
        completed: boolean('completed').default(false).notNull(),
        createdAt: timestamp('created_at').defaultNow().notNull(),
      });

      The todos table has the following fields:

      • id: A unique identifier for each todo item.
      • text: The text of the todo item.
      • completed: A boolean indicating whether the todo is completed.
      • createdAt: A timestamp for when the todo was created.
    3. Configure Drizzle Kit:

      Create drizzle.config.ts in your project root. Drizzle Kit will use this to connect to your database to generate and run migrations.

      import { defineConfig } from 'drizzle-kit';
      
      export default defineConfig({
        schema: './server/db/schema.ts',
        out: './server/db/migrations',
        dialect: 'postgresql',
        dbCredentials: {
          url: process.env.DATABASE_URL!,
        },
      });
    4. Add migration scripts to package.json:

      {
        "scripts": {
          // ... other scripts
          "db:generate": "drizzle-kit generate",
          "db:migrate": "drizzle-kit migrate",
          "build": "nuxt build && npm run db:migrate"
        }
      }

      The build script runs db:migrate after building the Nuxt application, so pending migrations are applied to the database before the new deployment goes live.

    5. Generate the initial migration:

      To run migrations locally or in the Vercel build step, you need the DATABASE_URL available. Create a .env file in your root directory.

      Now, go to your Vercel project's Settings -> Environment Variables, reveal the value for DATABASE_URL, and copy it into your .env file.

      DATABASE_URL="postgres://..."

      Now, generate your first migration file:

      npm run db:generate

      This creates a new SQL file in the server/db/migrations directory.

    6. Commit the changes: Add all the new files to git and commit them.

      git add .
      git commit -m "feat: setup drizzle and initial schema"
      git push

      Pushing to main will trigger a production deployment on Vercel. The npm run build command will execute npm run db:migrate, applying your initial migration to the main database branch.

  6. Build the Todo application

    With the database configured, build the Todo application: server routes for CRUD operations and a frontend that calls them.

    Security note

    The following code is a basic example and does not include authentication or input validation. In a production application, you should implement proper security measures to protect your API endpoints and database.

    1. Create a Drizzle client utility:

      Create server/utils/db.ts to instantiate and export the Drizzle client.

      import { drizzle } from 'drizzle-orm/neon-http';
      import { neon } from '@neondatabase/serverless';
      import * as schema from '~/server/db/schema';
      
      const sql = neon(process.env.DATABASE_URL!);
      export const db = drizzle(sql, { schema });
    2. Create API endpoints:

      Nuxt automatically scans the server/api directory to create your API.

      • GET /api/todos: Create server/api/todos.get.ts

        import { db } from '~/server/utils/db';
        import { todos } from '~/server/db/schema';
        import { desc } from 'drizzle-orm';
        
        export default defineEventHandler(async () => {
          return await db.select().from(todos).orderBy(desc(todos.createdAt));
        });

        The GET endpoint retrieves all todo items from the database, ordered by creation date.

      • POST /api/todos: Create server/api/todos.post.ts

        import { db } from '~/server/utils/db';
        import { todos } from '~/server/db/schema';
        
        export default defineEventHandler(async (event) => {
          const body = await readBody(event);
          const [newTodo] = await db.insert(todos).values({ text: body.text }).returning();
          return newTodo;
        });

        The POST endpoint creates a new todo item with the provided text.

      • PUT /api/todos/:id: Create server/api/todos/[id].put.ts

        import { db } from '~/server/utils/db';
        import { todos } from '~/server/db/schema';
        import { eq } from 'drizzle-orm';
        
        export default defineEventHandler(async (event) => {
          const id = getRouterParam(event, 'id');
          const body = await readBody(event);
          const [updatedTodo] = await db
            .update(todos)
            .set({ completed: body.completed })
            .where(eq(todos.id, Number(id)))
            .returning();
          return updatedTodo;
        });

        The PUT endpoint updates the completion status of a todo item by its ID.

      • DELETE /api/todos/:id: Create server/api/todos/[id].delete.ts

        import { db } from '~/server/utils/db';
        import { todos } from '~/server/db/schema';
        import { eq } from 'drizzle-orm';
        
        export default defineEventHandler(async (event) => {
          const id = getRouterParam(event, 'id');
          await db.delete(todos).where(eq(todos.id, Number(id)));
          return { message: 'Todo deleted' };
        });

        The DELETE endpoint deletes a todo item by its ID.

    3. Build the user interface:

      Replace the content of app.vue with the following code to create the frontend for the todo app.

      <template>
        <div class="container">
          <h1>Todo List</h1>
          <p>
            A Nuxt.js app with a serverless Postgres database from Neon, deployed on Vercel with
            automated database branching.
          </p>
          <form @submit.prevent="addTodo">
            <input v-model="newTodoText" placeholder="Add a new todo item" required />
            <button type="submit">Add Todo</button>
          </form>
          <ul>
            <li v-for="todo in todos" :key="todo.id">
              <span :class="{ completed: todo.completed }" @click="toggleTodo(todo)">
                {{ todo.text }}
              </span>
              <button class="remove-btn" @click="removeTodo(todo)">Remove</button>
            </li>
          </ul>
        </div>
      </template>
      
      <script setup>
      const { data: todos, refresh } = await useFetch('/api/todos');
      const newTodoText = ref('');
      
      const addTodo = async () => {
        if (!newTodoText.value.trim()) return;
        await $fetch('/api/todos', {
          method: 'POST',
          body: { text: newTodoText.value },
        });
        newTodoText.value = '';
        await refresh();
      };
      
      const toggleTodo = async (todo) => {
        await $fetch(`/api/todos/${todo.id}`, {
          method: 'PUT',
          body: { completed: !todo.completed },
        });
        await refresh();
      };
      
      const removeTodo = async (todo) => {
        await $fetch(`/api/todos/${todo.id}`, {
          method: 'DELETE',
        });
        await refresh();
      };
      </script>
      
      <style>
      body {
        background-color: #f4f4f9;
        color: #333;
      }
      .container {
        max-width: 600px;
        margin: 2rem auto;
        font-family: sans-serif;
        background: #fff;
        padding: 2rem;
        border-radius: 8px;
        box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
      }
      h1 {
        text-align: center;
        color: #000000;
      }
      p {
        text-align: center;
        margin-bottom: 2rem;
      }
      form {
        display: flex;
        margin-bottom: 1rem;
      }
      input {
        flex-grow: 1;
        padding: 0.75rem;
        border: 1px solid #ccc;
        border-radius: 4px;
      }
      button {
        padding: 0.75rem 1.5rem;
        border: none;
        background-color: #00b04f;
        color: white;
        border-radius: 4px;
        cursor: pointer;
        margin-left: 0.5rem;
      }
      ul {
        list-style: none;
        padding: 0;
      }
      li {
        display: flex;
        justify-content: space-between;
        align-items: center;
        padding: 0.75rem;
        border-bottom: 1px solid #eee;
      }
      li span {
        cursor: pointer;
      }
      .completed {
        text-decoration: line-through;
        color: #aaa;
      }
      .remove-btn {
        background-color: #ff4d4d;
        color: white;
        border: none;
        padding: 0.4rem 0.8rem;
        border-radius: 4px;
        cursor: pointer;
      }
      </style>

      The code above creates the Todo application. It includes a form to add new todos, a list to display existing todos, and buttons to toggle completion status and remove todos. It uses the API endpoints you created earlier to interact with the database.

    4. Run the application locally:

      Start your Nuxt.js application locally to test the functionality.

      npm run dev

      Open your browser and navigate to http://localhost:3000. You should see your Todo application running. You can add, toggle, and remove todos, which reads and writes your database on Neon.

      Nuxt Todo Application

    5. Commit and push the application code:

      git add .
      git commit -m "build: complete todo application"
      git push
  7. The branching workflow in action

    To demonstrate the automated branching workflow, you will add a new feature to your Todo application that requires a database schema change. For this example, you will add a priority field to the todos.

    1. Create a new feature branch in git:

      Create a new branch so you can work on the feature without affecting main:

      git checkout -b feature/add-priority
    2. Modify the database schema:

      Update server/db/schema.ts to include a priority field.

      import { pgTable, serial, text, boolean, timestamp } from 'drizzle-orm/pg-core'; 
      import { pgTable, serial, text, boolean, timestamp, pgEnum } from 'drizzle-orm/pg-core'; 
      
      export const priorityEnum = pgEnum('priority', ['low', 'medium', 'high']); 
      
      export const todos = pgTable('todos', {
        id: serial('id').primaryKey(),
        text: text('text').notNull(),
        completed: boolean('completed').default(false).notNull(),
        priority: priorityEnum().default('medium').notNull(),
        createdAt: timestamp('created_at').defaultNow().notNull(),
      });
    3. Generate a new migration:

      Run the db:generate command again. It will compare your updated schema to the database state and create a new migration file.

      npm run db:generate
    4. Update app.vue with the new priority field in the UI. Copy the following code to replace the existing content in app.vue.

      <template>
        <div class="container">
          <h1>Todo List</h1>
          <p>
            A Nuxt.js app with a serverless Postgres database from Neon, deployed on Vercel with
            automated database branching.
          </p>
          <form @submit.prevent="addTodo">
            <div class="form-row">
              <input v-model="newTodoText" placeholder="Add a new todo item" required />
              <select v-model="newTodoPriority" class="priority-select">
                <option value="low">Low Priority</option>
                <option value="medium">Medium Priority</option>
                <option value="high">High Priority</option>
              </select>
              <button type="submit">Add Todo</button>
            </div>
          </form>
          <ul>
            <li v-for="todo in todos" :key="todo.id" :class="`priority-${todo.priority}`">
              <div class="todo-content">
                <span :class="{ completed: todo.completed }" @click="toggleTodo(todo)">
                  {{ todo.text }}
                </span>
                <span class="priority-badge" :class="`priority-${todo.priority}`">
                  {{ todo.priority }}
                </span>
              </div>
              <button class="remove-btn" @click="removeTodo(todo)">Remove</button>
            </li>
          </ul>
        </div>
      </template>
      
      <script setup>
      const { data: todos, refresh } = await useFetch('/api/todos');
      const newTodoText = ref('');
      const newTodoPriority = ref('medium');
      
      const addTodo = async () => {
        if (!newTodoText.value.trim()) return;
        await $fetch('/api/todos', {
          method: 'POST',
          body: {
            text: newTodoText.value,
            priority: newTodoPriority.value,
          },
        });
        newTodoText.value = '';
        newTodoPriority.value = 'medium';
        await refresh();
      };
      
      const toggleTodo = async (todo) => {
        await $fetch(`/api/todos/${todo.id}`, {
          method: 'PUT',
          body: { completed: !todo.completed },
        });
        await refresh();
      };
      
      const removeTodo = async (todo) => {
        await $fetch(`/api/todos/${todo.id}`, {
          method: 'DELETE',
        });
        await refresh();
      };
      </script>
      
      <style>
      body {
        background-color: #f4f4f9;
        color: #333;
      }
      .container {
        max-width: 600px;
        margin: 2rem auto;
        font-family: sans-serif;
        background: #fff;
        padding: 2rem;
        border-radius: 8px;
        box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
      }
      h1 {
        text-align: center;
        color: #000000;
      }
      p {
        text-align: center;
        margin-bottom: 2rem;
      }
      form {
        margin-bottom: 1rem;
      }
      .form-row {
        display: flex;
        gap: 0.5rem;
        align-items: stretch;
      }
      input {
        flex-grow: 1;
        padding: 0.75rem;
        border: 1px solid #ccc;
        border-radius: 4px;
      }
      .priority-select {
        padding: 0.75rem;
        border: 1px solid #ccc;
        border-radius: 4px;
        background: white;
        min-width: 140px;
      }
      button {
        padding: 0.75rem 1.5rem;
        border: none;
        background-color: #00b04f;
        color: white;
        border-radius: 4px;
        cursor: pointer;
      }
      ul {
        list-style: none;
        padding: 0;
      }
      li {
        display: flex;
        justify-content: space-between;
        align-items: center;
        padding: 0.75rem;
        border-bottom: 1px solid #eee;
        border-left: 4px solid #ddd;
      }
      li.priority-high {
        border-left-color: #ff4444;
      }
      li.priority-medium {
        border-left-color: #ffaa00;
      }
      li.priority-low {
        border-left-color: #44ff44;
      }
      .todo-content {
        display: flex;
        align-items: center;
        gap: 0.75rem;
        flex-grow: 1;
      }
      li span {
        cursor: pointer;
      }
      .completed {
        text-decoration: line-through;
        color: #aaa;
      }
      .priority-badge {
        font-size: 0.75rem;
        padding: 0.25rem 0.5rem;
        border-radius: 12px;
        text-transform: uppercase;
        font-weight: bold;
        cursor: default !important;
      }
      .priority-badge.priority-high {
        background-color: #ffe6e6;
        color: #cc0000;
      }
      .priority-badge.priority-medium {
        background-color: #fff3e0;
        color: #e65100;
      }
      .priority-badge.priority-low {
        background-color: #e8f5e8;
        color: #2e7d32;
      }
      .remove-btn {
        background-color: #ff4d4d;
        color: white;
        border: none;
        padding: 0.4rem 0.8rem;
        border-radius: 4px;
        cursor: pointer;
      }
      </style>
    5. Update the server/api/todos.post.ts endpoint to handle the new priority field:

      import { db } from '~/server/utils/db';
      import { todos } from '~/server/db/schema';
      
      export default defineEventHandler(async (event) => {
        const body = await readBody(event);
        const [newTodo] = await db
          .insert(todos)
          .values({
            text: body.text,
            priority: body.priority || 'medium',
          })
          .returning();
        return newTodo;
      });
    6. Push the feature branch to GitHub:

      Commit the schema and migration changes and push the new branch.

      git add .
      git commit -m "feat: add priority field to todos"
      git push -u origin feature/add-priority
    7. Check the Preview deployment:

      • Vercel: Go to your project in Vercel. You will see a new Preview deployment being built for the feature/add-priority branch.
      • Neon: Go to your project in the Neon Console and click Branches. You will see a new branch has been automatically created, named something like preview/feature/add-priority. This branch is an instant, copy-on-write clone of your main branch.

      When Vercel built the preview deployment, the npm run build command ran db:migrate against the new preview database branch, applying the migration to add the priority column. Your preview URL is now running the new feature code against a database with the updated schema, all without touching production.

      Preview deployment

    8. Merge the pull request:

      On GitHub, create and merge the pull request for your feature branch. Merging to main will trigger a new Production Deployment in Vercel. The build process runs again, and this time db:migrate applies the new schema change to your production database branch.

Summary

You configured a Nuxt.js application so every preview deployment on Vercel gets its own copy-on-write database branch on Neon, with migrations applied during the build.

Preview branches stay around after you merge. To delete them automatically, see Vercel branch cleanup.

Resources

Need help?

Join our Discord Server to ask questions or see what others are doing with Neon. For paid plan support options, see Support.

Was this page helpful?
Edit on GitHub