What We’re Building
A full-stack task management app: Next.js 15 App Router, PostgreSQL via Neon (serverless), Prisma ORM, Server Actions (no separate API routes for mutations), Auth.js v5 GitHub OAuth, deployed to Vercel.
📋 Table of Contents
Project Setup
npx create-next-app@latest taskapp \
--typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd taskapp
Database: PostgreSQL with Neon + Prisma
Create a free database at neon.tech. Then:
npm install prisma @prisma/client
npx prisma init
In .env.local:
DATABASE_URL="postgresql://user:pass@ep-xxx.us-east-2.aws.neon.tech/neondb?sslmode=require"
NEXTAUTH_SECRET="your-random-secret-here"
GITHUB_CLIENT_ID="your-github-oauth-app-id"
GITHUB_CLIENT_SECRET="your-github-oauth-app-secret"
Data Models
// prisma/schema.prisma
model User {
id String @id @default(cuid())
email String @unique
name String?
createdAt DateTime @default(now())
tasks Task[]
}
model Task {
id String @id @default(cuid())
title String
status TaskStatus @default(PENDING)
priority Priority @default(MEDIUM)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
}
enum TaskStatus { PENDING IN_PROGRESS COMPLETED CANCELLED }
enum Priority { LOW MEDIUM HIGH URGENT }
npx prisma migrate dev --name init
npx prisma generate
// src/lib/db.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const db = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
Server Components & Data Fetching
App Router components are Server Components by default — they run on the server and can directly query the database:
// src/app/dashboard/page.tsx (Server Component)
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) redirect('/');
const tasks = await db.task.findMany({
where: { userId: session.user.id, status: { not: 'CANCELLED' } },
orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }],
take: 50,
});
return (
<main className="container mx-auto p-6">
<h1>My Tasks</h1>
{tasks.map(task => <TaskCard key={task.id} task={task} />)}
</main>
);
}
No API route needed. No useEffect. No loading state boilerplate. The component fetches and renders in one shot.
Server Actions for Mutations
Server Actions replace API routes for form submissions and mutations:
// src/actions/tasks.ts
'use server';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
const Schema = z.object({
title: z.string().min(1).max(200),
priority: z.enum(['LOW','MEDIUM','HIGH','URGENT']).default('MEDIUM'),
});
export async function createTask(formData: FormData) {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
const parsed = Schema.safeParse({
title: formData.get('title'),
priority: formData.get('priority') || 'MEDIUM',
});
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors };
await db.task.create({ data: { ...parsed.data, userId: session.user.id } });
revalidatePath('/dashboard');
return { success: true };
}
The revalidatePath call invalidates Next.js cache for that route, triggering a fresh server render with new data.
Authentication with Auth.js v5
npm install next-auth@beta @auth/prisma-adapter
// src/lib/auth.ts
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { db } from './db';
export const { auth, handlers, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(db),
providers: [GitHub({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET! })],
session: { strategy: 'database' },
callbacks: { session: ({ session, user }) => ({ ...session, user: { ...session.user, id: user.id } }) }
});
Add the route handler: src/app/api/auth/[...nextauth]/route.ts → export handlers as GET, handlers as POST.
Deploying to Vercel
# Install Vercel CLI and deploy
npm install -g vercel
vercel
# Set environment variables
vercel env add DATABASE_URL
vercel env add NEXTAUTH_SECRET
vercel env add GITHUB_CLIENT_ID
vercel env add GITHUB_CLIENT_SECRET
vercel --prod
After deployment: update GitHub OAuth callback URL to https://your-app.vercel.app/api/auth/callback/github, then run npx prisma migrate deploy.
Production Checklist
- Rate limiting — Upstash Ratelimit on Server Actions and API routes
- Error boundaries — add
error.tsxfiles for graceful fallbacks - Loading states — add
loading.tsxfor Suspense skeletons - Input validation — always use Zod on Server Actions
- Connection pooling — add
?pgbouncer=true&connection_limit=1to Neon URL - Security headers — add via
next.config.tsheaders()
🔧 Ready to Ship?
Next.js 15 + PostgreSQL is the dominant full-stack stack in 2026. Explore why TypeScript is essential for larger Next.js apps and master async/await patterns for Server Components and Actions.
Frequently Asked Questions
What is Next.js 15?
Latest Next.js: stable Turbopack (5× faster), React 19 required, async request APIs, improved Server Actions. App Router is the standard going forward.
App Router or Pages Router in 2026?
App Router for all new projects. Pages Router works but gets no new features.
What database to use with Next.js?
PostgreSQL via Neon (serverless) + Prisma or Drizzle ORM. Supabase if you need built-in auth and real-time.
What are Server Actions?
Async server functions (marked ‘use server’) called from React. Replace API routes for mutations. Type-safe, support optimistic updates.
How to deploy?
Vercel (zero config, made by Next.js team). Self-host: next build + next start behind nginx, or Docker.
📚 You might also like
🔗 Share this article




✍️ Leave a Comment