Build a Personal Book Tracker

Build a full-stack book tracker with Spring Boot, H2, and React/TypeScript.

Introduction

30 Second Summary

If you love reading, you probably have a growing list of books you want to track. Maybe some are half-finished, others are on your wish list, and a few you have already completed. Keeping all of that organized in your head gets messy fast.

In this project, you will build a full-stack Personal Book Tracker application. You will create a backend API that stores your books in a database and a frontend interface that lets you manage your reading list through a clean UI.

What You'll Build

A full-stack book tracking application with a Spring Boot REST API backend and a React + TypeScript frontend.

By the end of this project, you'll have:

  • A Spring Boot REST API with five endpoints that perform full CRUD operations on a Book entity.
  • An H2 embedded database storing your books with title, author, and reading status.
  • A React + TypeScript frontend with components for listing books, adding new ones, and updating reading status.
  • Secret Mission: Add filtering by reading status so you can view only books you are currently reading, finished, or want to read.

Are there any prerequisites?

None. This guide walks you through installing everything you need, including JDK 21, Node.js, and your code editor. No prior Java or React experience is required.

Set Up Your Project

Before you can build a full-stack application, you need the right tools installed and two projects created. The backend needs Java to run, and the frontend needs a modern build tool to bundle your React code.

In this step, you will install JDK 21, generate a Spring Boot project from the official starter site, and scaffold a React + TypeScript frontend using Vite.

In this step, get ready to:
  • Install JDK 21 and verify it works.
  • Generate a Spring Boot project with the right dependencies.
  • Scaffold a React + TypeScript frontend with Vite.
Install JDK 21

Spring Boot runs on Java, so you need the Java Development Kit (JDK) installed on your machine. This project uses JDK 21, the latest Long-Term Support release.

  • Open your terminal and check if Java is already installed by running:
java -version

✔️ I see a version starting with 21

You already have JDK 21 installed. Skip ahead to the next substep.

ⓧ I see an older version

You have Java installed, but it is an older version. You need JDK 21 for this project. Install the correct version alongside your existing one.

Windows

  • Open your browser and go to https://adoptium.net.
  • Select JDK 21 - LTS and download the Windows .msi installer.
  • Run the installer.
  • When prompted, check the box for Set JAVA_HOME variable. This ensures your system knows where Java lives.
  • Complete the installation wizard.
  • Close your current terminal and open a new one (the new PATH settings only take effect in a fresh terminal).
  • Verify the installation by running:
java -version

You should see output containing a version number starting with 21.

macOS

  • Open your browser and go to https://adoptium.net.
  • Select JDK 21 - LTS and download the macOS .pkg installer.
  • Open the downloaded file and follow the prompts to install.
  • Close your current terminal and open a new one.
  • Verify the installation by running:
java -version

You should see output containing a version number starting with 21.

ⓧ Command not found

Java is not installed on your machine. Follow the steps below to install JDK 21 from Eclipse Temurin (Adoptium).

Windows

  • Open your browser and go to https://adoptium.net.
  • Select JDK 21 - LTS and download the Windows .msi installer.
  • Run the installer.
  • When prompted, check the box for Set JAVA_HOME variable. This ensures your system knows where Java lives.
  • Complete the installation wizard.
  • Close your current terminal and open a new one (the new PATH settings only take effect in a fresh terminal).
  • Verify the installation by running:
java -version

You should see output containing a version number starting with 21.

macOS

  • Open your browser and go to https://adoptium.net.
  • Select JDK 21 - LTS and download the macOS .pkg installer.
  • Open the downloaded file and follow the prompts to install.
  • Close your current terminal and open a new one.
  • Verify the installation by running:
java -version

You should see output containing a version number starting with 21.

What is JDK 21?

The Java Development Kit (JDK) includes the compiler and runtime you need to build and run Java applications. Version 21 is a Long-Term Support (LTS) release, meaning it receives updates and security patches for years.

Eclipse Temurin is a free, open-source distribution of the JDK maintained by the Adoptium community.

Still seeing an older version?

If you installed JDK 21 but java -version still shows an older version, your system PATH may be pointing to the old installation. Make sure you opened a brand new terminal after installing. On Windows, the Temurin installer updates PATH automatically when Set JAVA_HOME variable is checked.

help me fix my Java version

Generate the Spring Boot project

Spring Initializr is a web tool that generates a ready-to-go Spring Boot project with your chosen dependencies bundled in. Instead of manually creating folders and configuration files, you get a working project skeleton in seconds.

  • Open your browser and go to https://start.spring.io.
  • Set Project to Maven.
  • Set Language to Java.
  • Set Spring Boot to 4.0.7.
  • Under Project Metadata, set Group to com.example.
  • Set Artifact to booktracker.
  • Set Packaging to Jar.
  • Set Java to 21.
  • Click ADD DEPENDENCIES and search for Spring Web. Select it.
  • Search for Spring Data JPA and select it.
  • Search for H2 Database and select it.
  • Click GENERATE to download a .zip file.

Why these three dependencies?

Spring Web lets you create REST API endpoints. Spring Data JPA handles database operations without writing SQL manually. H2 Database gives you a lightweight, in-memory database that runs inside your app with zero setup.

  • Extract the downloaded booktracker.zip file to your Desktop.
  • Open your IDE (VS Code or Antigravity).
  • Open the extracted booktracker folder in your IDE. In VS Code, click File in the top menu, then Open Folder, and select the booktracker folder on your Desktop.

You should see a project structure with folders like src/main/java and files like pom.xml and mvnw in the Explorer sidebar.

What is the Maven Wrapper?

