Neon Serverless Postgres with Next.js App Router: Build a Full-Stack App with Database Branching

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

Serverless Postgres that scales to zero. Neon is a fully managed Postgres platform designed for modern serverless and edge applications. In this tutorial, you will build a complete bookmarks manager with Next.js 15, Neon's serverless driver, and database branching for safe preview deployments.

What You Will Learn

By the end of this tutorial, you will:

  • Set up a Neon Postgres database with serverless connection pooling
  • Use the Neon serverless driver (@neondatabase/serverless) for HTTP-based queries
  • Build a full-stack bookmarks manager with Next.js 15 App Router
  • Implement Server Actions for database mutations
  • Create database branches for preview deployments
  • Configure connection pooling for production performance
  • Deploy with proper environment variable management

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ installed (node --version)
  • TypeScript experience (types, async/await)
  • Next.js familiarity (App Router, Server Components, Server Actions)
  • A Neon account — free tier at neon.tech (no credit card required)
  • A code editor — VS Code or Cursor recommended

Why Neon?

PostgreSQL is the gold standard for relational databases, but traditional Postgres requires always-on servers. Neon changes this with a serverless architecture built from the ground up:

FeatureTraditional PostgresNeon Serverless
ScalingManual vertical scalingAuto-scales to zero, scales up on demand
Cold startsN/A (always running)Under 500ms wake-up time
BranchingManual pg_dump + restoreInstant copy-on-write branches
CostPay for idle timePay only for compute and storage used
Connection modelTCP persistent connectionsHTTP queries + WebSocket pooling
Edge compatibilityRequires TCP (not edge-friendly)Works in Vercel Edge, Cloudflare Workers

The killer feature is database branching — you can create an instant copy of your production database for every pull request, just like Git branches for your data.


Step 1: Create a Neon Project

  1. Sign up at neon.tech and create a new project
  2. Choose your region (pick one close to your deployment target)
  3. Name your project bookmarks-app
  4. Neon creates a default main branch with a neondb database

After creation, you will see your connection string. It looks like this:

postgres://username:password@ep-cool-name-123456.us-east-2.aws.neon.tech/neondb?sslmode=require

Save this — you will need it in the next step. Neon provides two connection strings:

  • Direct connection — for migrations and admin tasks
  • Pooled connection — for application queries (uses connection pooling via PgBouncer)

The pooled connection adds -pooler to the hostname:

postgres://username:password@ep-cool-name-123456-pooler.us-east-2.aws.neon.tech/neondb?sslmode=require

Step 2: Scaffold the Next.js Project

Create a new Next.js 15 project with TypeScript:

npx create-next-app@latest bookmarks-app --typescript --tailwind --eslint --app --src-dir --use-npm
cd bookmarks-app

Install the Neon serverless driver and Drizzle ORM for type-safe queries:

npm install @neondatabase/serverless drizzle-orm
npm install -D drizzle-kit dotenv

Why the Neon Serverless Driver?

The @neondatabase/serverless package replaces the traditional pg driver. It communicates over HTTP and WebSockets instead of TCP, which means:

  • Works in edge runtimes (Vercel Edge Functions, Cloudflare Workers)
  • No persistent connection overhead — each query is a stateless HTTP request
  • Automatic connection pooling via Neon's proxy
  • Sub-millisecond overhead compared to traditional TCP connections

Step 3: Configure Environment Variables

Create a .env.local file in your project root:

# Neon Database
DATABASE_URL="postgres://username:password@ep-cool-name-123456-pooler.us-east-2.aws.neon.tech/neondb?sslmode=require"
DIRECT_DATABASE_URL="postgres://username:password@ep-cool-name-123456.us-east-2.aws.neon.tech/neondb?sslmode=require"

Replace the values with your actual Neon connection strings. The DATABASE_URL uses the pooled connection for application queries, while DIRECT_DATABASE_URL uses the direct connection for migrations.


Step 4: Define the Database Schema

Create the schema file at src/db/schema.ts:

import { pgTable, serial, text, varchar, timestamp, boolean, integer } from "drizzle-orm/pg-core";
 
