DEV Community

# Build a Java Full-Stack Task Manager with Spring Boot, React, and PostgreSQL


If you are exploring a java full stack course in Bangalore with placement, building a complete application is one of the best ways to apply what you learn. In this hands-on tutorial, we will create a task manager using Spring Boot, React, and PostgreSQL.

You will build a working application that can:

  • Create tasks.
  • Display tasks.
  • Mark tasks as completed.
  • Delete tasks.
  • Validate user input.
  • Store data in PostgreSQL.
  • Connect a React frontend to a Java backend.

The project is small enough to complete in a weekend but includes concepts used in real Java full-stack development.

Project Architecture

The application will follow this structure:

React frontend
      |
      | HTTP requests
      v
Spring Boot REST API
      |
      | Spring Data JPA
      v
PostgreSQL database
Enter fullscreen mode Exit fullscreen mode

The backend will use a layered architecture:

Controller
    |
Service
    |
Repository
    |
Database
Enter fullscreen mode Exit fullscreen mode

Each layer has a separate responsibility:

  • The controller handles HTTP requests.
  • The service contains business logic.
  • The repository communicates with the database.
  • The entity represents database data.

The final project structure will look like this:

task-manager/
├── backend/
│   └── src/main/java/com/example/taskmanager/
│       ├── controller/
│       ├── exception/
│       ├── model/
│       ├── repository/
│       └── service/
└── frontend/
    ├── src/
    │   ├── App.jsx
    │   └── App.css
    └── package.json
Enter fullscreen mode Exit fullscreen mode

Prerequisites

You should have basic knowledge of:

  • Java classes and methods.
  • JavaScript functions.
  • HTML and CSS.
  • SQL.
  • HTTP requests.

Install these tools:

  • JDK 17 or later.
  • Maven.
  • PostgreSQL.
  • Node.js and npm.
  • Git.
  • An IDE such as IntelliJ IDEA or VS Code.
  • Postman or curl.

We will use:

  • Java 17.
  • Spring Boot 3.x.
  • Spring Data JPA.
  • PostgreSQL.
  • React with Vite.
  • Maven.

Check whether the tools are installed:

java -version
mvn -version
node --version
npm --version
psql --version
Enter fullscreen mode Exit fullscreen mode

If each command returns version information, your environment is ready.

Step 1: Create the PostgreSQL Database

Start PostgreSQL and open the PostgreSQL command-line client:

psql -U postgres
Enter fullscreen mode Exit fullscreen mode

Create a database named task_manager:

CREATE DATABASE task_manager;
Enter fullscreen mode Exit fullscreen mode

Exit the PostgreSQL shell:

\q
Enter fullscreen mode Exit fullscreen mode

PostgreSQL requires the server to be running before you create or access a database. The CREATE DATABASE command is the standard way to create a new database.

You do not need to create the tasks table manually. Hibernate will create it when the Spring Boot application starts.

Step 2: Create the Spring Boot Project

Create a Spring Boot project with these dependencies:

  • Spring Web.
  • Spring Data JPA.
  • PostgreSQL Driver.
  • Validation.
  • Spring Boot DevTools.

You can create the project using an IDE or Spring Initializr.

Place the backend inside a directory named:

backend
Enter fullscreen mode Exit fullscreen mode

The main application class should look like this:

package com.example.taskmanager;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class TaskManagerApplication {

    public static void main(String[] args) {
        SpringApplication.run(TaskManagerApplication.class, args);
    }
}
Enter fullscreen mode Exit fullscreen mode

The @SpringBootApplication annotation enables component scanning, auto-configuration, and Spring Boot configuration.

Step 3: Configure the Database Connection

Open this file:

backend/src/main/resources/application.properties
Enter fullscreen mode Exit fullscreen mode

Add the following configuration:

spring.datasource.url=jdbc:postgresql://localhost:5432/task_manager
spring.datasource.username=postgres
spring.datasource.password=your_password

spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

server.port=8080
Enter fullscreen mode Exit fullscreen mode

Replace your_password with your local PostgreSQL password.

The important properties are:

  • spring.datasource.url tells Spring where PostgreSQL is running.
  • spring.datasource.username specifies the database user.
  • spring.datasource.password specifies the database password.
  • spring.jpa.hibernate.ddl-auto=update allows Hibernate to create or update tables during local development.
  • server.port=8080 runs the backend on port 8080.