The mvnw (Unix) and mvnw.cmd (Windows) files are the Maven Wrapper. They let you build and run your project without installing Maven separately. Spring Initializr includes them automatically.

Don't see the project structure?

Make sure you opened the booktracker folder itself, not the folder that contains it. The pom.xml file should be at the top level of what you opened.

help me find my Spring Boot project files

Scaffold the React frontend

Your frontend will live in a separate folder alongside the backend. Vite scaffolds a React + TypeScript project with everything pre-configured for fast development.

  • Open a terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar, type Terminal (macOS) or PowerShell (Windows), and press Enter.
  • Navigate to your Desktop by running this command:
cd ~/Desktop
  • Scaffold the React + TypeScript project by running:
npm create vite@latest booktracker-frontend -- --template react-ts
  • Move into the new project folder and install its dependencies by running:
cd booktracker-frontend
npm install

What does this command do?

npm create vite@latest uses the Vite scaffolding tool to generate a project. The --template react-ts flag tells it to use the React + TypeScript template. Then npm install downloads all the libraries your project depends on.

  • Start the development server to verify everything works by running:
npm run dev

You should see output in your terminal showing the dev server is running, with a URL pointing to http://localhost:5173.

  • Open http://localhost:5173 in your browser. You should see the Vite + React welcome page.
  • Go back to your terminal and press Ctrl+C to stop the dev server. You will restart it later when building the frontend.

Don't see the welcome page?

Make sure npm install completed without errors before running npm run dev. If you see a port conflict, another process may be using port 5173. Close other dev servers and try again.

help me get Vite running

Your development environment is ready. Next up, you will build the Book entity and configure the H2 database so your backend can store data.

Build the Book Entity and Configure H2

In Step 1, you set up your development environment and generated a Spring Boot project with the right dependencies. Now it's time to define what a "book" actually looks like in your application.

Every application that stores data needs a clear definition of the data it manages. In this step, you'll create a Book entity, connect it to an in-memory database, and build a repository layer that handles all the database operations for you.

In this step, get ready to:
  • Create a Book entity class with JPA annotations.
  • Configure an H2 in-memory database and verify the table was auto-created.
  • Build a BookRepository interface and practice SQL queries in the H2 Console.
Create the Book entity class

In Spring Boot, an entity is a Java class that maps directly to a database table. Each field in the class becomes a column in the table. JPA (Java Persistence API) annotations tell the framework how to translate between your Java objects and database rows.

  • In your IDE, create a new folder called model inside src/main/java/com/example/booktracker/.
  • Create a new file called Book.java inside the model folder.
  • Paste the following code into Book.java:
package com.example.booktracker.model;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity
public class Book {

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

    private String title;
    private String author;
    private String status;

    public Book() {}

    public Book(String title, String author, String status) {
        this.title = title;
        this.author = author;
        this.status = status;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }
    public String getAuthor() { return author; }
    public void setAuthor(String author) { this.author = author; }
    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
}

What does this code do?

  • @Entity tells JPA that this class represents a database table.
  • @Id marks the id field as the primary key.
  • @GeneratedValue(strategy = GenerationType.IDENTITY) tells the database to auto-increment the ID for each new book.
  • The status field stores one of three values: READING, FINISHED, or WANT_TO_READ.
  • The empty constructor Book() is required by JPA for creating objects from database rows.
  • Getters and setters let the framework read and write field values.
  • Save Book.java.

You won't see a visible change yet. The next substep configures the database so Spring Boot knows where to store Book data.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

package com.example.booktracker.model;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity
public class Book {

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

    private String title;
    private String author;
    private String status;

    public Book() {}

    public Book(String title, String author, String status) {
        this.title = title;
        this.author = author;
        this.status = status;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }
    public String getAuthor() { return author; }
    public void setAuthor(String author) { this.author = author; }
    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
}
Configure H2 and start the application

H2 is a lightweight, in-memory database that runs inside your Java application. It resets every time you restart the app, which makes it perfect for development. Spring Boot's application.properties file is where you tell it how to connect.

  • Open src/main/resources/application.properties in your IDE.
  • Replace its contents with the following configuration:
spring.application.name=booktracker
spring.datasource.url=jdbc:h2:mem:bookdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.h2.console.enabled=true

What does this configuration do?

  • spring.datasource.url=jdbc:h2:mem:bookdb creates an in-memory database called bookdb. Data lives only while the app is running.
  • spring.jpa.hibernate.ddl-auto=update tells Hibernate to auto-create database tables from your entity classes.
  • spring.jpa.show-sql=true prints the SQL queries Hibernate runs in your terminal, which is great for learning.
  • spring.h2.console.enabled=true turns on a web-based SQL console at /h2-console so you can inspect your database in the browser.
  • Save application.properties.
  • Start your Spring Boot application by running this command in the terminal (from the booktracker folder):
.\mvnw spring-boot:run

Wait for the startup to finish. You should see a line in the terminal that says the application started on port 8080.

Seeing a build error?

  • Make sure you are running the command from inside the booktracker folder (where mvnw.cmd lives).
  • Check that Book.java is in the correct path: src/main/java/com/example/booktracker/model/Book.java.
  • Verify that JAVA_HOME points to JDK 21. Run java -version to confirm.

Help me debug my Spring Boot startup error

  • Open your browser and navigate to http://localhost:8080/h2-console.
  • In the JDBC URL field, enter jdbc:h2:mem:bookdb.
  • Leave User Name as sa and Password empty.
  • Click Connect.

You should see a BOOK table listed in the left panel. Hibernate auto-created it from your Book entity class.

Why is there a table already?

The spring.jpa.hibernate.ddl-auto=update setting told Hibernate to scan your entity classes on startup and create matching tables automatically. The BOOK table has columns for ID, TITLE, AUTHOR, and STATUS because those are the fields in your Book.java class.