export const bookmarks = pgTable("bookmarks", {
  id: serial("id").primaryKey(),
  title: varchar("title", { length: 255 }).notNull(),
  url: text("url").notNull(),
  description: text("description"),
  tags: text("tags").array(),
  isFavorite: boolean("is_favorite").default(false).notNull(),
  clickCount: integer("click_count").default(0).notNull(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
 
export type Bookmark = typeof bookmarks.$inferSelect;
export type NewBookmark = typeof bookmarks.$inferInsert;

This schema defines a bookmarks table with:

  • Auto-incrementing id as primary key
  • title and url as required fields
  • Optional description and tags array
  • A isFavorite boolean for quick filtering
  • A clickCount integer to track popularity
  • Automatic timestamps for createdAt and updatedAt

Step 5: Set Up Drizzle with the Neon Driver

Create the database connection at src/db/index.ts:

import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import * as schema from "./schema";
 
if (!process.env.DATABASE_URL) {
  throw new Error("DATABASE_URL environment variable is not set");
}
 
const sql = neon(process.env.DATABASE_URL);
 
export const db = drizzle(sql, { schema });

This is the key integration point. Instead of using the traditional pg Pool, you use neon() to create an HTTP-based SQL executor, then pass it to Drizzle's neon-http adapter.

Understanding the Connection Flow

Your App → HTTP Request → Neon Proxy (pooler) → Neon Postgres

Every query is a stateless HTTP request. There is no connection to manage, no pool to configure, and no connection limits to worry about. Neon's proxy handles connection pooling on their end.


Step 6: Configure Drizzle Kit for Migrations

Create drizzle.config.ts in the project root:

import { defineConfig } from "drizzle-kit";
import dotenv from "dotenv";
 
dotenv.config({ path: ".env.local" });
 
export default defineConfig({
  schema: "./src/db/schema.ts",
  out: "./drizzle",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DIRECT_DATABASE_URL!,
  },
});

Note that we use DIRECT_DATABASE_URL (not the pooled one) for migrations. Migrations require DDL statements that work better over direct connections.

Add migration scripts to package.json:

{
  "scripts": {
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:push": "drizzle-kit push",
    "db:studio": "drizzle-kit studio"
  }
}

Now push the schema to your Neon database:

npm run db:push

You should see output confirming the bookmarks table was created. You can verify by opening Drizzle Studio:

npm run db:studio

This opens a visual database browser at https://local.drizzle.studio where you can inspect tables and data.


Step 7: Build Server Actions for CRUD Operations

Create src/app/actions.ts for all database operations:

"use server";
 
import { db } from "@/db";
import { bookmarks } from "@/db/schema";
import { eq, desc, sql, ilike, or } from "drizzle-orm";
import { revalidatePath } from "next/cache";
 
export async function getBookmarks(search?: string) {
  if (search) {
    return db
      .select()
      .from(bookmarks)
      .where(
        or(
          ilike(bookmarks.title, `%${search}%`),
          ilike(bookmarks.url, `%${search}%`),
          ilike(bookmarks.description, `%${search}%`)
        )
      )
      .orderBy(desc(bookmarks.createdAt));
  }
 
  return db
    .select()
    .from(bookmarks)
    .orderBy(desc(bookmarks.createdAt));
}
 
export async function createBookmark(formData: FormData) {
  const title = formData.get("title") as string;
  const url = formData.get("url") as string;
  const description = formData.get("description") as string;
  const tags = (formData.get("tags") as string)
    ?.split(",")
    .map((t) => t.trim())
    .filter(Boolean);
 
  await db.insert(bookmarks).values({
    title,
    url,
    description: description || null,
    tags: tags?.length ? tags : null,
  });
 
  revalidatePath("/");
}
 
export async function toggleFavorite(id: number) {
  const [bookmark] = await db
    .select({ isFavorite: bookmarks.isFavorite })
    .from(bookmarks)
    .where(eq(bookmarks.id, id));
 
  if (bookmark) {
    await db
      .update(bookmarks)
      .set({ isFavorite: !bookmark.isFavorite })
      .where(eq(bookmarks.id, id));
  }
 
  revalidatePath("/");
}
 
export async function incrementClickCount(id: number) {
  await db
    .update(bookmarks)
    .set({
      clickCount: sql`${bookmarks.clickCount} + 1`,
    })
    .where(eq(bookmarks.id, id));
}
 
export async function deleteBookmark(id: number) {
  await db.delete(bookmarks).where(eq(bookmarks.id, id));
  revalidatePath("/");
}
 
export async function getStats() {
  const [result] = await db
    .select({
      total: sql<number>`count(*)`,
      favorites: sql<number>`count(*) filter (where ${bookmarks.isFavorite} = true)`,
      totalClicks: sql<number>`coalesce(sum(${bookmarks.clickCount}), 0)`,
    })
    .from(bookmarks);
 
  return result;
}

Each Server Action communicates directly with Neon over HTTP. There is no API route layer — Next.js Server Actions call the database directly from the server, and the Neon driver handles the HTTP transport.


Step 8: Build the UI Components

Bookmark Card Component

Create src/components/bookmark-card.tsx:

"use client";
 
import { Bookmark } from "@/db/schema";
import { toggleFavorite, deleteBookmark, incrementClickCount } from "@/app/actions";
 
export function BookmarkCard({ bookmark }: { bookmark: Bookmark }) {
  const handleClick = async () => {
    await incrementClickCount(bookmark.id);
  };
 
  return (
    <div className="border rounded-lg p-4 hover:shadow-md transition-shadow bg-white">
      <div className="flex items-start justify-between">
        <div className="flex-1 min-w-0">
          <a
            href={bookmark.url}
            target="_blank"
            rel="noopener noreferrer"
            onClick={handleClick}
            className="text-lg font-semibold text-blue-600 hover:text-blue-800 hover:underline truncate block"
          >
            {bookmark.title}
          </a>
          <p className="text-sm text-gray-500 truncate mt-1">{bookmark.url}</p>
        </div>
        <div className="flex gap-2 ml-4">
          <button
            onClick={() => toggleFavorite(bookmark.id)}
            className="text-xl hover:scale-110 transition-transform"
            title={bookmark.isFavorite ? "Remove from favorites" : "Add to favorites"}
          >
            {bookmark.isFavorite ? "\u2605" : "\u2606"}
          </button>
          <button
            onClick={() => deleteBookmark(bookmark.id)}
            className="text-red-400 hover:text-red-600 text-sm"
            title="Delete bookmark"
          >
            Delete
          </button>
        </div>
      </div>
 
      {bookmark.description && (
        <p className="text-gray-600 mt-2 text-sm line-clamp-2">
          {bookmark.description}
        </p>
      )}
 
      <div className="flex items-center justify-between mt-3">
        <div className="flex gap-1 flex-wrap">
          {bookmark.tags?.map((tag) => (
            <span
              key={tag}
              className="px-2 py-0.5 bg-gray-100 text-gray-600 rounded-full text-xs"
            >
              {tag}
            </span>
          ))}
        </div>
        <span className="text-xs text-gray-400">
          {bookmark.clickCount} clicks
        </span>
      </div>
    </div>
  );
}

Add Bookmark Form

Create src/components/add-bookmark-form.tsx:

"use client";
 
import { createBookmark } from "@/app/actions";
import { useRef } from "react";
 
export function AddBookmarkForm() {
  const formRef = useRef<HTMLFormElement>(null);
 
  const handleSubmit = async (formData: FormData) => {
    await createBookmark(formData);
    formRef.current?.reset();
  };
 
  return (
    <form ref={formRef} action={handleSubmit} className="space-y-4 bg-white p-6 rounded-lg border">
      <h2 className="text-lg font-semibold">Add New Bookmark</h2>
 
      <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
        <div>
          <label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
            Title *
          </label>
          <input
            type="text"
            id="title"
            name="title"
            required
            className="w-full px-3 py-2 border rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
            placeholder="My Bookmark"
          />
        </div>
 
        <div>
          <label htmlFor="url" className="block text-sm font-medium text-gray-700 mb-1">
            URL *
          </label>
          <input
            type="url"
            id="url"
            name="url"
            required
            className="w-full px-3 py-2 border rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
            placeholder="https://example.com"
          />
        </div>
      </div>
 
      <div>
        <label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
          Description
        </label>
        <textarea
          id="description"
          name="description"
          rows={2}
          className="w-full px-3 py-2 border rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
          placeholder="A brief description..."
        />
      </div>
 
      <div>
        <label htmlFor="tags" className="block text-sm font-medium text-gray-700 mb-1">
          Tags (comma-separated)
        </label>
        <input
          type="text"
          id="tags"
          name="tags"
          className="w-full px-3 py-2 border rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
          placeholder="react, tutorial, database"
        />
      </div>
 
      <button
        type="submit"
        className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
      >
        Add Bookmark
      </button>
    </form>
  );
}

Step 9: Build the Main Page

Update src/app/page.tsx:

import { getBookmarks, getStats } from "./actions";
import { BookmarkCard } from "@/components/bookmark-card";
import { AddBookmarkForm } from "@/components/add-bookmark-form";
 
export default async function Home({
  searchParams,
}: {
  searchParams: Promise<{ search?: string }>;
}) {
  const { search } = await searchParams;
  const [allBookmarks, stats] = await Promise.all([
    getBookmarks(search),
    getStats(),
  ]);
 
  return (
    <main className="max-w-4xl mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-2">Bookmarks Manager</h1>
      <p className="text-gray-500 mb-8">
        Powered by Neon Serverless Postgres
      </p>
 
      {/* Stats */}
      <div className="grid grid-cols-3 gap-4 mb-8">
        <div className="bg-blue-50 rounded-lg p-4 text-center">
          <div className="text-2xl font-bold text-blue-600">{stats.total}</div>
          <div className="text-sm text-gray-600">Total Bookmarks</div>
        </div>
        <div className="bg-yellow-50 rounded-lg p-4 text-center">
          <div className="text-2xl font-bold text-yellow-600">{stats.favorites}</div>
          <div className="text-sm text-gray-600">Favorites</div>
        </div>
        <div className="bg-green-50 rounded-lg p-4 text-center">
          <div className="text-2xl font-bold text-green-600">{stats.totalClicks}</div>
          <div className="text-sm text-gray-600">Total Clicks</div>
        </div>
      </div>
 
      {/* Add Form */}
      <div className="mb-8">
        <AddBookmarkForm />
      </div>
 
      {/* Search */}
      <form className="mb-6">
        <input
          type="text"
          name="search"
          defaultValue={search}
          placeholder="Search bookmarks..."
          className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
        />
      </form>
 
      {/* Bookmarks List */}
      <div className="space-y-4">
        {allBookmarks.length === 0 ? (
          <div className="text-center py-12 text-gray-400">
            <p className="text-lg">No bookmarks yet</p>
            <p className="text-sm mt-1">Add your first bookmark above</p>
          </div>
        ) : (
          allBookmarks.map((bookmark) => (
            <BookmarkCard key={bookmark.id} bookmark={bookmark} />
          ))
        )}
      </div>
    </main>
  );
}

Run the development server to verify everything works:

npm run dev

Open http://localhost:3000 and you should see the bookmarks manager. Try adding a few bookmarks, marking them as favorites, and searching.


Step 10: Database Branching for Preview Deployments

This is where Neon truly shines. Database branching creates an instant, copy-on-write clone of your database — perfect for preview deployments, testing migrations, or experimenting with data.

How Branching Works

Neon uses a copy-on-write storage layer inspired by Git:

main branch (production data)
  └── preview/feature-xyz (instant copy, isolated changes)
  • Instant creation — branches are created in milliseconds regardless of database size
  • Zero storage overhead — branches share data pages until modified
  • Full isolation — changes on a branch never affect the parent
  • Automatic cleanup — branches can be deleted when the PR is merged

Create a Branch via the Neon API

You can automate branch creation in your CI/CD pipeline. Here is how to create a branch using the Neon API:

// scripts/create-neon-branch.ts
const NEON_API_KEY = process.env.NEON_API_KEY!;
const NEON_PROJECT_ID = process.env.NEON_PROJECT_ID!;
 
async function createBranch(branchName: string) {
  const response = await fetch(
    `https://console.neon.tech/api/v2/projects/${NEON_PROJECT_ID}/branches`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${NEON_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        branch: {
          name: branchName,
          parent_id: undefined, // defaults to main branch
        },
        endpoints: [
          {
            type: "read_write",
          },
        ],
      }),
    }
  );
 
  const data = await response.json();
 
  const endpoint = data.endpoints[0];
  const host = endpoint.host;
  const dbName = "neondb";
  const role = data.roles?.[0]?.name || "neondb_owner";
 
  console.log(`Branch created: ${data.branch.name}`);
  console.log(`Connection: postgres://${role}@${host}/${dbName}?sslmode=require`);
 
  return data;
}
 
