Projects Case Studies

Building a Full-Stack Application

A comprehensive guide to building a complete web application from frontend to backend.

D
Developer Blog
January 10, 2024 • 9 min read

Building a Full-Stack Application

Building a full-stack application can seem daunting, but with the right approach and modern tools, it's more accessible than ever. In this comprehensive guide, we'll walk through building a complete web application from frontend to backend.

Project Overview

We'll build a task management application with the following features:

  • User authentication
  • CRUD operations for tasks
  • Real-time updates
  • Responsive design
  • Database persistence

Tech Stack

Frontend

  • Next.js 15 - React framework with App Router
  • TypeScript - Type safety
  • Tailwind CSS - Styling
  • React Hook Form - Form handling
  • Zustand - State management

Backend

  • Node.js - Runtime environment
  • Express.js - Web framework
  • PostgreSQL - Database
  • Prisma - ORM
  • JWT - Authentication

Infrastructure

  • Vercel - Frontend deployment
  • Railway - Backend deployment
  • Supabase - Database hosting

Project Structure

fullstack-task-app/
ā”œā”€ā”€ frontend/                 # Next.js application
│   ā”œā”€ā”€ app/
│   ā”œā”€ā”€ components/
│   ā”œā”€ā”€ lib/
│   └── types/
ā”œā”€ā”€ backend/                  # Express.js API
│   ā”œā”€ā”€ src/
│   ā”œā”€ā”€ prisma/
│   └── package.json
└── README.md

Step 1: Setting Up the Backend

1. Initialize the Project

mkdir fullstack-task-app
cd fullstack-task-app
mkdir backend
cd backend
npm init -y

2. Install Dependencies

npm install express cors helmet morgan jsonwebtoken bcryptjs
npm install -D @types/node @types/express @types/cors @types/jsonwebtoken @types/bcryptjs
npm install prisma @prisma/client

3. Database Schema

Create prisma/schema.prisma:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  password  String
  name      String
  tasks     Task[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Task {
  id          String   @id @default(cuid())
  title       String
  description String?
  completed   Boolean  @default(false)
  userId      String
  user        User     @relation(fields: [userId], references: [id])
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}

4. API Routes

Create src/routes/auth.ts:

import express from "express";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import { PrismaClient } from "@prisma/client";

const router = express.Router();
const prisma = new PrismaClient();

// Register
router.post("/register", async (req, res) => {
  try {
    const { email, password, name } = req.body;

    const existingUser = await prisma.user.findUnique({
      where: { email },
    });

    if (existingUser) {
      return res.status(400).json({ message: "User already exists" });
    }

    const hashedPassword = await bcrypt.hash(password, 12);

    const user = await prisma.user.create({
      data: {
        email,
        password: hashedPassword,
        name,
      },
    });

    const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET!, {
      expiresIn: "7d",
    });

    res.status(201).json({
      token,
      user: {
        id: user.id,
        email: user.email,
        name: user.name,
      },
    });
  } catch (error) {
    res.status(500).json({ message: "Server error" });
  }
});

// Login
router.post("/login", async (req, res) => {
  try {
    const { email, password } = req.body;

    const user = await prisma.user.findUnique({
      where: { email },
    });

    if (!user) {
      return res.status(400).json({ message: "Invalid credentials" });
    }

    const isValidPassword = await bcrypt.compare(password, user.password);

    if (!isValidPassword) {
      return res.status(400).json({ message: "Invalid credentials" });
    }

    const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET!, {
      expiresIn: "7d",
    });

    res.json({
      token,
      user: {
        id: user.id,
        email: user.email,
        name: user.name,
      },
    });
  } catch (error) {
    res.status(500).json({ message: "Server error" });
  }
});

export default router;

Step 2: Setting Up the Frontend

1. Create Next.js App

cd ..
npx create-next-app@latest frontend --typescript --tailwind --app
cd frontend

2. Install Dependencies

npm install axios react-hook-form @hookform/resolvers zod zustand

3. State Management

Create lib/store.ts:

import { create } from "zustand";

interface User {
  id: string;
  email: string;
  name: string;
}

interface AuthStore {
  user: User | null;
  token: string | null;
  setUser: (user: User | null) => void;
  setToken: (token: string | null) => void;
  logout: () => void;
}

export const useAuthStore = create<AuthStore>((set) => ({
  user: null,
  token: null,
  setUser: (user) => set({ user }),
  setToken: (token) => set({ token }),
  logout: () => set({ user: null, token: null }),
}));

4. API Client

Create lib/api.ts:

import axios from "axios";

const api = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api",
});

api.interceptors.request.use((config) => {
  const token = localStorage.getItem("token");
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

export default api;

5. Authentication Components

Create components/LoginForm.tsx:

"use client";

import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useAuthStore } from "@/lib/store";
import api from "@/lib/api";

const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(6),
});

type LoginData = z.infer<typeof loginSchema>;