✔️ Awesome, I've got everything!

Great. Make sure you can see the BOOK table in the H2 Console before continuing.

ⓧ I'd like to double check the full code

spring.application.name=booktracker
spring.datasource.url=jdbc:h2:mem:bookdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.h2.console.enabled=true
Create the BookRepository interface

Spring Data JPA eliminates boilerplate database code. Instead of writing SQL queries manually, you create a repository interface and Spring auto-generates the implementation at runtime.

  • Create a new folder called repository inside src/main/java/com/example/booktracker/.
  • Create a new file called BookRepository.java inside the repository folder.
  • Paste the following code into BookRepository.java:
package com.example.booktracker.repository;

import com.example.booktracker.model.Book;
import org.springframework.data.jpa.repository.JpaRepository;

public interface BookRepository extends JpaRepository<Book, Long> {
}

What does this give you?

By extending JpaRepository<Book, Long>, your interface automatically inherits methods like findAll(), findById(), save(), and deleteById().

You don't write any implementation code. Spring generates it for you at startup based on the entity type (Book) and ID type (Long).

  • Save BookRepository.java.
  • Stop the running application in your terminal by pressing Ctrl+C.
  • Restart the application by running:
.\mvnw spring-boot:run

Now practice writing SQL directly in the H2 Console to see how data gets stored.

  • Return to http://localhost:8080/h2-console in your browser and reconnect (JDBC URL: jdbc:h2:mem:bookdb, User Name: sa, password empty).
  • Type the following SQL query in the text area and click Run:
INSERT INTO BOOK (TITLE, AUTHOR, STATUS) VALUES ('The Great Gatsby', 'F. Scott Fitzgerald', 'WANT_TO_READ');
  • Now run a SELECT query to verify the data was inserted:
SELECT * FROM BOOK;

You should see one row with the book you just inserted. This is the same table your Spring Boot application reads from and writes to.

Why practice SQL manually?

In production, your repository handles all database operations through Java methods. But understanding the SQL underneath helps you debug issues and write custom queries when the auto-generated ones aren't enough.

Don't see the BOOK table after reconnecting?

  • H2 is in-memory. Every time you restart the app, the database resets. Reconnect after each restart.
  • Make sure the JDBC URL is exactly jdbc:h2:mem:bookdb. A typo creates a different empty database.

Help me connect to the H2 Console

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before moving on.

ⓧ I'd like to double check the full code

package com.example.booktracker.repository;

import com.example.booktracker.model.Book;
import org.springframework.data.jpa.repository.JpaRepository;

public interface BookRepository extends JpaRepository<Book, Long> {
}

Your data layer is complete. You have a Book entity, a configured database, and a repository ready to handle CRUD operations. Next up, you'll build the REST API that exposes these operations as HTTP endpoints.

Build the REST API

In the previous step, you created the Book entity and configured the H2 database. Your app knows what a book looks like, but it has no way for the outside world to interact with books.

In this step, you will build a REST API that exposes endpoints for creating, reading, updating, and deleting books. You will follow the layered architecture pattern: a Service layer handles business logic, and a Controller layer maps HTTP requests to service methods.

In this step, get ready to:
  • Create a BookService class with full CRUD operations.
  • Create a BookController class that maps HTTP endpoints to service methods.
  • Configure CORS so the React frontend can talk to the backend.
Create the service layer

The service layer sits between the controller and the repository. It contains your business logic and keeps controllers thin.

  • In your IDE, create a new folder called service inside src/main/java/com/example/booktracker/.
  • Create a new file called BookService.java inside the service folder, then paste in this code:
package com.example.booktracker.service;

import com.example.booktracker.model.Book;
import com.example.booktracker.repository.BookRepository;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

// Marks this class as a Spring-managed service bean
@Service
public class BookService {

    // Spring injects the BookRepository automatically
    private final BookRepository bookRepository;

    public BookService(BookRepository bookRepository) {
        this.bookRepository = bookRepository;
    }

    // Return all books from the database
    public List<Book> getAllBooks() {
        return bookRepository.findAll();
    }

    // Find a single book by its ID
    public Optional<Book> getBookById(Long id) {
        return bookRepository.findById(id);
    }

    // Save a new book to the database
    public Book createBook(Book book) {
        return bookRepository.save(book);
    }

    // Update an existing book's fields if it exists
    public Optional<Book> updateBook(Long id, Book updatedBook) {
        return bookRepository.findById(id).map(book -> {
            book.setTitle(updatedBook.getTitle());
            book.setAuthor(updatedBook.getAuthor());
            book.setStatus(updatedBook.getStatus());
            return bookRepository.save(book);
        });
    }

    // Remove a book by its ID
    public void deleteBook(Long id) {
        bookRepository.deleteById(id);
    }
}

What does this code do?

The @Service annotation tells Spring Boot to manage this class as a bean. Spring automatically injects the BookRepository through the constructor.

Each method wraps a single repository call. The updateBook method uses Optional.map() to only update the book if it exists. If the ID is not found, it returns an empty Optional instead of throwing an error.

  • Save BookService.java.

You will not see a change yet. The controller in the next substep uses this service to handle HTTP requests.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

package com.example.booktracker.service;

import com.example.booktracker.model.Book;
import com.example.booktracker.repository.BookRepository;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service
public class BookService {

    private final BookRepository bookRepository;

    public BookService(BookRepository bookRepository) {
        this.bookRepository = bookRepository;
    }

    public List<Book> getAllBooks() {
        return bookRepository.findAll();
    }

    public Optional<Book> getBookById(Long id) {
        return bookRepository.findById(id);
    }

