Next.js

Building a Full Next.js Application - Part 2

60 min Lesson 40 of 40

Building a Full Next.js Application - Part 2

Welcome to Part 2 of building TaskFlow! In this lesson, we'll implement advanced features, add real-time collaboration, optimize performance, write tests, and deploy our application to production.

Step 6: Task Management with Drag-and-Drop

Install drag-and-drop library:
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
components/tasks/TaskBoard.tsx:
'use client';

import { useMemo, useState } from 'react';
import {
  DndContext,
  DragEndEvent,
  DragOverlay,
  DragStartEvent,
  PointerSensor,
  useSensor,
  useSensors,
} from '@dnd-kit/core';
import { SortableContext, arrayMove } from '@dnd-kit/sortable';
import { TaskColumn } from './TaskColumn';
import { TaskCard } from './TaskCard';

const COLUMNS = [
  { id: 'TODO', title: 'To Do' },
  { id: 'IN_PROGRESS', title: 'In Progress' },
  { id: 'IN_REVIEW', title: 'In Review' },
  { id: 'DONE', title: 'Done' },
];

export function TaskBoard({ tasks, projectId }: Props) {
  const [activeTask, setActiveTask] = useState(null);

  const sensors = useSensors(
    useSensor(PointerSensor, {
      activationConstraint: {
        distance: 8,
      },
    })
  );

  const tasksByStatus = useMemo(() => {
    return COLUMNS.reduce((acc, column) => {
      acc[column.id] = tasks.filter((task) => task.status === column.id);
      return acc;
    }, {} as Record<string, Task[]>);
  }, [tasks]);

  const handleDragStart = (event: DragStartEvent) => {
    const task = tasks.find((t) => t.id === event.active.id);
    setActiveTask(task);
  };

  const handleDragEnd = async (event: DragEndEvent) => {
    const { active, over } = event;

    if (!over || active.id === over.id) {
      setActiveTask(null);
      return;
    }

    const activeTask = tasks.find((t) => t.id === active.id);
    const overColumn = over.id as TaskStatus;

    if (activeTask && activeTask.status !== overColumn) {
      // Update task status
      await fetch(\`/api/tasks/${activeTask.id}\`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ status: overColumn }),
      });

      // Trigger revalidation or update local state
    }

    setActiveTask(null);
  };

  return (
    <DndContext
      sensors={sensors}
      onDragStart={handleDragStart}
      onDragEnd={handleDragEnd}
    >
      <div className="grid grid-cols-4 gap-4 h-full">
        {COLUMNS.map((column) => (
          <TaskColumn
            key={column.id}
            column={column}
            tasks={tasksByStatus[column.id] || []}
          />
        ))}
      </div>

      <DragOverlay>
        {activeTask ? <TaskCard task={activeTask} isDragging /> : null}
      </DragOverlay>
    </DndContext>
  );
}
components/tasks/TaskColumn.tsx:
'use client';

import { useDroppable } from '@dnd-kit/core';
import { SortableContext } from '@dnd-kit/sortable';
import { TaskCard } from './TaskCard';

export function TaskColumn({ column, tasks }: Props) {
  const { setNodeRef, isOver } = useDroppable({
    id: column.id,
  });

  return (
    <div
      ref={setNodeRef}
      className={cn(
        'flex flex-col bg-gray-50 rounded-lg p-4',
        isOver && 'bg-blue-50'
      )}
    >
      <div className="flex items-center justify-between mb-4">
        <h3 className="font-semibold">{column.title}</h3>
        <span className="text-sm text-gray-500">{tasks.length}</span>
      </div>

      <SortableContext items={tasks.map((t) => t.id)}>
        <div className="space-y-2 flex-1 overflow-y-auto">
          {tasks.map((task) => (
            <TaskCard key={task.id} task={task} />
          ))}
        </div>
      </SortableContext>
    </div>
  );
}

Step 7: Real-time Collaboration with Pusher

lib/pusher.ts:
import Pusher from 'pusher';
import PusherClient from 'pusher-js';

// Server-side Pusher instance
export const pusherServer = new Pusher({
  appId: process.env.PUSHER_APP_ID!,
  key: process.env.NEXT_PUBLIC_PUSHER_KEY!,
  secret: process.env.PUSHER_SECRET!,
  cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
  useTLS: true,
});

// Client-side Pusher instance
export const pusherClient = new PusherClient(
  process.env.NEXT_PUBLIC_PUSHER_KEY!,
  {
    cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
  }
);
app/api/tasks/[id]/route.ts (with real-time updates):
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { prisma } from '@/lib/db';
import { pusherServer } from '@/lib/pusher';

export async function PATCH(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const session = await auth();

  if (!session?.user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const body = await request.json();
  const { status, priority, assigneeId, title, description, dueDate } = body;

  const task = await prisma.task.update({
    where: { id: params.id },
    data: {
      ...(status && { status }),
      ...(priority && { priority }),
      ...(assigneeId !== undefined && { assigneeId }),
      ...(title && { title }),
      ...(description !== undefined && { description }),
      ...(dueDate !== undefined && { dueDate }),
    },
    include: {
      assignee: {
        select: {
          id: true,
          name: true,
          image: true,
        },
      },
    },
  });

  // Trigger real-time update
  await pusherServer.trigger(
    \`project-${task.projectId}\`,
    'task-updated',
    {
      task,
      updatedBy: {
        id: session.user.id,
        name: session.user.name,
      },
    }
  );

  return NextResponse.json(task);
}
hooks/useRealtimeUpdates.ts:
import { useEffect } from 'react';
import { pusherClient } from '@/lib/pusher';
import { useRouter } from 'next/navigation';

export function useRealtimeUpdates(projectId: string) {
  const router = useRouter();

  useEffect(() => {
    const channel = pusherClient.subscribe(\`project-${projectId}\`);

    channel.bind('task-updated', (data: any) => {
      // Revalidate the current page
      router.refresh();

      // Show notification
      showNotification({
        title: 'Task Updated',
        message: \`${data.updatedBy.name} updated a task\`,
      });
    });

    channel.bind('task-created', (data: any) => {
      router.refresh();
      showNotification({
        title: 'New Task',
        message: \`${data.createdBy.name} created a task\`,
      });
    });

    channel.bind('comment-added', (data: any) => {
      router.refresh();
      showNotification({
        title: 'New Comment',
        message: \`${data.author.name} commented on a task\`,
      });
    });

    return () => {
      channel.unbind_all();
      pusherClient.unsubscribe(\`project-${projectId}\`);
    };
  }, [projectId, router]);
}

Step 8: Testing Implementation

Install testing dependencies:
npm install -D @testing-library/react @testing-library/jest-dom jest jest-environment-jsdom
npm install -D @playwright/test
jest.config.js:
const nextJest = require('next/jest');

const createJestConfig = nextJest({
  dir: './',
});

const customJestConfig = {
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  testEnvironment: 'jest-environment-jsdom',
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/$1',
  },
};

module.exports = createJestConfig(customJestConfig);
__tests__/components/TaskCard.test.tsx:
import { render, screen } from '@testing-library/react';
import { TaskCard } from '@/components/tasks/TaskCard';

describe('TaskCard', () => {
  const mockTask = {
    id: '1',
    title: 'Test Task',
    description: 'Test description',
    status: 'TODO',
    priority: 'HIGH',
    dueDate: new Date('2024-12-31'),
    assignee: {
      id: 'user1',
      name: 'John Doe',
      image: '/avatar.jpg',
    },
  };

  it('renders task title', () => {
    render(<TaskCard task={mockTask} />);
    expect(screen.getByText('Test Task')).toBeInTheDocument();
  });

  it('displays priority badge', () => {
    render(<TaskCard task={mockTask} />);
    expect(screen.getByText('HIGH')).toBeInTheDocument();
  });

  it('shows assignee information', () => {
    render(<TaskCard task={mockTask} />);
    expect(screen.getByText('John Doe')).toBeInTheDocument();
  });
});
e2e/project-creation.spec.ts (Playwright):
import { test, expect } from '@playwright/test';

test('user can create a new project', async ({ page }) => {
  // Login
  await page.goto('http://localhost:3000/login');
  await page.fill('input[name="email"]', 'test@example.com');
  await page.fill('input[name="password"]', 'password123');
  await page.click('button[type="submit"]');

  // Navigate to projects
  await page.waitForURL('**/dashboard');
  await page.click('text=New Project');

  // Fill project form
  await page.fill('input[name="name"]', 'Test Project');
  await page.fill('textarea[name="description"]', 'This is a test project');
  await page.click('button:has-text("Create")');

  // Verify project created
  await expect(page.locator('text=Test Project')).toBeVisible();
});

Step 9: Performance Optimization

Implement pagination for tasks:
// app/api/projects/[id]/tasks/route.ts
export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const { searchParams } = new URL(request.url);
  const page = parseInt(searchParams.get('page') || '1');
  const limit = parseInt(searchParams.get('limit') || '20');
  const skip = (page - 1) * limit;

  const [tasks, total] = await Promise.all([
    prisma.task.findMany({
      where: { projectId: params.id },
      skip,
      take: limit,
      include: {
        assignee: true,
      },
      orderBy: {
        createdAt: 'desc',
      },
    }),
    prisma.task.count({
      where: { projectId: params.id },
    }),
  ]);

  return NextResponse.json({
    tasks,
    pagination: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit),
    },
  });
}
Add database indexes (prisma/schema.prisma):
model Task {
  // ... fields

  @@index([projectId])
  @@index([assigneeId])
  @@index([status])
  @@index([createdAt])
}

model Project {
  // ... fields

  @@index([slug])
  @@index([archived])
}
Implement caching for frequently accessed data:
import { unstable_cache } from 'next/cache';

export const getProject = unstable_cache(
  async (id: string) => {
    return await prisma.project.findUnique({
      where: { id },
      include: {
        members: {
          include: {
            user: true,
          },
        },
        _count: {
          select: {
            tasks: true,
          },
        },
      },
    });
  },
  ['project'],
  {
    revalidate: 60, // Cache for 60 seconds
    tags: [\`project-${id}\`],
  }
);

Step 10: Production Deployment

Environment variables for production (.env.production):
# Database
DATABASE_URL="postgresql://user:password@production-host:5432/taskflow"

# Auth
NEXTAUTH_URL="https://taskflow.example.com"
NEXTAUTH_SECRET="secure-production-secret"

# OAuth
GOOGLE_CLIENT_ID="production-google-client-id"
GOOGLE_CLIENT_SECRET="production-google-client-secret"

# Pusher
NEXT_PUBLIC_PUSHER_KEY="production-pusher-key"
PUSHER_SECRET="production-pusher-secret"
PUSHER_APP_ID="production-app-id"
NEXT_PUBLIC_PUSHER_CLUSTER="mt1"

# Monitoring
SENTRY_DSN="your-sentry-dsn"
NEXT_PUBLIC_VERCEL_ANALYTICS_ID="your-analytics-id"
Deploy to Vercel:
# Install Vercel CLI
npm install -g vercel

# Login
vercel login

# Deploy
vercel --prod

# Or use Git integration for automatic deployments
git push origin main

Production Checklist

Pre-Deployment Checklist:
  • ✓ All environment variables configured in Vercel
  • ✓ Database migrations run on production database
  • ✓ SSL certificate configured (automatic with Vercel)
  • ✓ Custom domain connected
  • ✓ Error tracking configured (Sentry)
  • ✓ Analytics configured (Vercel Analytics, GA4)
  • ✓ Security headers configured
  • ✓ Rate limiting implemented
  • ✓ Database backups scheduled
  • ✓ Monitoring and alerts set up

Security Hardening

middleware.ts (rate limiting & security):
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '10 s'),
});

export async function middleware(request: NextRequest) {
  // Rate limiting for API routes
  if (request.nextUrl.pathname.startsWith('/api/')) {
    const ip = request.ip ?? '127.0.0.1';
    const { success } = await ratelimit.limit(ip);

    if (!success) {
      return NextResponse.json(
        { error: 'Too many requests' },
        { status: 429 }
      );
    }
  }

  // Security headers
  const response = NextResponse.next();

  response.headers.set(
    'Strict-Transport-Security',
    'max-age=31536000; includeSubDomains'
  );
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-Content-Type-Options', 'nosniff');
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
  response.headers.set(
    'Permissions-Policy',
    'camera=(), microphone=(), geolocation=()'
  );

  return response;
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico).*)',
  ],
};

Monitoring & Observability

lib/monitoring.ts:
import * as Sentry from '@sentry/nextjs';

export function logError(error: Error, context?: Record<string, any>) {
  console.error(error);

  if (process.env.NODE_ENV === 'production') {
    Sentry.captureException(error, {
      extra: context,
    });
  }
}

export function logPerformance(metric: string, duration: number) {
  if (process.env.NODE_ENV === 'production') {
    // Send to your analytics service
    fetch('/api/analytics/performance', {
      method: 'POST',
      body: JSON.stringify({ metric, duration }),
      keepalive: true,
    });
  }
}

Documentation & Maintenance

README.md structure:
# TaskFlow

Project management application built with Next.js 15

## Features
- Authentication with NextAuth.js
- Real-time collaboration with Pusher
- Drag-and-drop task boards
- Team management
- Notifications

## Tech Stack
- Next.js 15
- TypeScript
- PostgreSQL + Prisma
- Tailwind CSS
- Pusher

## Getting Started
\`\`\`bash
npm install
cp .env.example .env.local
# Configure environment variables
npx prisma db push
npm run dev
\`\`\`

## Testing
\`\`\`bash
npm test              # Unit tests
npm run test:e2e      # E2E tests
\`\`\`

## Deployment
See [DEPLOYMENT.md](./DEPLOYMENT.md)

## License
MIT
Final Project Exercise:
  1. Complete the TaskFlow application with all features
  2. Write unit tests for key components
  3. Write E2E tests for critical user flows
  4. Optimize database queries and add proper indexes
  5. Set up error tracking with Sentry
  6. Configure CI/CD with GitHub Actions
  7. Deploy to Vercel with custom domain
  8. Set up monitoring and alerts
  9. Document your API with Swagger/OpenAPI
  10. Create a comprehensive README
Congratulations!

You've now built a complete, production-ready Next.js application from scratch. You've learned:

  • Project architecture and planning
  • Database design with Prisma
  • Authentication with NextAuth.js
  • Real-time features with Pusher
  • Advanced UI with drag-and-drop
  • Testing strategies (unit and E2E)
  • Performance optimization
  • Security best practices
  • Production deployment
  • Monitoring and observability

These skills form the foundation for building any modern web application with Next.js!

Next Steps:
  • Add more features: notifications, search, analytics dashboard
  • Implement PWA capabilities for offline support
  • Add internationalization (i18n) for multiple languages
  • Build a mobile app with React Native sharing code
  • Explore edge runtime for better performance
  • Implement advanced caching strategies
  • Add AI features with OpenAI or similar APIs
  • Build integrations with third-party services

Tutorial Complete!

Congratulations! You have completed all lessons in this tutorial.