// Usage: create a branch named after the PR
const prNumber = process.argv[2];
if (prNumber) {
  createBranch(`preview/pr-${prNumber}`);
}

GitHub Actions Integration

Add this workflow to automatically create a Neon branch for each pull request:

# .github/workflows/preview-branch.yml
name: Create Preview Database
 
on:
  pull_request:
    types: [opened, synchronize]
 
jobs:
  create-branch:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - name: Create Neon Branch
        uses: neondatabase/create-branch-action@v5
        id: create-branch
        with:
          project_id: ${{ secrets.NEON_PROJECT_ID }}
          api_key: ${{ secrets.NEON_API_KEY }}
          branch_name: preview/pr-${{ github.event.pull_request.number }}
 
      - name: Set DATABASE_URL
        run: |
          echo "Preview database URL: ${{ steps.create-branch.outputs.db_url_with_pooler }}"
          # Pass to your deployment (e.g., Vercel preview environment)

When the PR is merged or closed, add a cleanup job:

# .github/workflows/cleanup-branch.yml
name: Cleanup Preview Database
 
on:
  pull_request:
    types: [closed]
 
jobs:
  delete-branch:
    runs-on: ubuntu-latest
    steps:
      - name: Delete Neon Branch
        uses: neondatabase/delete-branch-action@v3
        with:
          project_id: ${{ secrets.NEON_PROJECT_ID }}
          api_key: ${{ secrets.NEON_API_KEY }}
          branch: preview/pr-${{ github.event.pull_request.number }}