    public Book createBook(Book book) {
        return bookRepository.save(book);
    }

    public Optional<Book> updateBook(Long id, Book updatedBook) {
        return bookRepository.findById(id).map(book -> {
            book.setTitle(updatedBook.getTitle());
            book.setAuthor(updatedBook.getAuthor());
            book.setStatus(updatedBook.getStatus());
            return bookRepository.save(book);
        });
    }

    public void deleteBook(Long id) {
        bookRepository.deleteById(id);
    }
}
Create the controller

The controller is the front door of your API. It maps incoming HTTP requests to the service methods you just wrote.

  • Create a new folder called controller inside src/main/java/com/example/booktracker/.
  • Create a new file called BookController.java inside the controller folder, then paste in this code:
package com.example.booktracker.controller;

import com.example.booktracker.model.Book;
import com.example.booktracker.service.BookService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

// Tells Spring this class handles HTTP requests and returns JSON
@RestController
// All endpoints in this class start with /api/books
@RequestMapping("/api/books")
public class BookController {

    private final BookService bookService;

    // Spring injects BookService through the constructor
    public BookController(BookService bookService) {
        this.bookService = bookService;
    }

    // GET /api/books - returns all books as JSON
    @GetMapping
    public List<Book> getAllBooks() {
        return bookService.getAllBooks();
    }

