Next.js
Building a Full Next.js Application - Part 1
Building a Full Next.js Application - Part 1
In this comprehensive two-part lesson series, we'll build a complete, production-ready Next.js application from scratch. Part 1 focuses on project planning, initial setup, authentication, database design, and core features implementation.
Project Overview: TaskFlow - Project Management Application
We'll build TaskFlow, a modern project management application with the following features:
- Authentication: User registration, login, and session management
- Projects: Create, manage, and archive projects
- Tasks: Task creation, assignment, status tracking, and priorities
- Teams: Team collaboration and member management
- Comments: Real-time task comments and discussions
- Dashboard: Analytics and project overview
- Notifications: Real-time updates for team activities
Step 1: Project Planning & Architecture
Technology Stack:
- Framework: Next.js 15 with App Router
- Language: TypeScript
- Database: PostgreSQL with Prisma ORM
- Authentication: NextAuth.js v5 (Auth.js)
- Styling: Tailwind CSS + shadcn/ui
- State Management: Zustand for client state
- Real-time: Pusher for live updates
- Deployment: Vercel
Project structure:
taskflow/
├── app/
│ ├── (auth)/
│ │ ├── login/
│ │ └── register/
│ ├── (dashboard)/
│ │ ├── dashboard/
│ │ ├── projects/
│ │ │ ├── [id]/
│ │ │ └── new/
│ │ ├── tasks/
│ │ └── teams/
│ ├── api/
│ │ ├── auth/
│ │ ├── projects/
│ │ ├── tasks/
│ │ └── notifications/
│ └── layout.tsx
├── components/
│ ├── ui/ # shadcn components
│ ├── layout/ # Layout components
│ ├── projects/ # Project-specific components
│ └── tasks/ # Task-specific components
├── lib/
│ ├── auth.ts # Auth configuration
│ ├── db.ts # Prisma client
│ ├── utils.ts # Utility functions
│ └── validations/ # Zod schemas
├── prisma/
│ └── schema.prisma
└── types/
└── index.ts
Step 2: Initial Project Setup
Create Next.js project:
npx create-next-app@latest taskflow --typescript --tailwind --app --use-npm cd taskflow
Install dependencies:
# Core dependencies npm install prisma @prisma/client npm install next-auth@beta npm install zod react-hook-form @hookform/resolvers npm install zustand npm install pusher pusher-js npm install date-fns npm install lucide-react # UI components npx shadcn-ui@latest init npx shadcn-ui@latest add button input label card dialog dropdown-menu
Environment variables (.env.local):
# Database DATABASE_URL="postgresql://user:password@localhost:5432/taskflow" # Auth NEXTAUTH_URL="http://localhost:3000" NEXTAUTH_SECRET="your-secret-key-here" # OAuth (optional) GOOGLE_CLIENT_ID="your-google-client-id" GOOGLE_CLIENT_SECRET="your-google-client-secret" # Pusher NEXT_PUBLIC_PUSHER_KEY="your-pusher-key" PUSHER_SECRET="your-pusher-secret" PUSHER_APP_ID="your-app-id" NEXT_PUBLIC_PUSHER_CLUSTER="mt1"
Step 3: Database Schema Design
prisma/schema.prisma:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
password String?
role Role @default(USER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
projects ProjectMember[]
tasks Task[]
comments Comment[]
notifications Notification[]
}
enum Role {
USER
ADMIN
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
model Project {
id String @id @default(cuid())
name String
description String?
slug String @unique
color String @default("#3B82F6")
archived Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members ProjectMember[]
tasks Task[]
}
model ProjectMember {
id String @id @default(cuid())
role MemberRole @default(MEMBER)
joinedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String
@@unique([userId, projectId])
}
enum MemberRole {
OWNER
ADMIN
MEMBER
}
model Task {
id String @id @default(cuid())
title String
description String?
status TaskStatus @default(TODO)
priority TaskPriority @default(MEDIUM)
dueDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String
assignee User? @relation(fields: [assigneeId], references: [id])
assigneeId String?
comments Comment[]
}
enum TaskStatus {
TODO
IN_PROGRESS
IN_REVIEW
DONE
}
enum TaskPriority {
LOW
MEDIUM
HIGH
URGENT
}
model Comment {
id String @id @default(cuid())
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
taskId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId String
}
model Notification {
id String @id @default(cuid())
type String
title String
message String
read Boolean @default(false)
link String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
}
Initialize database:
npx prisma generate npx prisma db push
Step 4: Authentication Setup
lib/auth.ts:
import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import Google from 'next-auth/providers/google';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/lib/db';
import bcrypt from 'bcryptjs';
import { z } from 'zod';
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
});
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: 'jwt' },
pages: {
signIn: '/login',
error: '/login',
},
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
Credentials({
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
const validated = loginSchema.safeParse(credentials);
if (!validated.success) {
return null;
}
const { email, password } = validated.data;
const user = await prisma.user.findUnique({
where: { email },
});
if (!user || !user.password) {
return null;
}
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
};
},
}),
],
callbacks: {
async session({ session, token }) {
if (token.sub) {
session.user.id = token.sub;
}
return session;
},
async jwt({ token, user }) {
if (user) {
token.sub = user.id;
}
return token;
},
},
});
app/api/auth/[...nextauth]/route.ts:
import { handlers } from '@/lib/auth';
export const { GET, POST } = handlers;
app/(auth)/login/page.tsx:
'use client';
import { signIn } from 'next-auth/react';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card } from '@/components/ui/card';
export default function LoginPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError('');
const formData = new FormData(e.currentTarget);
const email = formData.get('email') as string;
const password = formData.get('password') as string;
try {
const result = await signIn('credentials', {
email,
password,
redirect: false,
});
if (result?.error) {
setError('Invalid email or password');
} else {
router.push('/dashboard');
}
} catch (error) {
setError('An error occurred');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center">
<Card className="w-full max-w-md p-6">
<h1 className="text-2xl font-bold mb-6">Sign In to TaskFlow</h1>
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded mb-4">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
required
autoComplete="email"
/>
</div>
<div>
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
required
autoComplete="current-password"
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Signing in...' : 'Sign In'}
</Button>
</form>
<div className="mt-4">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-gray-500">
Or continue with
</span>
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full mt-4"
onClick={() => signIn('google', { callbackUrl: '/dashboard' })}
>
Continue with Google
</Button>
</div>
</Card>
</div>
);
}
Step 5: Core Data Models & API Routes
lib/validations/project.ts:
import { z } from 'zod';
export const createProjectSchema = z.object({
name: z.string().min(1, 'Project name is required').max(100),
description: z.string().optional(),
color: z.string().regex(/^#[0-9A-F]{6}$/i).optional(),
});
export const updateProjectSchema = createProjectSchema.partial();
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
export type UpdateProjectInput = z.infer<typeof updateProjectSchema>;
app/api/projects/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { prisma } from '@/lib/db';
import { createProjectSchema } from '@/lib/validations/project';
export async function GET(request: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const projects = await prisma.project.findMany({
where: {
members: {
some: {
userId: session.user.id,
},
},
archived: false,
},
include: {
members: {
include: {
user: {
select: {
id: true,
name: true,
image: true,
},
},
},
},
_count: {
select: {
tasks: true,
},
},
},
orderBy: {
updatedAt: 'desc',
},
});
return NextResponse.json(projects);
}
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const validated = createProjectSchema.safeParse(body);
if (!validated.success) {
return NextResponse.json(
{ error: validated.error.errors },
{ status: 400 }
);
}
const { name, description, color } = validated.data;
// Generate slug
const slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
const project = await prisma.project.create({
data: {
name,
description,
color,
slug: \`${slug}-${Date.now()}\`,
members: {
create: {
userId: session.user.id,
role: 'OWNER',
},
},
},
include: {
members: {
include: {
user: true,
},
},
},
});
return NextResponse.json(project, { status: 201 });
}
Exercise - Part 1 Checkpoint:
- Set up a new Next.js project with the technology stack mentioned above
- Configure the database schema and run migrations
- Implement authentication with both credentials and Google OAuth
- Create the projects API routes with GET and POST endpoints
- Build the login page with proper error handling
- Test user registration, login, and project creation
Development Tips:
- Use environment variables for all sensitive configuration
- Test authentication flows thoroughly before building features
- Keep your database schema normalized and well-indexed
- Use TypeScript types generated from Prisma schemas
- Implement proper error handling from the start
- Use Prisma Studio (npx prisma studio) to inspect your database
What's Next in Part 2:
In Part 2, we'll continue building TaskFlow by implementing:
- Task management with drag-and-drop boards
- Real-time collaboration with Pusher
- Advanced features (notifications, search, filters)
- Testing strategies and test implementation
- Performance optimization techniques
- Production deployment and monitoring
- Security hardening and best practices