This gives you true database preview environments — each PR gets its own database with production data, completely isolated from production.


Step 11: Connection Pooling and Performance

Understanding Neon Connection Modes

Neon offers three connection modes, each suited to different use cases:

1. HTTP (Serverless Driver)

import { neon } from "@neondatabase/serverless";
const sql = neon(process.env.DATABASE_URL);
 
// Each query is an independent HTTP request
const result = await sql`SELECT * FROM bookmarks`;

Best for: serverless functions, edge runtimes, one-shot queries.

2. WebSocket (Pooled)

import { Pool } from "@neondatabase/serverless";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
 
// Uses WebSocket for persistent connection
const { rows } = await pool.query("SELECT * FROM bookmarks");

Best for: long-running Node.js processes, multiple queries per request.

3. Direct TCP

import { Client } from "pg";
const client = new Client(process.env.DIRECT_DATABASE_URL);
await client.connect();

Best for: migrations, admin tasks, local development.

Choosing the Right Mode for Next.js

For a Next.js App Router application:

ContextRecommended Mode
Server ComponentsHTTP (neon serverless driver)
Server ActionsHTTP (neon serverless driver)
Route HandlersHTTP or WebSocket Pool
Middleware (Edge)HTTP only
MigrationsDirect TCP

Our tutorial uses the HTTP mode throughout because it is the most compatible with Next.js App Router patterns — every Server Component render and Server Action invocation is a short-lived request, making HTTP queries ideal.

