Next.js

Building a Full Next.js Application - Part 1

55 min Lesson 39 of 40

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:
  1. Set up a new Next.js project with the technology stack mentioned above
  2. Configure the database schema and run migrations
  3. Implement authentication with both credentials and Google OAuth
  4. Create the projects API routes with GET and POST endpoints
  5. Build the login page with proper error handling
  6. 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