For a production project, do not store credentials directly in the properties file. Use environment variables instead:

spring.datasource.url=${DB_URL}
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}
Enter fullscreen mode Exit fullscreen mode

On Linux or macOS:

export DB_URL=jdbc:postgresql://localhost:5432/task_manager
export DB_USERNAME=postgres
export DB_PASSWORD=your_password
Enter fullscreen mode Exit fullscreen mode

On Windows PowerShell:

$env:DB_URL="jdbc:postgresql://localhost:5432/task_manager"
$env:DB_USERNAME="postgres"
$env:DB_PASSWORD="your_password"
Enter fullscreen mode Exit fullscreen mode

Step 4: Create the Task Entity

Create this file:

backend/src/main/java/com/example/taskmanager/model/Task.java
Enter fullscreen mode Exit fullscreen mode

Add the following code:

package com.example.taskmanager.model;

import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

@Entity
@Table(name = "tasks")
public class Task {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank(message = "Title is required")
    @Size(max = 120, message = "Title cannot exceed 120 characters")
    @Column(nullable = false, length = 120)
    private String title;

    @Size(max = 500, message = "Description cannot exceed 500 characters")
    private String description;

    @Column(nullable = false)
    private boolean completed = false;

    public Task() {
    }

    public Task(String title, String description) {
        this.title = title;
        this.description = description;
        this.completed = false;
    }

    public Long getId() {
        return id;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    public boolean isCompleted() {
        return completed;
    }

    public void setCompleted(boolean completed) {
        this.completed = completed;
    }
}
Enter fullscreen mode Exit fullscreen mode

Understanding the Entity

The @Entity annotation tells JPA that this class represents a database table.

The @Table(name = "tasks") annotation sets the table name.

The @Id annotation marks the primary key.

The @GeneratedValue annotation allows the database to generate the ID automatically.

The validation annotations protect your API from invalid input:

@NotBlank
Enter fullscreen mode Exit fullscreen mode

prevents an empty title.

@Size(max = 120)
Enter fullscreen mode Exit fullscreen mode

prevents titles longer than 120 characters.

The completed field defaults to false, so every new task starts as incomplete.

Step 5: Create the Repository

Create:

backend/src/main/java/com/example/taskmanager/repository/TaskRepository.java
Enter fullscreen mode Exit fullscreen mode
package com.example.taskmanager.repository;

import com.example.taskmanager.model.Task;
import org.springframework.data.jpa.repository.JpaRepository;

public interface TaskRepository extends JpaRepository<Task, Long> {
}
Enter fullscreen mode Exit fullscreen mode

By extending JpaRepository, your repository automatically receives common database methods:

findAll()
findById(id)
save(task)
deleteById(id)
existsById(id)
Enter fullscreen mode Exit fullscreen mode

You do not need to write SQL for these basic operations.

You can also create custom query methods. For example:

import java.util.List;

List<Task> findByCompleted(boolean completed);
Enter fullscreen mode Exit fullscreen mode

Spring Data JPA can create the query based on the method name.

Step 6: Create the Service Layer

Create:

backend/src/main/java/com/example/taskmanager/service/TaskService.java
Enter fullscreen mode Exit fullscreen mode
package com.example.taskmanager.service;

import com.example.taskmanager.model.Task;
import com.example.taskmanager.repository.TaskRepository;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class TaskService {

    private final TaskRepository taskRepository;

    public TaskService(TaskRepository taskRepository) {
        this.taskRepository = taskRepository;
    }

    public List<Task> getAllTasks() {
        return taskRepository.findAll();
    }

    public Task getTaskById(Long id) {
        return taskRepository.findById(id)
                .orElseThrow(() ->
                        new RuntimeException("Task not found with id: " + id));
    }

    public Task createTask(Task task) {
        return taskRepository.save(task);
    }

    public Task updateTask(Long id, Task updatedTask) {
        Task existingTask = getTaskById(id);

        existingTask.setTitle(updatedTask.getTitle());
        existingTask.setDescription(updatedTask.getDescription());
        existingTask.setCompleted(updatedTask.isCompleted());

        return taskRepository.save(existingTask);
    }

    public Task toggleTask(Long id) {
        Task task = getTaskById(id);
        task.setCompleted(!task.isCompleted());

        return taskRepository.save(task);
    }

    public void deleteTask(Long id) {
        if (!taskRepository.existsById(id)) {
            throw new RuntimeException("Task not found with id: " + id);
        }

        taskRepository.deleteById(id);
    }
}
Enter fullscreen mode Exit fullscreen mode

Why Use a Service Layer?

You could call the repository directly from the controller, but that approach becomes difficult to maintain.

The service layer is useful for:

  • Business rules.
  • Data validation.
  • Transactions.
  • Logging.
  • Permission checks.
  • Reusable application logic.
  • Unit testing.

For example, if you later decide that completed tasks cannot be deleted, that rule belongs in the service layer.

Step 7: Create the REST Controller

Create:

backend/src/main/java/com/example/taskmanager/controller/TaskController.java
Enter fullscreen mode Exit fullscreen mode
package com.example.taskmanager.controller;

import com.example.taskmanager.model.Task;
import com.example.taskmanager.service.TaskService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/tasks")
@CrossOrigin(origins = "http://localhost:5173")
public class TaskController {

    private final TaskService taskService;

    public TaskController(TaskService taskService) {
        this.taskService = taskService;
    }

    @GetMapping
    public List<Task> getAllTasks() {
        return taskService.getAllTasks();
    }

    @GetMapping("/{id}")
    public Task getTaskById(@PathVariable Long id) {
        return taskService.getTaskById(id);
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Task createTask(@Valid @RequestBody Task task) {
        return taskService.createTask(task);
    }

    @PutMapping("/{id}")
    public Task updateTask(
            @PathVariable Long id,
            @Valid @RequestBody Task task
    ) {
        return taskService.updateTask(id, task);
    }

    @PatchMapping("/{id}/toggle")
    public Task toggleTask(@PathVariable Long id) {
        return taskService.toggleTask(id);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteTask(@PathVariable Long id) {
        taskService.deleteTask(id);
    }
}
Enter fullscreen mode Exit fullscreen mode

The controller exposes these endpoints:

Method Endpoint Description
GET /api/tasks Get all tasks
GET /api/tasks/{id} Get one task
POST /api/tasks Create a task
PUT /api/tasks/{id} Update a task
PATCH /api/tasks/{id}/toggle Change completion status
DELETE /api/tasks/{id} Delete a task

The @CrossOrigin annotation allows the React application running on port 5173 to call the backend running on port 8080.

Step 8: Start and Test the Backend

Start the Spring Boot application from the backend directory:

./mvnw spring-boot:run
Enter fullscreen mode Exit fullscreen mode

On Windows:

mvnw.cmd spring-boot:run
Enter fullscreen mode Exit fullscreen mode

You can also start it with Maven:

mvn spring-boot:run
Enter fullscreen mode Exit fullscreen mode

If the application starts successfully, you should see a message indicating that the server is running on port 8080.

Create a Task

Use curl to create a task:

curl -X POST http://localhost:8080/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"Learn Spring Boot","description":"Build a REST API"}'
Enter fullscreen mode Exit fullscreen mode

Expected response:

{
  "id": 1,
  "title": "Learn Spring Boot",
  "description": "Build a REST API",
  "completed": false
}
Enter fullscreen mode Exit fullscreen mode

Get All Tasks

curl http://localhost:8080/api/tasks
Enter fullscreen mode Exit fullscreen mode

Get One Task

curl http://localhost:8080/api/tasks/1
Enter fullscreen mode Exit fullscreen mode

Toggle Completion

curl -X PATCH http://localhost:8080/api/tasks/1/toggle
Enter fullscreen mode Exit fullscreen mode

Delete a Task

curl -X DELETE http://localhost:8080/api/tasks/1
Enter fullscreen mode Exit fullscreen mode

Test Validation

Send an empty title:

curl -X POST http://localhost:8080/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"","description":"Invalid task"}'
Enter fullscreen mode Exit fullscreen mode

Spring will reject the request because the title is required.

Step 9: Improve Error Handling

Returning a generic RuntimeException is not ideal. A REST API should return meaningful status codes and messages.

Create this exception class:

backend/src/main/java/com/example/taskmanager/exception/TaskNotFoundException.java
Enter fullscreen mode Exit fullscreen mode
package com.example.taskmanager.exception;

public class TaskNotFoundException extends RuntimeException {

    public TaskNotFoundException(Long id) {
        super("Task not found with id: " + id);
    }
}
Enter fullscreen mode Exit fullscreen mode

Update the service imports:

import com.example.taskmanager.exception.TaskNotFoundException;
Enter fullscreen mode Exit fullscreen mode

Change the getTaskById method:

public Task getTaskById(Long id) {
    return taskRepository.findById(id)
            .orElseThrow(() -> new TaskNotFoundException(id));
}
Enter fullscreen mode Exit fullscreen mode

Update the deleteTask method:

public void deleteTask(Long id) {
    if (!taskRepository.existsById(id)) {
        throw new TaskNotFoundException(id);
    }

    taskRepository.deleteById(id);
}
Enter fullscreen mode Exit fullscreen mode

Now create the global exception handler:

backend/src/main/java/com/example/taskmanager/exception/GlobalExceptionHandler.java
Enter fullscreen mode Exit fullscreen mode
package com.example.taskmanager.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(TaskNotFoundException.class)
    public ResponseEntity<Map<String, String>> handleTaskNotFound(
            TaskNotFoundException exception
    ) {
        Map<String, String> response = new HashMap<>();
        response.put("error", exception.getMessage());

        return ResponseEntity
                .status(HttpStatus.NOT_FOUND)
                .body(response);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidation(
            MethodArgumentNotValidException exception
    ) {
        Map<String, String> errors = new HashMap<>();

        exception.getBindingResult()
                .getFieldErrors()
                .forEach(error ->
                        errors.put(
                                error.getField(),
                                error.getDefaultMessage()
                        )
                );

        return ResponseEntity
                .badRequest()
                .body(errors);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, requesting a task that does not exist returns:

{
  "error": "Task not found with id: 99"
}
Enter fullscreen mode Exit fullscreen mode

An invalid title returns:

{
  "title": "Title is required"
}
Enter fullscreen mode Exit fullscreen mode

This makes the API easier for the frontend and other clients to understand.

Step 10: Create the React Frontend

From the project root, create a React application with Vite:

npm create vite@latest frontend -- --template react
Enter fullscreen mode Exit fullscreen mode

Move into the frontend directory:

cd frontend
Enter fullscreen mode Exit fullscreen mode

Install dependencies:

npm install
Enter fullscreen mode Exit fullscreen mode

Start the development server:

npm run dev
Enter fullscreen mode Exit fullscreen mode

The frontend normally runs at:

http://localhost:5173
Enter fullscreen mode Exit fullscreen mode

Create the React Application

Replace the contents of src/App.jsx with:

import { useEffect, useState } from "react";
import "./App.css";

const API_URL = "http://localhost:8080/api/tasks";

function App() {
  const [tasks, setTasks] = useState([]);
  const [title, setTitle] = useState("");
  const [description, setDescription] = useState("");
  const [error, setError] = useState("");

  async function loadTasks() {
    try {
      const response = await fetch(API_URL);

      if (!response.ok) {
        throw new Error("Unable to load tasks");
      }

      const data = await response.json();
      setTasks(data);
    } catch (error) {
      setError(error.message);
    }
  }

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

  async function addTask(event) {
    event.preventDefault();
    setError("");

    if (!title.trim()) {
      setError("Title is required");
      return;
    }

    try {
      const response = await fetch(API_URL, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          title,
          description,
        }),
      });

      if (!response.ok) {
        throw new Error("Unable to create task");
      }

      const newTask = await response.json();

      setTasks((currentTasks) => [
        ...currentTasks,
        newTask,
      ]);

      setTitle("");
      setDescription("");
    } catch (error) {
      setError(error.message);
    }
  }

  async function toggleTask(id) {
    try {
      const response = await fetch(`${API_URL}/${id}/toggle`, {
        method: "PATCH",
      });

      if (!response.ok) {
        throw new Error("Unable to update task");
      }

      const updatedTask = await response.json();

      setTasks((currentTasks) =>
        currentTasks.map((task) =>
          task.id === updatedTask.id
            ? updatedTask
            : task
        )
      );
    } catch (error) {
      setError(error.message);
    }
  }

  async function deleteTask(id) {
    try {
      const response = await fetch(`${API_URL}/${id}`, {
        method: "DELETE",
      });

      if (!response.ok) {
        throw new Error("Unable to delete task");
      }

      setTasks((currentTasks) =>
        currentTasks.filter((task) => task.id !== id)
      );
    } catch (error) {
      setError(error.message);
    }
  }

  return (
    <main className="container">
      <h1>Task Manager</h1>

      <form onSubmit={addTask} className="task-form">
        <input
          value={title}
          onChange={(event) => setTitle(event.target.value)}
          placeholder="Task title"
          maxLength="120"
        />

        <textarea
          value={description}
          onChange={(event) => setDescription(event.target.value)}
          placeholder="Description"
          maxLength="500"
        />

        <button type="submit">Add task</button>
      </form>

      {error && <p className="error">{error}</p>}

      <section>
        {tasks.length === 0 ? (
          <p>No tasks yet.</p>
        ) : (
          tasks.map((task) => (
            <article key={task.id} className="task-card">
              <div>
                <h2 className={task.completed ? "completed" : ""}>
                  {task.title}
                </h2>

                <p>{task.description}</p>
              </div>

              <div className="actions">
                <button onClick={() => toggleTask(task.id)}>
                  {task.completed
                    ? "Mark pending"
                    : "Complete"}
                </button>

                <button onClick={() => deleteTask(task.id)}>
                  Delete
                </button>
              </div>
            </article>
          ))
        )}
      </section>
    </main>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

The useState hook stores component data such as tasks and form values. The useEffect hook loads tasks when the component is rendered.

Add CSS

Replace src/App.css with:

body {
  margin: 0;
  font-family: Arial, sans-serif;
  background: #f4f6f8;
  color: #1f2937;
}

.container {
  width: min(760px, 92%);
  margin: 40px auto;
}

.task-form,
.task-card {
  background: white;
  padding: 20px;
  border-radius: 10px;
  margin-bottom: 16px;
  box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
}

.task-form {
  display: grid;
  gap: 12px;
}

input,
textarea,
button {
  font: inherit;
  padding: 10px;
}

textarea {
  min-height: 90px;
  resize: vertical;
}

button {
  cursor: pointer;
  border: 0;
  border-radius: 6px;
  background: #2563eb;
  color: white;
}

.task-card {
  display: flex;
  justify-content: space-between;
  gap: 16px;
}

.completed {
  text-decoration: line-through;
  color: #6b7280;
}

.actions {
  display: flex;
  gap: 8px;
  align-items: center;
}

.error {
  color: #b91c1c;
}
Enter fullscreen mode Exit fullscreen mode

Open the frontend in your browser:

http://localhost:5173
Enter fullscreen mode Exit fullscreen mode

Try the following:

  1. Add a task.
  2. Refresh the page.
  3. Mark the task as completed.
  4. Refresh the page again.
  5. Delete the task.

The data remains available after refreshing because it is stored in PostgreSQL rather than only in React state.

Step 11: Add the Project to Git

From the project root:

git init
git add .
git commit -m "Build task manager with Spring Boot and React"
Enter fullscreen mode Exit fullscreen mode

Create a remote Git repository and connect it:

git branch -M main
git remote add origin your-repository-address
git push -u origin main
Enter fullscreen mode Exit fullscreen mode

Before pushing, create a .gitignore file:

# Java
target/
*.class

# Node
node_modules/
dist/

# IDE
.idea/
.vscode/

# Environment files
.env
.env.*
Enter fullscreen mode Exit fullscreen mode

Your README should contain:

  • Project description.
  • Features.
  • Technologies used.
  • Installation instructions.
  • Database setup.
  • API endpoints.
  • Screenshots.
  • Future improvements.

Useful startup commands:

# Start backend
cd backend
./mvnw spring-boot:run
Enter fullscreen mode Exit fullscreen mode
# Start frontend
cd frontend
npm install
npm run dev
Enter fullscreen mode Exit fullscreen mode

Never commit:

  • Database passwords.
  • API keys.
  • Private tokens.
  • Production environment files.
  • Personal credentials.

Practical Exercises

After completing the basic application, extend it with these exercises.

Exercise 1: Add Due Dates

Add a due date field to the entity:

private LocalDate dueDate;
Enter fullscreen mode Exit fullscreen mode

Then add the required getter and setter.

Think about these questions:

  • Should the due date be optional?
  • Should past due dates be allowed?
  • How should the frontend display the date?
  • Should overdue tasks have a different color?

Exercise 2: Add Task Filtering

Add an endpoint that filters tasks by completion status:

GET /api/tasks?completed=true
Enter fullscreen mode Exit fullscreen mode

Add this method to the repository:

List<Task> findByCompleted(boolean completed);
Enter fullscreen mode Exit fullscreen mode

Then update the service and controller to use the query parameter.

Exercise 3: Add Pagination

Returning every task works for a small project, but it becomes inefficient as the number of tasks grows.

Update the repository:

Page<Task> findAll(Pageable pageable);
Enter fullscreen mode Exit fullscreen mode

Then support requests such as:

GET /api/tasks?page=0&size=10
Enter fullscreen mode Exit fullscreen mode

Pagination reduces response size and frontend rendering work.

Exercise 4: Add Task Categories

Add a category field such as:

Work
Personal
Learning
Shopping
Enter fullscreen mode Exit fullscreen mode

Then allow users to filter by category.

Exercise 5: Add Authentication

Protect the task endpoints with Spring Security.

Start with basic authentication in a local environment. Later, learn how to use token-based authentication.

Never store passwords as plain text. Passwords should be securely hashed.

Exercise 6: Write Tests

Write tests for:

  • Creating a valid task.
  • Rejecting an empty title.
  • Fetching all tasks.
  • Fetching a task by ID.
  • Returning a not-found response.
  • Toggling completion.
  • Deleting a task.

Troubleshooting

PostgreSQL Connection Refused

Make sure PostgreSQL is running.

On Linux:

sudo systemctl status postgresql
Enter fullscreen mode Exit fullscreen mode

Also check:

  • The database name is task_manager.
  • PostgreSQL is using port 5432.
  • The username is correct.
  • The password is correct.
  • The database server accepts local connections.

Password Authentication Failed

Test the database credentials directly:

psql -U postgres -h localhost -d task_manager
Enter fullscreen mode Exit fullscreen mode

If this command fails, correct the PostgreSQL password or username before starting Spring Boot.

CORS Error

The React frontend runs on port 5173, while the backend runs on port 8080. The backend must allow requests from the frontend.

Check this annotation:

@CrossOrigin(origins = "http://localhost:5173")
Enter fullscreen mode Exit fullscreen mode

If your frontend runs on a different port, update the origin.

For larger projects, configure CORS globally instead of adding the annotation to individual controllers.

404 Not Found

Check the request URL:

http://localhost:8080/api/tasks
Enter fullscreen mode Exit fullscreen mode

Common mistakes include:

  • Sending the request to port 5173 instead of 8080.
  • Omitting /api.
  • Using /task instead of /tasks.
  • Forgetting to restart the application after changing a controller mapping.

React Shows an Empty List

Open the browser developer tools and inspect the Network tab.

Check that:

  • The backend is running.
  • The API returns JSON.
  • The frontend calls port 8080.
  • CORS allows port 5173.
  • The database contains task records.

You can test the backend independently:

curl http://localhost:8080/api/tasks
Enter fullscreen mode Exit fullscreen mode

Hibernate Does Not Create the Table

Check the following:

  • The PostgreSQL driver dependency is present.
  • The database exists.
  • The entity has the @Entity annotation.
  • The database credentials are correct.
  • The application can connect to PostgreSQL.
  • spring.jpa.hibernate.ddl-auto=update is present for local development.

For production applications, use database migration tools instead of depending on automatic schema updates.

Port Already in Use

If port 8080 is already occupied, change the backend port:

server.port=8081
Enter fullscreen mode Exit fullscreen mode

Then update the React API URL:

const API_URL = "http://localhost:8081/api/tasks";
Enter fullscreen mode Exit fullscreen mode

If port 5173 is occupied, Vite may automatically select another port. Update the backend CORS configuration accordingly.

Best Practices

Keep Layers Separate

Do not place database logic inside the controller.

A clean structure looks like this:

Controller → Service → Repository
Enter fullscreen mode Exit fullscreen mode

This makes the code easier to understand and test.

Validate Input on the Backend

Frontend validation improves the user experience, but it cannot be trusted as the only protection. Users can call your API directly using tools such as curl or Postman.

Always validate important input on the backend.

Use DTOs in Larger Applications

This tutorial uses the Task entity directly in the request body to keep the project simple.

In a larger application, create request and response DTOs:

public class CreateTaskRequest {
    private String title;
    private String description;
}
Enter fullscreen mode Exit fullscreen mode

DTOs help prevent internal database fields from being exposed accidentally.

Use Pagination

Do not return thousands of records in a single response. Pagination improves:

  • Database performance.
  • Network performance.
  • Server memory usage.
  • Frontend rendering speed.

Use Database Indexes Carefully

If you frequently filter by completion status, an index may help:

CREATE INDEX idx_tasks_completed
ON tasks(completed);
Enter fullscreen mode Exit fullscreen mode

Indexes can speed up reads but add storage and write overhead. Add them based on actual query patterns.

Avoid Sensitive Data in Git

Never commit this type of configuration:

spring.datasource.password=real-password
Enter fullscreen mode Exit fullscreen mode

Use environment variables or a secrets manager for sensitive information.

Use Database Migrations

The following setting is convenient during development:

spring.jpa.hibernate.ddl-auto=update
Enter fullscreen mode Exit fullscreen mode

For production, consider a migration tool such as Flyway or Liquibase. Migrations make database changes explicit, reviewable, and repeatable.

Test Before Optimizing

Do not optimize based only on assumptions. Measure:

  • API response time.
  • Database query time.
  • Frontend rendering time.
  • Network payload size.

Then improve the actual bottleneck.

Performance Improvements

The first version of this project is suitable for learning, but you can improve its performance in several ways.

Reduce Unnecessary API Calls

After creating a task, the frontend adds the returned task directly to state:

setTasks((currentTasks) => [
  ...currentTasks,
  newTask,
]);
Enter fullscreen mode Exit fullscreen mode

This avoids fetching the entire task list again.

Use Database-Level Filtering

Do not fetch every task and filter them in Java if the database can perform the filtering:

List<Task> findByCompleted(boolean completed);
Enter fullscreen mode Exit fullscreen mode

The database can process filtering more efficiently, especially as the table grows.

Add Pagination

Pagination prevents the server and browser from processing unnecessary records.

Disable SQL Logging in Production

This setting is useful while learning:

spring.jpa.show-sql=true
Enter fullscreen mode Exit fullscreen mode

However, SQL logging can generate a large amount of output. Disable it in production unless you are actively debugging.

Use Connection Pooling

Spring Boot applications commonly use a database connection pool. For larger applications, tune pool settings based on traffic and database capacity rather than choosing large values blindly.

Learning Path

If you are building skills through a Java full-stack course in Bangalore with placement, use this project as a practical assignment rather than only reading the theory.

A useful learning path is:

  1. Learn Java classes, interfaces, collections, and exception handling.
  2. Practice SQL queries and relational database design.
  3. Understand HTTP methods, status codes, JSON, and REST.
  4. Learn Spring Boot dependency injection and configuration.
  5. Build REST APIs with Spring Web.
  6. Connect applications to databases using Spring Data JPA.
  7. Learn React components, state, forms, and effects.
  8. Use Git for version control.
  9. Write unit and integration tests.
  10. Add authentication with Spring Security.
  11. Containerize the application with Docker.
  12. Deploy the frontend, backend, and database separately.

Final Thoughts

This task manager gives you a complete Java full-stack project with a Spring Boot backend, React frontend, and PostgreSQL database.

You practiced:

  • Designing REST endpoints.
  • Creating a JPA entity.
  • Connecting Spring Boot to PostgreSQL.
  • Separating controller, service, and repository logic.
  • Validating API input.
  • Handling errors.
  • Calling APIs from React.
  • Managing frontend state.
  • Testing endpoints with curl.
  • Using Git to manage source code.

Do not stop after making the application work once. Add due dates, filtering, pagination, authentication, tests, Docker support, and deployment. Each improvement will help you understand how real full-stack applications are designed and maintained.

Top comments (0)