Performance Tips

1. Use Promise.all for parallel queries:

// Bad: sequential queries (slow)
const bookmarks = await db.select().from(bookmarksTable);
const stats = await db.select().from(statsTable);
 
// Good: parallel queries (fast)
const [bookmarks, stats] = await Promise.all([
  db.select().from(bookmarksTable),
  db.select().from(statsTable),
]);

2. Select only needed columns:

// Bad: fetches all columns
const result = await db.select().from(bookmarks);
 
// Good: fetches only what you need
const result = await db
  .select({
    id: bookmarks.id,
    title: bookmarks.title,
    url: bookmarks.url,
  })
  .from(bookmarks);

3. Use Neon's fetchConnectionCache option:

const sql = neon(process.env.DATABASE_URL, {
  fetchConnectionCache: true,
});

This reuses the underlying fetch connection between queries in the same request, reducing latency by 10-20ms per query.


Step 12: Adding Full-Text Search with Postgres

Since we are using real Postgres, we get full-text search built in — no external search service needed:

// Add to src/db/schema.ts
import { index, pgTable, serial, text, varchar, timestamp, boolean, integer, customType } from "drizzle-orm/pg-core";
 
// Add a tsvector column for search
export const bookmarks = pgTable("bookmarks", {
  id: serial("id").primaryKey(),
  title: varchar("title", { length: 255 }).notNull(),
  url: text("url").notNull(),
  description: text("description"),
  tags: text("tags").array(),
  isFavorite: boolean("is_favorite").default(false).notNull(),
  clickCount: integer("click_count").default(0).notNull(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

Create a migration to add a GIN index for full-text search:

-- drizzle/0001_add_search_index.sql
CREATE INDEX idx_bookmarks_search ON bookmarks
USING GIN (to_tsvector('english', coalesce(title, '') || ' ' || coalesce(description, '')));

Update the search action to use Postgres full-text search:

export async function searchBookmarks(query: string) {
  const results = await db.execute(
    sql`SELECT *, ts_rank(
      to_tsvector('english', coalesce(title, '') || ' ' || coalesce(description, '')),
      plainto_tsquery('english', ${query})
    ) as rank
    FROM bookmarks
    WHERE to_tsvector('english', coalesce(title, '') || ' ' || coalesce(description, ''))
      @@ plainto_tsquery('english', ${query})
    ORDER BY rank DESC`
  );
 
  return results.rows;
}

This gives you relevance-ranked search powered by Postgres — no Algolia, Meilisearch, or Elasticsearch required.


Troubleshooting

Common Issues

"Connection terminated unexpectedly"

This usually happens when using the direct connection in a serverless environment. Switch to the pooled connection URL (with -pooler in the hostname).

"Too many connections"

If you see connection limit errors, ensure you are using the HTTP driver (neon()) instead of the Pool class. The HTTP driver does not hold open connections.

"Endpoint is suspended"

Neon's free tier suspends endpoints after 5 minutes of inactivity. The first query after suspension takes 300-500ms to wake up. For production, upgrade to a paid plan with always-on endpoints.

Migrations failing

Always use DIRECT_DATABASE_URL for running migrations. The pooled connection through PgBouncer does not support DDL statements well.

Type errors with sql template

Make sure you import sql from drizzle-orm, not from @neondatabase/serverless. They are different — Drizzle's sql builds parameterized queries, while Neon's is a tagged template for raw SQL.


Next Steps

Now that you have a working Neon-powered application, here are ways to extend it:

  • Add authentication with Auth.js or Better Auth to make bookmarks per-user
  • Implement row-level security using Postgres RLS policies
  • Set up Neon Autoscaling to handle traffic spikes automatically
  • Create a REST API with Route Handlers for external integrations
  • Add real-time updates using Neon's logical replication with WebSockets
  • Integrate with Vercel for automatic preview deployments with database branches

Conclusion

In this tutorial, you built a full-stack bookmarks manager powered by Neon Serverless Postgres and Next.js 15 App Router. You learned how to:

  • Set up Neon with the serverless HTTP driver for edge-compatible queries
  • Build type-safe database operations with Drizzle ORM
  • Implement CRUD operations using Next.js Server Actions
  • Create database branches for isolated preview environments
  • Optimize performance with connection pooling and parallel queries
  • Add Postgres full-text search without external dependencies

Neon brings the power of PostgreSQL to the serverless world — auto-scaling, instant branching, and pay-per-use pricing make it an excellent choice for modern full-stack applications. Combined with Next.js App Router and Drizzle ORM, you get a development experience that is both productive and production-ready.


Want to read more tutorials? Check out our latest tutorial on Motion for React: Build Production-Grade Animations, Gestures, and Transitions.

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