Back to articles
Next.js

Next.js 15: Complete Step-by-Step Guide from Setup to Production

Build a complete task management app with Next.js 15, React 19 hooks, Turbopack, authentication, database, and deploy to production. Follow along from zero to live application.

25 min read
Next.jsReact 19TurbopackFull-StackProduction

Next.js 15 represents a revolutionary leap forward in web development. In this comprehensive guide, we'll build a complete task management application from scratch, taking you through every step from initial setup to production deployment. You'll learn React 19 hooks, Turbopack optimization, authentication, database integration, and modern deployment strategies.

What We'll Build: TaskFlow - A Complete Task Management App

Our production-ready application will include user authentication, real-time task management, optimistic UI updates, advanced caching, and deployment to Vercel. By the end of this guide, you'll have a fully functional app and deep understanding of Next.js 15.

User Authentication

Secure login/signup with better-auth and session management

Real-time Updates

Live task updates using Server-Sent Events and WebSockets

Optimistic UI

Instant feedback with React 19 useOptimistic hook

Production Deployment

Complete CI/CD pipeline with Vercel and database hosting

Phase 1: Project Setup and Installation

Let's start by creating our Next.js 15 project with all necessary dependencies. We'll configure Turbopack for lightning-fast development and set up our development environment.

Step 1: Create Next.js 15 Project

Terminal - Project Creation
# Create new Next.js 15 project with all modern featuresnpx create-next-app@latest taskflow-app \  --typescript \  --tailwind \  --eslint \  --app \  --src-dir \  --import-alias "@/*"# Navigate to project directorycd taskflow-app# Verify Next.js 15 installationnpm list next# Should show: next@15.x.x

Step 2: Install Dependencies

Terminal - Dependencies Installation
# Core dependencies for our full-stack appnpm install \  better-auth \  @better-fetch/fetch \  drizzle-orm \  postgres \  @neondatabase/serverless \  @paralleldrive/cuid2 \  bcryptjs \  zod \  react-hook-form \  @hookform/resolvers \  framer-motion \  lucide-react \  @radix-ui/react-dialog \  @radix-ui/react-dropdown-menu \  @radix-ui/react-toast \  @radix-ui/react-select \  date-fns \  @tanstack/react-query# Development dependenciesnpm install -D \  drizzle-kit \  @types/bcryptjs \  @types/node \  tsx \  @next/bundle-analyzer

Step 3: Configure Next.js 15 with Turbopack

Configure your project to leverage Next.js 15's performance improvements and new features:

Next.jsnext.config.ts
import type { NextConfig } from 'next';const nextConfig: NextConfig = {  // Enable Turbopack for development (10x faster than Webpack)  experimental: {    turbo: {      resolveAlias: {        '@': './src',        '@/components': './src/components',        '@/lib': './src/lib',        '@/app': './src/app',        '@/types': './src/types',      },      // CSS optimization      optimizeCss: true,      // Tree shaking for smaller bundles      treeShaking: true,      // Module imports optimization      modularizeImports: {        'lucide-react': {          transform: 'lucide-react/dist/esm/icons/{{member}}',        },        '@radix-ui/react-icons': {          transform: '@radix-ui/react-icons/dist/{{member}}.js',        },      },    },  },    // TypeScript configuration  typescript: {    tsconfigPath: './tsconfig.json',    ignoreBuildErrors: false,  },    // Image optimization for better performance  images: {    formats: ['image/avif', 'image/webp'],    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048],    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],  },    // Environment variables  env: {    BETTER_AUTH_URL: process.env.BETTER_AUTH_URL,    BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET,  },};export default nextConfig;
package.json - Updated Scripts
{  "scripts": {    "dev": "next dev --turbo",    "dev:webpack": "next dev",    "build": "next build",    "start": "next start",    "lint": "next lint",    "type-check": "tsc --noEmit",    "analyze": "ANALYZE=true npm run build",    "db:generate": "drizzle-kit generate",    "db:push": "drizzle-kit push",    "db:studio": "drizzle-kit studio",    "db:migrate": "drizzle-kit migrate"  }}

Phase 2: Database Setup with Drizzle ORM

Set up a PostgreSQL database with Drizzle ORM for our task management system. We'll create schemas for users, tasks, and sessions using Drizzle's type-safe approach.

Step 4: Initialize Database

Terminal - Database Setup
# Create database directoriesmkdir -p src/dbmkdir -p drizzle# Initialize Drizzle configurationtouch drizzle.config.tstouch src/db/schema.tstouch src/db/index.ts# Create environment filetouch .env.local
TypeScriptdrizzle.config.ts
import { defineConfig } from "drizzle-kit";export default defineConfig({  schema: "./src/db/schema.ts",  out: "./drizzle",  dialect: "postgresql",  dbCredentials: {    url: process.env.DATABASE_URL!,  },  verbose: true,  strict: true,});
TypeScriptsrc/db/schema.ts
import { pgTable, text, timestamp, boolean, pgEnum, index, uniqueIndex } from "drizzle-orm/pg-core";import { createId } from "@paralleldrive/cuid2";// Enumsexport const priorityEnum = pgEnum("priority", ["LOW", "MEDIUM", "HIGH", "URGENT"]);export const statusEnum = pgEnum("status", ["TODO", "IN_PROGRESS", "REVIEW", "COMPLETED"]);// Users tableexport const users = pgTable("users", {  id: text("id").primaryKey().$defaultFn(() => createId()),  email: text("email").notNull().unique(),  name: text("name"),  password: text("password"),  avatar: text("avatar"),  emailVerified: timestamp("email_verified"),  createdAt: timestamp("created_at").defaultNow().notNull(),  updatedAt: timestamp("updated_at").defaultNow().notNull(),}, (table) => ({  emailIdx: uniqueIndex("users_email_idx").on(table.email),}));// Sessions table for better-authexport const sessions = pgTable("sessions", {  id: text("id").primaryKey().$defaultFn(() => createId()),  sessionToken: text("session_token").notNull().unique(),  userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),  expires: timestamp("expires").notNull(),  createdAt: timestamp("created_at").defaultNow().notNull(),}, (table) => ({  sessionTokenIdx: uniqueIndex("sessions_token_idx").on(table.sessionToken),  userIdIdx: index("sessions_user_id_idx").on(table.userId),}));// Accounts table for OAuth providersexport const accounts = pgTable("accounts", {  id: text("id").primaryKey().$defaultFn(() => createId()),  userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),  type: text("type").notNull(),  provider: text("provider").notNull(),  providerAccountId: text("provider_account_id").notNull(),  refreshToken: text("refresh_token"),  accessToken: text("access_token"),  expiresAt: timestamp("expires_at"),  tokenType: text("token_type"),  scope: text("scope"),  idToken: text("id_token"),  sessionState: text("session_state"),  createdAt: timestamp("created_at").defaultNow().notNull(),}, (table) => ({  providerAccountIdx: uniqueIndex("accounts_provider_account_idx").on(table.provider, table.providerAccountId),  userIdIdx: index("accounts_user_id_idx").on(table.userId),}));// Categories tableexport const categories = pgTable("categories", {  id: text("id").primaryKey().$defaultFn(() => createId()),  name: text("name").notNull(),  color: text("color").notNull().default("#3B82F6"),  createdAt: timestamp("created_at").defaultNow().notNull(),});// Tasks tableexport const tasks = pgTable("tasks", {  id: text("id").primaryKey().$defaultFn(() => createId()),  title: text("title").notNull(),  description: text("description"),  completed: boolean("completed").notNull().default(false),  priority: priorityEnum("priority").notNull().default("MEDIUM"),  status: statusEnum("status").notNull().default("TODO"),  dueDate: timestamp("due_date"),  createdAt: timestamp("created_at").defaultNow().notNull(),  updatedAt: timestamp("updated_at").defaultNow().notNull(),  userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),  categoryId: text("category_id").references(() => categories.id),}, (table) => ({  userIdIdx: index("tasks_user_id_idx").on(table.userId),  completedIdx: index("tasks_completed_idx").on(table.completed),  priorityIdx: index("tasks_priority_idx").on(table.priority),  categoryIdIdx: index("tasks_category_id_idx").on(table.categoryId),}));// Type exports for TypeScriptexport type User = typeof users.$inferSelect;export type NewUser = typeof users.$inferInsert;export type Session = typeof sessions.$inferSelect;export type NewSession = typeof sessions.$inferInsert;export type Account = typeof accounts.$inferSelect;export type NewAccount = typeof accounts.$inferInsert;export type Task = typeof tasks.$inferSelect;export type NewTask = typeof tasks.$inferInsert;export type Category = typeof categories.$inferSelect;export type NewCategory = typeof categories.$inferInsert;
TypeScriptsrc/db/index.ts
import { drizzle } from "drizzle-orm/neon-serverless";import { Pool } from "@neondatabase/serverless";import * as schema from "./schema";// Create connection poolconst pool = new Pool({ connectionString: process.env.DATABASE_URL });// Create Drizzle instanceexport const db = drizzle(pool, { schema });// Export schema for easier importsexport { schema };export * from "./schema";

Step 5: Environment Configuration

.env.local
# Database (for development, use local PostgreSQL or Docker)DATABASE_URL="postgresql://username:password@localhost:5432/taskflow"# For production, use Neon, Vercel Postgres or your preferred provider# DATABASE_URL="postgres://username:password@host:5432/database"# better-auth configurationBETTER_AUTH_SECRET="your-super-secret-key-here-change-this"BETTER_AUTH_URL="http://localhost:3000"# Optional: OAuth providers (Google, GitHub, etc.)GOOGLE_CLIENT_ID="your-google-client-id"GOOGLE_CLIENT_SECRET="your-google-client-secret"GITHUB_CLIENT_ID="your-github-client-id"GITHUB_CLIENT_SECRET="your-github-client-secret"
Terminal - Database Migration
# Generate migration filesnpx drizzle-kit generate# Push schema to database (creates tables)npx drizzle-kit push# Optional: Open Drizzle Studio to view datanpx drizzle-kit studio

Phase 3: Authentication with better-auth

Implement secure authentication with email/password and OAuth providers using better-auth, a modern TypeScript-first authentication library.

Step 6: Configure better-auth

TypeScriptsrc/lib/auth.ts
import { betterAuth } from "better-auth";import { drizzleAdapter } from "better-auth/adapters/drizzle";import { db } from "@/db";import { users, sessions, accounts } from "@/db/schema";export const auth = betterAuth({  database: drizzleAdapter(db, {    provider: "pg",    schema: {      users,      sessions,      accounts,    },  }),    emailAndPassword: {    enabled: true,    requireEmailVerification: false, // Set to true in production  },    socialProviders: {    google: {      clientId: process.env.GOOGLE_CLIENT_ID!,      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,    },    github: {      clientId: process.env.GITHUB_CLIENT_ID!,      clientSecret: process.env.GITHUB_CLIENT_SECRET!,    },  },    session: {    expiresIn: 60 * 60 * 24 * 7, // 7 days    updateAge: 60 * 60 * 24, // 24 hours  },    baseURL: process.env.BETTER_AUTH_URL,  secret: process.env.BETTER_AUTH_SECRET,    logger: {    level: process.env.NODE_ENV === "development" ? "debug" : "error",  },});export type Session = typeof auth.$Infer.Session;export type User = typeof auth.$Infer.User;
TypeScriptsrc/lib/auth-client.ts
import { createAuthClient } from "better-auth/react";export const authClient = createAuthClient({  baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL || "http://localhost:3000",});export const { signIn, signOut, signUp, useSession } = authClient;

Step 7: Create API Routes

TypeScriptsrc/app/api/auth/[...all]/route.ts
import { auth } from "@/lib/auth";export const { GET, POST } = auth.handler;

Note: With better-auth, user registration is handled automatically through the authentication system. You can customize the registration flow by using better-auth's built-in signup functionality or create custom registration logic if needed.

Phase 4: Building the UI with React 19 Hooks

Create a modern, responsive UI using React 19 hooks, Tailwind CSS, and Radix UI components. We'll implement the new useActionState, useFormStatus, and useOptimistic hooks.

Step 8: Create Layout and Providers

Reactsrc/app/layout.tsx
import './globals.css'import type { Metadata } from 'next'import { Inter } from 'next/font/google'import { QueryProvider } from '@/components/providers/query-provider'import { ThemeProvider } from '@/components/providers/theme-provider'import { Toaster } from '@/components/ui/toaster'const inter = Inter({ subsets: ['latin'] })export const metadata: Metadata = {  title: 'TaskFlow - Modern Task Management',  description: 'A modern task management application built with Next.js 15',}export default function RootLayout({  children,}: {  children: React.ReactNode}) {  return (    <html lang="en" suppressHydrationWarning>      <body className={inter.className}>        <QueryProvider>          <ThemeProvider            attribute="class"            defaultTheme="system"            enableSystem            disableTransitionOnChange          >            {children}            <Toaster />          </ThemeProvider>        </QueryProvider>      </body>    </html>  )}

Step 9: Implement React 19 useActionState Hook

Create a task creation form using the new useActionState hook for seamless form handling:

Reactsrc/components/task-form.tsx
'use client'import { useActionState } from 'react'import { useFormStatus } from 'react-dom'import { createTask } from '@/app/actions/tasks'import { Button } from '@/components/ui/button'import { Input } from '@/components/ui/input'import { Textarea } from '@/components/ui/textarea'import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'import { Loader2, Plus } from 'lucide-react'function SubmitButton() {  const { pending } = useFormStatus()    return (    <Button type="submit" disabled={pending} className="w-full">      {pending ? (        <>          <Loader2 className="w-4 h-4 mr-2 animate-spin" />          Creating task...        </>      ) : (        <>          <Plus className="w-4 h-4 mr-2" />          Create Task        </>      )}    </Button>  )}export function TaskForm() {  const [state, formAction] = useActionState(createTask, {    message: '',    errors: {},  })  return (    <form action={formAction} className="space-y-4">      <div>        <Input          name="title"          placeholder="Task title"          required          className="w-full"        />        {state.errors?.title && (          <p className="text-sm text-red-600 mt-1">{state.errors.title}</p>        )}      </div>      <div>        <Textarea          name="description"          placeholder="Task description (optional)"          className="w-full"        />        {state.errors?.description && (          <p className="text-sm text-red-600 mt-1">{state.errors.description}</p>        )}      </div>      <div className="grid grid-cols-2 gap-4">        <div>          <Select name="priority" defaultValue="MEDIUM">            <SelectTrigger>              <SelectValue placeholder="Priority" />            </SelectTrigger>            <SelectContent>              <SelectItem value="LOW">Low</SelectItem>              <SelectItem value="MEDIUM">Medium</SelectItem>              <SelectItem value="HIGH">High</SelectItem>              <SelectItem value="URGENT">Urgent</SelectItem>            </SelectContent>          </Select>        </div>        <div>          <Input            name="dueDate"            type="date"            className="w-full"          />        </div>      </div>      <SubmitButton />      {state.message && (        <div className="p-3 bg-green-50 border border-green-200 rounded-md">          <p className="text-green-800 text-sm">{state.message}</p>        </div>      )}      {state.errors?._form && (        <div className="p-3 bg-red-50 border border-red-200 rounded-md">          <p className="text-red-800 text-sm">{state.errors._form}</p>        </div>      )}    </form>  )}

Step 10: Server Actions with Enhanced Error Handling

TypeScriptsrc/app/actions/tasks.ts
'use server'import { z } from 'zod'import { revalidatePath } from 'next/cache'import { auth } from '@/lib/auth'import { db } from '@/db'import { tasks } from '@/db/schema'import { eq, and } from 'drizzle-orm'import { headers } from 'next/headers'const createTaskSchema = z.object({  title: z.string().min(1, 'Title is required').max(100, 'Title too long'),  description: z.string().max(500, 'Description too long').optional(),  priority: z.enum(['LOW', 'MEDIUM', 'HIGH', 'URGENT']).default('MEDIUM'),  dueDate: z.string().optional(),})export async function createTask(prevState: any, formData: FormData) {  const session = await auth.api.getSession({    headers: await headers(),  })    if (!session?.user?.id) {    return {      errors: { _form: 'You must be logged in to create tasks' },      message: '',    }  }  const validatedFields = createTaskSchema.safeParse({    title: formData.get('title'),    description: formData.get('description'),    priority: formData.get('priority'),    dueDate: formData.get('dueDate'),  })  if (!validatedFields.success) {    return {      errors: validatedFields.error.flatten().fieldErrors,      message: '',    }  }  const { title, description, priority, dueDate } = validatedFields.data  try {    await db.insert(tasks).values({      title,      description: description || null,      priority,      dueDate: dueDate ? new Date(dueDate) : null,      userId: session.user.id,    })    revalidatePath('/dashboard')        return {      message: 'Task created successfully!',      errors: {},    }  } catch (error) {    console.error('Failed to create task:', error)    return {      errors: { _form: 'Failed to create task. Please try again.' },      message: '',    }  }}export async function toggleTask(taskId: string) {  const session = await auth.api.getSession({    headers: await headers(),  })    if (!session?.user?.id) {    throw new Error('Unauthorized')  }  try {    const task = await db.select({       userId: tasks.userId,       completed: tasks.completed     })    .from(tasks)    .where(eq(tasks.id, taskId))    .limit(1)    if (!task[0] || task[0].userId !== session.user.id) {      throw new Error('Task not found or unauthorized')    }    await db.update(tasks)      .set({         completed: !task[0].completed,        updatedAt: new Date()      })      .where(eq(tasks.id, taskId))    revalidatePath('/dashboard')  } catch (error) {    console.error('Failed to toggle task:', error)    throw error  }}export async function deleteTask(taskId: string) {  const session = await auth.api.getSession({    headers: await headers(),  })    if (!session?.user?.id) {    throw new Error('Unauthorized')  }  try {    const task = await db.select({ userId: tasks.userId })      .from(tasks)      .where(eq(tasks.id, taskId))      .limit(1)    if (!task[0] || task[0].userId !== session.user.id) {      throw new Error('Task not found or unauthorized')    }    await db.delete(tasks).where(eq(tasks.id, taskId))    revalidatePath('/dashboard')  } catch (error) {    console.error('Failed to delete task:', error)    throw error  }}

Step 11: Optimistic UI with useOptimistic Hook

Implement instant UI updates using React 19's useOptimistic hook:

Reactsrc/components/task-list.tsx
'use client'import { useOptimistic, useTransition } from 'react'import { Task } from '@/db/schema'import { toggleTask, deleteTask } from '@/app/actions/tasks'import { Button } from '@/components/ui/button'import { Checkbox } from '@/components/ui/checkbox'import { Trash2, Calendar, Flag } from 'lucide-react'import { format } from 'date-fns'interface TaskListProps {  initialTasks: Task[]}type OptimisticAction =   | { type: 'toggle'; id: string }  | { type: 'delete'; id: string }export function TaskList({ initialTasks }: TaskListProps) {  const [isPending, startTransition] = useTransition()    const [optimisticTasks, addOptimisticTask] = useOptimistic(    initialTasks,    (state, action: OptimisticAction) => {      switch (action.type) {        case 'toggle':          return state.map(task =>            task.id === action.id               ? { ...task, completed: !task.completed }              : task          )        case 'delete':          return state.filter(task => task.id !== action.id)        default:          return state      }    }  )  const handleToggle = (taskId: string) => {    startTransition(() => {      addOptimisticTask({ type: 'toggle', id: taskId })      toggleTask(taskId)    })  }  const handleDelete = (taskId: string) => {    startTransition(() => {      addOptimisticTask({ type: 'delete', id: taskId })      deleteTask(taskId)    })  }  const getPriorityColor = (priority: string) => {    switch (priority) {      case 'URGENT': return 'text-red-600'      case 'HIGH': return 'text-orange-600'      case 'MEDIUM': return 'text-yellow-600'      case 'LOW': return 'text-green-600'      default: return 'text-gray-600'    }  }  const getPriorityBorder = (priority: string) => {    switch (priority) {      case 'URGENT': return 'border-l-red-500'      case 'HIGH': return 'border-l-orange-500'      case 'MEDIUM': return 'border-l-yellow-500'      case 'LOW': return 'border-l-green-500'      default: return 'border-l-gray-500'    }  }  if (optimisticTasks.length === 0) {    return (      <div className="text-center py-12 text-gray-500">        <p className="text-lg">No tasks yet!</p>        <p className="text-sm">Create your first task to get started.</p>      </div>    )  }  return (    <div className="space-y-3">      {optimisticTasks.map((task) => (        <div          key={task.id}          className={`relative p-4 bg-white dark:bg-gray-800 rounded-lg border-l-4 shadow-sm transition-all duration-200 ${            getPriorityBorder(task.priority)          } ${            task.completed ? 'opacity-75' : ''          } ${            isPending ? 'opacity-50 pointer-events-none' : ''          }`}        >          <div className="flex items-start gap-3">            <Checkbox              checked={task.completed}              onCheckedChange={() => handleToggle(task.id)}              className="mt-1"            />                        <div className="flex-1 min-w-0">              <h3 className={`font-medium ${                task.completed                   ? 'line-through text-gray-500 dark:text-gray-400'                   : 'text-gray-900 dark:text-white'              }`}>                {task.title}              </h3>                            {task.description && (                <p className={`text-sm mt-1 ${                  task.completed                     ? 'line-through text-gray-400 dark:text-gray-500'                     : 'text-gray-600 dark:text-gray-300'                }`}>                  {task.description}                </p>              )}                            <div className="flex items-center gap-4 mt-2 text-xs text-gray-500 dark:text-gray-400">                <div className={`flex items-center gap-1 ${getPriorityColor(task.priority)}`}>                  <Flag className="w-3 h-3" />                  <span className="capitalize">{task.priority.toLowerCase()}</span>                </div>                                {task.dueDate && (                  <div className="flex items-center gap-1">                    <Calendar className="w-3 h-3" />                    <span>{format(new Date(task.dueDate), 'MMM d, yyyy')}</span>                  </div>                )}                                <span>                  Created {format(new Date(task.createdAt), 'MMM d')}                </span>              </div>            </div>                        <Button              variant="ghost"              size="sm"              onClick={() => handleDelete(task.id)}              className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20"            >              <Trash2 className="w-4 h-4" />            </Button>          </div>        </div>      ))}            {isPending && (        <div className="text-center py-2 text-sm text-gray-500">          Saving changes...        </div>      )}    </div>  )}

Phase 5: Production Deployment

Deploy your application to production with Vercel, set up a production database, and configure environment variables for a live application.

Step 12: Prepare for Production

Terminal - Build Optimization
# Type check the entire projectnpm run type-check# Lint and fix issuesnpm run lint# Test production build locallynpm run buildnpm run start# Analyze bundle sizenpm run analyze

Step 13: Deploy to Vercel

Terminal - Vercel Deployment
# Install Vercel CLInpm i -g vercel# Login to Vercelvercel login# Deploy to productionvercel --prod# Or connect GitHub repository for automatic deployments# 1. Push code to GitHub# 2. Import project in Vercel dashboard# 3. Configure environment variables# 4. Deploy automatically on every push

Step 14: Production Database Setup

Set up a production PostgreSQL database with Vercel Postgres or your preferred provider:

Terminal - Vercel Postgres
# Create Vercel Postgres databasevercel storage create postgres --type postgres# Get connection stringvercel env pull .env.production# Run database migration in productionnpx prisma db push --schema=./prisma/schema.prisma

Step 15: Environment Variables for Production

🔒

Production Environment Variables

DATABASE_URL - Production PostgreSQL connection string
BETTER_AUTH_SECRET - Strong secret for JWT signing (use: openssl rand -base64 32)
BETTER_AUTH_URL - Your production domain (https://yourdomain.com)
GOOGLE_CLIENT_ID & GOOGLE_CLIENT_SECRET - OAuth credentials
GITHUB_CLIENT_ID & GITHUB_CLIENT_SECRET - OAuth credentials
NODE_ENV=production - Ensure production optimizations
Vercel Environment Variables
# Set environment variables in Vercel dashboard or CLIvercel env add DATABASE_URLvercel env add BETTER_AUTH_SECRETvercel env add BETTER_AUTH_URLvercel env add GOOGLE_CLIENT_IDvercel env add GOOGLE_CLIENT_SECRETvercel env add GITHUB_CLIENT_IDvercel env add GITHUB_CLIENT_SECRET# Deploy with new environment variablesvercel --prod

Performance Optimizations and Best Practices

Implement advanced performance optimizations and monitoring for your production application:

Caching Strategy

Implement intelligent caching with Next.js 15 fetch configurations

Error Monitoring

Set up error tracking and performance monitoring

SEO Optimization

Configure metadata, sitemaps, and search engine optimization

Security Hardening

Implement security headers, CSRF protection, and rate limiting

src/lib/cache.ts - Advanced Caching
// Next.js 15 explicit caching strategiesexport async function getTasks(userId: string) {  const response = await fetch(`${process.env.NEXTAUTH_URL}/api/tasks?userId=${userId}`, {    // Cache for 5 minutes with background revalidation    next: {       revalidate: 300,      tags: [`tasks-${userId}`]    },    cache: 'force-cache'  })    return response.json()}// Cache invalidation on mutationsimport { revalidateTag } from 'next/cache'export async function invalidateUserTasks(userId: string) {  revalidateTag(`tasks-${userId}`)}

Congratulations! You've built a complete, production-ready task management application with Next.js 15. Your app includes modern React 19 hooks, optimistic UI updates, secure authentication, database integration, and is deployed to production with proper caching and performance optimizations.

Next Steps and Advanced Features

Take your application further with these advanced features:

  • Real-time collaboration with WebSockets or Server-Sent Events
  • Advanced filtering and search with full-text search
  • File attachments and image uploads with Vercel Blob
  • Mobile app with React Native and shared API
  • Team collaboration features with role-based permissions
  • Advanced analytics and user behavior tracking
  • Progressive Web App (PWA) capabilities
  • Internationalization (i18n) for global users

You now have a solid foundation in Next.js 15 and modern React development. The patterns and techniques you've learned can be applied to any full-stack application. Keep building, keep learning, and keep pushing the boundaries of what's possible with modern web development!