export default function LoginForm() {
  const [isLoading, setIsLoading] = useState(false);
  const { setUser, setToken } = useAuthStore();

  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<LoginData>({
    resolver: zodResolver(loginSchema),
  });

  const onSubmit = async (data: LoginData) => {
    setIsLoading(true);
    try {
      const response = await api.post("/auth/login", data);
      const { token, user } = response.data;

      localStorage.setItem("token", token);
      setToken(token);
      setUser(user);
    } catch (error) {
      console.error("Login failed:", error);
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <label htmlFor="email" className="block text-sm font-medium">
          Email
        </label>
        <input
          {...register("email")}
          type="email"
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
        />
        {errors.email && (
          <p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="password" className="block text-sm font-medium">
          Password
        </label>
        <input
          {...register("password")}
          type="password"
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
        />
        {errors.password && (
          <p className="mt-1 text-sm text-red-600">{errors.password.message}</p>
        )}
      </div>

      <button
        type="submit"
        disabled={isLoading}
        className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50"
      >
        {isLoading ? "Signing in..." : "Sign In"}
      </button>
    </form>
  );
}

Step 3: Task Management

1. Task API Routes

Create src/routes/tasks.ts:

import express from "express";
import { PrismaClient } from "@prisma/client";
import { authenticateToken } from "../middleware/auth";

const router = express.Router();
const prisma = new PrismaClient();

// Get all tasks for user
router.get("/", authenticateToken, async (req, res) => {
  try {
    const tasks = await prisma.task.findMany({
      where: { userId: req.user.id },
      orderBy: { createdAt: "desc" },
    });
    res.json(tasks);
  } catch (error) {
    res.status(500).json({ message: "Server error" });
  }
});

// Create task
router.post("/", authenticateToken, async (req, res) => {
  try {
    const { title, description } = req.body;

    const task = await prisma.task.create({
      data: {
        title,
        description,
        userId: req.user.id,
      },
    });

    res.status(201).json(task);
  } catch (error) {
    res.status(500).json({ message: "Server error" });
  }
});

// Update task
router.put("/:id", authenticateToken, async (req, res) => {
  try {
    const { id } = req.params;
    const { title, description, completed } = req.body;

    const task = await prisma.task.update({
      where: { id, userId: req.user.id },
      data: { title, description, completed },
    });

    res.json(task);
  } catch (error) {
    res.status(500).json({ message: "Server error" });
  }
});

// Delete task
router.delete("/:id", authenticateToken, async (req, res) => {
  try {
    const { id } = req.params;

    await prisma.task.delete({
      where: { id, userId: req.user.id },
    });

    res.status(204).send();
  } catch (error) {
    res.status(500).json({ message: "Server error" });
  }
});

export default router;

2. Task Components

Create components/TaskList.tsx:

"use client";

import { useState, useEffect } from "react";
import api from "@/lib/api";

interface Task {
  id: string;
  title: string;
  description?: string;
  completed: boolean;
  createdAt: string;
}

export default function TaskList() {
  const [tasks, setTasks] = useState<Task[]>([]);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    fetchTasks();
  }, []);

  const fetchTasks = async () => {
    try {
      const response = await api.get("/tasks");
      setTasks(response.data);
    } catch (error) {
      console.error("Failed to fetch tasks:", error);
    } finally {
      setIsLoading(false);
    }
  };

  const toggleTask = async (taskId: string, completed: boolean) => {
    try {
      await api.put(`/tasks/${taskId}`, { completed });
      setTasks(
        tasks.map((task) =>
          task.id === taskId ? { ...task, completed } : task
        )
      );
    } catch (error) {
      console.error("Failed to update task:", error);
    }
  };

  const deleteTask = async (taskId: string) => {
    try {
      await api.delete(`/tasks/${taskId}`);
      setTasks(tasks.filter((task) => task.id !== taskId));
    } catch (error) {
      console.error("Failed to delete task:", error);
    }
  };

  if (isLoading) {
    return <div>Loading tasks...</div>;
  }

  return (
    <div className="space-y-4">
      {tasks.map((task) => (
        <div
          key={task.id}
          className="flex items-center justify-between p-4 bg-white rounded-lg shadow"
        >
          <div className="flex items-center space-x-3">
            <input
              type="checkbox"
              checked={task.completed}
              onChange={(e) => toggleTask(task.id, e.target.checked)}
              className="h-4 w-4 text-blue-600"
            />
            <div>
              <h3
                className={`font-medium ${
                  task.completed ? "line-through text-gray-500" : ""
                }`}
              >
                {task.title}
              </h3>
              {task.description && (
                <p className="text-sm text-gray-600">{task.description}</p>
              )}
            </div>
          </div>
          <button
            onClick={() => deleteTask(task.id)}
            className="text-red-600 hover:text-red-800"
          >
            Delete
          </button>
        </div>
      ))}
    </div>
  );
}

Step 4: Deployment

1. Backend Deployment (Railway)

  1. Push your backend code to GitHub
  2. Connect your repository to Railway
  3. Add environment variables:
    • DATABASE_URL
    • JWT_SECRET
    • PORT

2. Frontend Deployment (Vercel)

  1. Push your frontend code to GitHub
  2. Connect your repository to Vercel
  3. Add environment variables:
    • NEXT_PUBLIC_API_URL

Best Practices

1. Error Handling

Implement proper error handling throughout your application:

// lib/api.ts
api.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      // Handle unauthorized access
      localStorage.removeItem("token");
      window.location.href = "/login";
    }
    return Promise.reject(error);
  }
);

2. Type Safety

Use TypeScript interfaces for all your data structures:

// types/index.ts
export interface Task {
  id: string;
  title: string;
  description?: string;
  completed: boolean;
  userId: string;
  createdAt: string;
  updatedAt: string;
}

export interface User {
  id: string;
  email: string;
  name: string;
  createdAt: string;
  updatedAt: string;
}

3. Security

  • Use environment variables for sensitive data
  • Implement proper authentication middleware
  • Validate all user inputs
  • Use HTTPS in production
  • Implement rate limiting

Conclusion

Building a full-stack application requires careful planning and the right tools. By following this guide, you've learned how to:

  • Set up a modern backend with Express.js and Prisma
  • Create a responsive frontend with Next.js 15
  • Implement user authentication
  • Build CRUD operations
  • Deploy to production

The key is to start simple and iterate. Begin with basic functionality and gradually add more features as your application grows.

Resources

Happy building! šŸš€

Related Posts