    // GET /api/books/{id} - returns one book or 404
    @GetMapping("/{id}")
    public ResponseEntity<Book> getBookById(@PathVariable Long id) {
        return bookService.getBookById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    // POST /api/books - creates a new book from the request body
    @PostMapping
    public Book createBook(@RequestBody Book book) {
        return bookService.createBook(book);
    }

    // PUT /api/books/{id} - updates an existing book or returns 404
    @PutMapping("/{id}")
    public ResponseEntity<Book> updateBook(@PathVariable Long id, @RequestBody Book book) {
        return bookService.updateBook(id, book)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    // DELETE /api/books/{id} - removes a book and returns 204 No Content
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteBook(@PathVariable Long id) {
        bookService.deleteBook(id);
        return ResponseEntity.noContent().build();
    }
}

What does this code do?

The @RestController annotation combines @Controller and @ResponseBody. Every method return value is automatically serialized to JSON.

The @RequestMapping("/api/books") sets the base path for all endpoints. Each method then adds its own path segment using @GetMapping, @PostMapping, @PutMapping, or @DeleteMapping.

The ResponseEntity wrapper lets you control the HTTP status code. When a book is not found, the endpoint returns a 404 instead of crashing.

  • Save BookController.java.

You will not see a change yet. The API needs CORS configuration before the frontend can access it.

Seeing red underlines in your IDE?

If your IDE shows errors on the import lines, make sure you saved BookService.java first. The controller depends on the service class existing.

help me fix import errors in my BookController

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

package com.example.booktracker.controller;

import com.example.booktracker.model.Book;
import com.example.booktracker.service.BookService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/books")
public class BookController {

    private final BookService bookService;

    public BookController(BookService bookService) {
        this.bookService = bookService;
    }

    @GetMapping
    public List<Book> getAllBooks() {
        return bookService.getAllBooks();
    }

    @GetMapping("/{id}")
    public ResponseEntity<Book> getBookById(@PathVariable Long id) {
        return bookService.getBookById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    public Book createBook(@RequestBody Book book) {
        return bookService.createBook(book);
    }

    @PutMapping("/{id}")
    public ResponseEntity<Book> updateBook(@PathVariable Long id, @RequestBody Book book) {
        return bookService.updateBook(id, book)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteBook(@PathVariable Long id) {
        bookService.deleteBook(id);
        return ResponseEntity.noContent().build();
    }
}
Configure CORS and test the API

Your React frontend will run on port 5173 and your backend runs on port 8080. Browsers block requests between different origins by default. CORS configuration tells the backend which origins are allowed to make requests.

  • Create a new folder called config inside src/main/java/com/example/booktracker/.
  • Create a new file called WebConfig.java inside the config folder, then paste in this code:
package com.example.booktracker.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

// Registers this class as a Spring configuration bean
@Configuration
public class WebConfig implements WebMvcConfigurer {

    // Allow the React frontend to make requests to our API
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("http://localhost:5173")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("*")
                .maxAge(3600);
    }
}

What does this code do?

The addCorsMappings method registers a CORS rule for all endpoints under /api/**. It permits requests from http://localhost:5173 (where Vite serves your React app).

The maxAge(3600) tells browsers to cache the CORS preflight response for one hour so they do not re-check on every request.

  • Save WebConfig.java.
  • If your backend is still running from the previous step, stop it by pressing Ctrl+C in the terminal.
  • Restart the backend by running:
.\mvnw spring-boot:run
  • Wait for the Spring Boot startup banner to appear. Once you see the line that says Started BooktrackerApplication, the server is ready.
  • Open a second terminal and test your API by running:
curl http://localhost:8080/api/books

You should see [] printed in the terminal. This empty JSON array means your API is running and the books table has no entries yet.

Not seeing an empty array?

If you see a connection refused error, check that the backend finished starting up. Look for the Started BooktrackerApplication message in the first terminal.

If you see an HTML error page, make sure the URL path is exactly /api/books (not /books or /api/book).

help me debug why curl is not returning an empty array from my Spring Boot API

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

package com.example.booktracker.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("http://localhost:5173")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("*")
                .maxAge(3600);
    }
}

Why a global CORS config instead of per-controller?

Spring Boot offers a @CrossOrigin annotation you can place on individual controllers. A global config in WebConfig is better for a real project because it applies the same rules to every endpoint automatically.

If you add more controllers later, they inherit the CORS rules without any extra annotations.

Your REST API is live and accepting requests. Next up, you will build the React frontend that calls these endpoints and displays your books in the browser.

Build the React Frontend

Your backend API is live and serving data. Now it is time to build the user interface that talks to it.

In this step, you will create React components that display books, accept new entries, and update reading status. Each component calls your Spring Boot API over HTTP using the browser's built-in fetch function.

In this step, get ready to:
  • Define a TypeScript interface and create an AddBookForm component.
  • Build a BookList component that renders book cards with inline controls.
  • Wire everything together in App.tsx with API calls and styling.
Define the Book type and create the AddBookForm

Before writing any components, you need a TypeScript interface that describes the shape of a Book object. This gives you type safety across the entire frontend.

  • In your IDE, navigate to the booktracker-frontend/src folder.
  • Create a new file called types.ts and add the following code:
// Describes the shape of a Book object from the API
export interface Book {
  id: number;
  title: string;
  author: string;
  status: string;
}

What does this code do?

The Book interface mirrors the fields your Spring Boot entity exposes through the API. Every component that handles book data will import this interface so TypeScript can catch mismatches at compile time.

  • Save types.ts.
  • You will not see a change yet. The next chunk uses this interface.

✔️ Awesome, I've got everything!

Great. Double check you have saved types.ts before moving on.

ⓧ I'd like to double check the full code

export interface Book {
  id: number;
  title: string;
  author: string;
  status: string;
}

Now you will build the form component that lets users add new books. It needs input fields for the title, author, and reading status.

  • Inside booktracker-frontend/src, create a new folder called components.
  • Inside components, create a file called AddBookForm.tsx and add the following code:
import { useState } from "react";

// Props interface: parent passes an onAdd callback
interface AddBookFormProps {
  onAdd: (title: string, author: string, status: string) => void;
}

function AddBookForm({ onAdd }: AddBookFormProps) {
  // Local state for each form field
  const [title, setTitle] = useState("");
  const [author, setAuthor] = useState("");
  const [status, setStatus] = useState("WANT_TO_READ");

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    // Prevent empty submissions
    if (!title.trim() || !author.trim()) return;
    onAdd(title, author, status);
    // Reset fields after submission
    setTitle("");
    setAuthor("");
    setStatus("WANT_TO_READ");
  };

  return (
    <form onSubmit={handleSubmit} className="add-book-form">
      <input type="text" placeholder="Title" value={title} onChange={(e) => setTitle(e.target.value)} />
      <input type="text" placeholder="Author" value={author} onChange={(e) => setAuthor(e.target.value)} />
      <select value={status} onChange={(e) => setStatus(e.target.value)}>
        <option value="WANT_TO_READ">Want to Read</option>
        <option value="READING">Reading</option>
        <option value="FINISHED">Finished</option>
      </select>
      <button type="submit">Add Book</button>
    </form>
  );
}

export default AddBookForm;

What does this code do?

The AddBookFormProps interface defines a contract: the parent component must pass an onAdd function that accepts title, author, and status strings.

Each input field is controlled by React state via useState. The handleSubmit function prevents empty submissions with a trim check, calls the parent's callback, then resets all fields.

  • Save AddBookForm.tsx.
  • You will not see a change yet. The form will render once you wire it into App.tsx later in this step.

Seeing red squiggly lines in your editor?

If your editor shows import errors on useState, make sure you are editing files inside the booktracker-frontend folder (not the backend). The React types are only available inside the frontend project.

Help me debug this

✔️ Awesome, I've got everything!

Great. Double check you have saved AddBookForm.tsx before moving on.

ⓧ I'd like to double check the full code

import { useState } from "react";

interface AddBookFormProps {
  onAdd: (title: string, author: string, status: string) => void;
}

function AddBookForm({ onAdd }: AddBookFormProps) {
  const [title, setTitle] = useState("");
  const [author, setAuthor] = useState("");
  const [status, setStatus] = useState("WANT_TO_READ");

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!title.trim() || !author.trim()) return;
    onAdd(title, author, status);
    setTitle("");
    setAuthor("");
    setStatus("WANT_TO_READ");
  };

  return (
    <form onSubmit={handleSubmit} className="add-book-form">
      <input type="text" placeholder="Title" value={title} onChange={(e) => setTitle(e.target.value)} />
      <input type="text" placeholder="Author" value={author} onChange={(e) => setAuthor(e.target.value)} />
      <select value={status} onChange={(e) => setStatus(e.target.value)}>
        <option value="WANT_TO_READ">Want to Read</option>
        <option value="READING">Reading</option>
        <option value="FINISHED">Finished</option>
      </select>
      <button type="submit">Add Book</button>
    </form>
  );
}

export default AddBookForm;
Create the BookList component

The BookList component is responsible for rendering each book as a card. Each card shows the title, author, a status dropdown for inline updates, and a delete button.

  • Inside the src/components folder, create a new file called BookList.tsx and add the following code:
import { Book } from "../types";

// Props: array of books plus callbacks for delete and status change
interface BookListProps {
  books: Book[];
  onDelete: (id: number) => void;
  onStatusChange: (id: number, status: string) => void;
}

function BookList({ books, onDelete, onStatusChange }: BookListProps) {
  // Show a message when the list is empty
  if (books.length === 0) {
    return <p className="empty-message">No books yet. Add one above!</p>;
  }

  return (
    <div className="book-list">
      {books.map((book) => (
        <div key={book.id} className="book-card">
          <div className="book-info">
            <h3>{book.title}</h3>
            <p>{book.author}</p>
          </div>
          <div className="book-actions">
            <select value={book.status} onChange={(e) => onStatusChange(book.id, e.target.value)}>
              <option value="WANT_TO_READ">Want to Read</option>
              <option value="READING">Reading</option>
              <option value="FINISHED">Finished</option>
            </select>
            <button onClick={() => onDelete(book.id)} className="delete-btn">Delete</button>
          </div>
        </div>
      ))}
    </div>
  );
}

export default BookList;

What does this code do?

The BookListProps interface requires three things from the parent: the array of books, a delete callback, and a status-change callback. The component does not manage state itself. It just renders whatever the parent gives it.

Each book renders as a card with a select dropdown bound to the current status. Changing the dropdown fires onStatusChange immediately, so the parent can send a PUT request to the API.

  • Save BookList.tsx.
  • You will not see a change yet. The next substep wires both components into App.tsx where you will see everything render.

Import error on Book type?

Make sure your types.ts file is saved in src/types.ts (not inside src/components). The import path "../types" goes one folder up from components to src.

Help me fix my import paths

✔️ Awesome, I've got everything!

Great. Double check you have saved BookList.tsx before moving on.

ⓧ I'd like to double check the full code

import { Book } from "../types";

interface BookListProps {
  books: Book[];
  onDelete: (id: number) => void;
  onStatusChange: (id: number, status: string) => void;
}

function BookList({ books, onDelete, onStatusChange }: BookListProps) {
  if (books.length === 0) {
    return <p className="empty-message">No books yet. Add one above!</p>;
  }

  return (
    <div className="book-list">
      {books.map((book) => (
        <div key={book.id} className="book-card">
          <div className="book-info">
            <h3>{book.title}</h3>
            <p>{book.author}</p>
          </div>
          <div className="book-actions">
            <select value={book.status} onChange={(e) => onStatusChange(book.id, e.target.value)}>
              <option value="WANT_TO_READ">Want to Read</option>
              <option value="READING">Reading</option>
              <option value="FINISHED">Finished</option>
            </select>
            <button onClick={() => onDelete(book.id)} className="delete-btn">Delete</button>
          </div>
        </div>
      ))}
    </div>
  );
}

export default BookList;
Wire everything together in App.tsx and App.css

Your two components are ready, but they are not connected to the API yet. The App.tsx file is where you fetch data, handle user actions, and pass everything down to the components.

  • Open src/App.tsx in the booktracker-frontend folder.
  • Replace all of its content with the following code:
import { useEffect, useState } from "react";
import AddBookForm from "./components/AddBookForm";
import BookList from "./components/BookList";
import { Book } from "./types";
import "./App.css";

// Base URL for all API requests
const API_URL = "http://localhost:8080/api/books";

function App() {
  // State: the current list of books from the API
  const [books, setBooks] = useState<Book[]>([]);

  // Fetch all books from the backend
  const fetchBooks = async () => {
    const response = await fetch(API_URL);
    const data = await response.json();
    setBooks(data);
  };

  // Load books when the component first mounts
  useEffect(() => {
    fetchBooks();
  }, []);

  // POST a new book, then refresh the list
  const handleAdd = async (title: string, author: string, status: string) => {
    await fetch(API_URL, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title, author, status }),
    });
    fetchBooks();
  };

  // DELETE a book by ID, then refresh the list
  const handleDelete = async (id: number) => {
    await fetch(`${API_URL}/${id}`, { method: "DELETE" });
    fetchBooks();
  };

  // PUT an updated status for a book, then refresh the list
  const handleStatusChange = async (id: number, status: string) => {
    const book = books.find((b) => b.id === id);
    if (!book) return;
    await fetch(`${API_URL}/${id}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ ...book, status }),
    });
    fetchBooks();
  };

  return (
    <div className="app">
      <h1>My Book Tracker</h1>
      <AddBookForm onAdd={handleAdd} />
      <BookList books={books} onDelete={handleDelete} onStatusChange={handleStatusChange} />
    </div>
  );
}

export default App;

What does this code do?

  • fetchBooks calls GET on your API and stores the result in state.
  • useEffect with an empty dependency array triggers fetchBooks once when the page loads.
  • handleAdd sends a POST request with the new book data as JSON, then refreshes the list.
  • handleDelete sends a DELETE request, and handleStatusChange sends a PUT with the updated book object.
  • Save App.tsx.
  • You will not see a styled result yet. The next chunk adds the CSS.

Seeing module not found errors?

Double check that AddBookForm.tsx and BookList.tsx are inside src/components/ and that types.ts is directly inside src/. The import paths in App.tsx assume this exact folder structure.

Help me check my file structure

✔️ Awesome, I've got everything!

Great. Double check you have saved App.tsx before moving on.

ⓧ I'd like to double check the full code

import { useEffect, useState } from "react";
import AddBookForm from "./components/AddBookForm";
import BookList from "./components/BookList";
import { Book } from "./types";
import "./App.css";

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

function App() {
  const [books, setBooks] = useState<Book[]>([]);

  const fetchBooks = async () => {
    const response = await fetch(API_URL);
    const data = await response.json();
    setBooks(data);
  };

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

  const handleAdd = async (title: string, author: string, status: string) => {
    await fetch(API_URL, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title, author, status }),
    });
    fetchBooks();
  };

  const handleDelete = async (id: number) => {
    await fetch(`${API_URL}/${id}`, { method: "DELETE" });
    fetchBooks();
  };

  const handleStatusChange = async (id: number, status: string) => {
    const book = books.find((b) => b.id === id);
    if (!book) return;
    await fetch(`${API_URL}/${id}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ ...book, status }),
    });
    fetchBooks();
  };

  return (
    <div className="app">
      <h1>My Book Tracker</h1>
      <AddBookForm onAdd={handleAdd} />
      <BookList books={books} onDelete={handleDelete} onStatusChange={handleStatusChange} />
    </div>
  );
}

export default App;

Now you need styles so the form and cards look presentable. The app uses a simple layout with flexbox for the form row and card grid.

  • Open src/App.css in the booktracker-frontend folder.
  • Replace all of its content with the following styles:
/* Main app container */
.app {
  max-width: 700px;
  margin: 0 auto;
  padding: 2rem;
  font-family: system-ui, -apple-system, sans-serif;
}

h1 {
  text-align: center;
  margin-bottom: 2rem;
}

/* Form layout: horizontal row that wraps on small screens */
.add-book-form {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 2rem;
  flex-wrap: wrap;
}

.add-book-form input,
.add-book-form select {
  padding: 0.5rem;
  border: 1px solid #ccc;
  border-radius: 4px;
  flex: 1;
  min-width: 120px;
}

.add-book-form button {
  padding: 0.5rem 1rem;
  background: #4f46e5;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.add-book-form button:hover {
  background: #4338ca;
}

/* Book cards stacked vertically */
.book-list {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

.book-card {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1rem;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
}

.book-info h3 {
  margin: 0 0 0.25rem 0;
}

.book-info p {
  margin: 0;
  color: #6b7280;
}

.book-actions {
  display: flex;
  gap: 0.5rem;
  align-items: center;
}

.book-actions select {
  padding: 0.25rem;
  border: 1px solid #ccc;
  border-radius: 4px;
}

/* Red delete button */
.delete-btn {
  padding: 0.25rem 0.75rem;
  background: #ef4444;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.delete-btn:hover {
  background: #dc2626;
}

.empty-message {
  text-align: center;
  color: #6b7280;
}

What do these styles do?

  • .app centers the page content in a 700px column.
  • .add-book-form lays out the inputs in a horizontal row that wraps on narrow screens.
  • .book-card uses flexbox to push the book info left and the action controls right.
  • .delete-btn uses a red background to clearly distinguish it from other controls.
  • Save App.css.
  • Start your frontend dev server by running this command from the booktracker-frontend folder:
npm run dev
  • Open http://localhost:5173 in your browser.
  • You should see the heading "My Book Tracker", a horizontal form with Title and Author inputs, a status dropdown, and an Add Book button. Below the form, the message "No books yet. Add one above!" should appear.

Page is blank or shows Vite default content?

Make sure you replaced the entire contents of App.tsx and App.css. If the Vite default template is still showing, the old code is still in those files.

Also check your terminal for compile errors. TypeScript will show you the exact file and line if something is wrong.

Help me debug my frontend

✔️ Awesome, I've got everything!

Great. Double check you have saved both App.tsx and App.css.

ⓧ I'd like to double check the full code

.app {
  max-width: 700px;
  margin: 0 auto;
  padding: 2rem;
  font-family: system-ui, -apple-system, sans-serif;
}

h1 {
  text-align: center;
  margin-bottom: 2rem;
}

.add-book-form {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 2rem;
  flex-wrap: wrap;
}

.add-book-form input,
.add-book-form select {
  padding: 0.5rem;
  border: 1px solid #ccc;
  border-radius: 4px;
  flex: 1;
  min-width: 120px;
}

.add-book-form button {
  padding: 0.5rem 1rem;
  background: #4f46e5;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.add-book-form button:hover {
  background: #4338ca;
}

.book-list {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

.book-card {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 1rem;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
}

.book-info h3 {
  margin: 0 0 0.25rem 0;
}

.book-info p {
  margin: 0;
  color: #6b7280;
}

.book-actions {
  display: flex;
  gap: 0.5rem;
  align-items: center;
}

.book-actions select {
  padding: 0.25rem;
  border: 1px solid #ccc;
  border-radius: 4px;
}

.delete-btn {
  padding: 0.25rem 0.75rem;
  background: #ef4444;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.delete-btn:hover {
  background: #dc2626;
}

.empty-message {
  text-align: center;
  color: #6b7280;
}

Your frontend is live and connected to the API. Next up, you will test the full flow end-to-end by adding books, updating their status, and verifying the data in the H2 Console.

Connect and Test End-to-End

Your backend API and React frontend are complete. Now it's time to run them side by side and prove the entire system works together.

In this step, you'll start both servers simultaneously and walk through every operation your app supports. You'll add books, update their reading status, delete entries, and verify the data directly in the H2 Console using SQL.

In this step, get ready to:
  • Start both the backend and frontend servers at the same time.
  • Test the full CRUD flow through the browser UI.
  • Verify stored data in the H2 Console and confirm form validation.
Start both servers

Your application needs two processes running at the same time: the Spring Boot backend serving the API, and the Vite dev server delivering the React frontend.

  • Open a terminal and navigate to your booktracker backend folder.
  • Start the Spring Boot backend by running this command:

macOS

./mvnw spring-boot:run

Windows

.\mvnw spring-boot:run
  • Wait until you see a line in the terminal mentioning that the application has started on port 8080.
  • Open a second terminal window and navigate to your booktracker-frontend folder.
  • Start the frontend dev server by running:
npm run dev
  • Open your browser and go to http://localhost:5173.

You should see the "My Book Tracker" heading with an empty form and the message "No books yet. Add one above!" below it.

Seeing a blank page or network error?

Make sure the backend terminal is still running and shows no errors. If port 8080 is already in use, stop the other process occupying it and try again.

Check that the frontend terminal shows the Vite dev server running on port 5173. If you see a CORS error in the browser console, confirm your WebConfig.java allows http://localhost:5173 as an origin.

Help me debug why my frontend can't connect to the backend API.

Test the full CRUD flow

With both servers running, you can now exercise every feature of your book tracker through the browser.

  • In the title field, type The Great Gatsby.
  • In the author field, type F. Scott Fitzgerald.
  • Leave the status dropdown on Want to Read.
  • Click Add Book.

The book should appear immediately as a card below the form, showing the title, author, and a status dropdown.

  • Add a second book: title Dune, author Frank Herbert, status Reading.
  • On the first book card, change the status dropdown from Want to Read to Finished.
  • Click the Delete button on the second book card.

After deleting, only "The Great Gatsby" should remain with its updated status of "Finished".

What just happened?

Each action triggered a different HTTP method to your backend. Adding a book sent a POST request. Changing the status sent a PUT request. Deleting sent a DELETE request.

After each operation, the frontend re-fetches the full book list with a GET request to keep the UI in sync with the database.

Now verify the data is actually stored in the database.

  • Open a new browser tab and go to http://localhost:8080/h2-console.
  • In the JDBC URL field, enter jdbc:h2:mem:bookdb.
  • In the User Name field, enter sa.
  • Leave the Password field empty.
  • Click Connect.

Match the JDBC URL exactly

The H2 Console may show a default JDBC URL that does not match your project. Make sure to replace it with jdbc:h2:mem:bookdb to connect to the correct in-memory database.

  • Run this SQL query in the H2 Console text box:
SELECT * FROM BOOK;

You should see a table with your remaining book ("The Great Gatsby") showing its id, title, author, and status columns.

Why verify in the H2 Console?

The H2 Console gives you direct access to the database your API writes to. This is the same skill you would use with any SQL database in a professional setting. Running SELECT queries helps you confirm that your API is persisting data correctly, independent of what the frontend displays.

Test validation and error handling

A production-ready app handles bad input gracefully. Your form already has client-side validation built in. Let's confirm it works.

  • Switch back to your app at http://localhost:5173.
  • Leave both the title and author fields empty.
  • Click Add Book.

Nothing should happen. The form silently prevents submission because the handleSubmit function checks that both title and author are non-empty before calling the API.

  • Now type a title but leave the author field empty.
  • Click Add Book.

Again, nothing submits. Both fields are required for the form to proceed.

Where does this validation live?

Open AddBookForm.tsx and find the line if (!title.trim() || !author.trim()) return; inside handleSubmit. This guard clause exits the function early when either field is blank or contains only whitespace.

  • Open your browser's developer console by pressing Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).
  • Try deleting a book that no longer exists by running this in the console:
// Attempt to delete a book with a non-existent ID
fetch('http://localhost:8080/api/books/9999', { method: 'DELETE' })
  .then(res => console.log('Status:', res.status));

You should see Status: 204 logged in the console. The backend returns a "no content" response regardless of whether the book existed, because the delete operation is idempotent.

What does idempotent mean here?

An idempotent operation produces the same result whether you run it once or multiple times. Deleting a book that does not exist does not cause an error. It simply confirms the resource is gone.

Seeing unexpected errors in the console?

If you see a CORS error, confirm your backend is still running in the other terminal. The H2 Console and your API share the same server process.

If the fetch returns a network error, double-check that you typed the URL correctly with port 8080 (not 5173).

Help me debug errors in my browser console when testing the book tracker API.

Secret mission

Filter Books by Reading Status

Your book tracker works great, but as your reading list grows, scrolling through every book becomes tedious. What if you could filter your list to show only the books you're currently reading, the ones you've finished, or the titles on your wish list? In this mission, you'll add query parameter filtering across the full stack.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • booktracker folder (Spring Boot backend project on your Desktop).
  • booktracker-frontend folder (React frontend project on your Desktop).
  • JDK 21 (Eclipse Temurin, installed during setup).

What about the H2 database?

The H2 database is in-memory only. It resets every time you restart the backend server. There is nothing to clean up for the database itself.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep experimenting with the app.

  • Your project folders stay on your Desktop.
  • JDK 21 stays installed for future Java projects.
  • You can restart the backend anytime with .\mvnw spring-boot:run from the booktracker folder.
  • You can restart the frontend anytime with npm run dev from the booktracker-frontend folder.

Pause - I'll come back to this later

Stop the running servers to free up terminal sessions and memory. Your files stay intact for next time.

  • In the terminal running the Spring Boot backend, press Ctrl+C to stop the server.
  • In the terminal running the Vite frontend, press Ctrl+C to stop the dev server.
  • Your project folders and JDK 21 installation remain untouched.

Delete - I don't want to use this again

Remove all project files and optionally uninstall JDK 21.

  • Stop both servers first by pressing Ctrl+C in each terminal.
  • Delete the backend project folder by running:
cd ~/Desktop
rm -rf booktracker
  • Delete the frontend project folder by running:
rm -rf booktracker-frontend
  • To uninstall JDK 21, open Settings on Windows.
  • Select Apps, then Installed apps.
  • Search for Eclipse Temurin JDK, click the three-dot menu, and select Uninstall.

Nice Work!

Nice Work!

You just built a full-stack Personal Book Tracker from scratch. You have a working Spring Boot backend connected to a React frontend, with real data persistence in between.

You learned how to:

  • Built a Spring Boot REST API with full CRUD endpoints following the Controller/Service/Repository layered architecture pattern.
  • Created a JPA entity and used Hibernate to auto-generate the database schema, then explored SQL queries through the H2 web console.
  • Built a React + TypeScript frontend with components, state management, and API integration, then connected it to the backend using CORS configuration.
  • Secret Mission: Added query parameter filtering with a custom JPA query method and a dynamic filter UI on the frontend.

Ready to quiz yourself?