Building a Full-Stack Application
A comprehensive guide to building a complete web application from frontend to backend.
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)
- Push your backend code to GitHub
- Connect your repository to Railway
- Add environment variables:
DATABASE_URLJWT_SECRETPORT
2. Frontend Deployment (Vercel)
- Push your frontend code to GitHub
- Connect your repository to Vercel
- 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
- Next.js Documentation
- Express.js Guide
- Prisma Documentation
- Railway Documentation
- Vercel Documentation
Happy building! š
Project Links
Related Posts
Mastering React Hooks: A Complete Guide
Learn how to use React hooks effectively to build better components and manage state.
Getting Started with Next.js 15
Learn how to build modern web applications with Next.js 15 and the App Router.
CSS Grid vs Flexbox: When to Use Each
A comprehensive comparison of CSS Grid and Flexbox, with practical examples and use cases.