StackTipsStackTips

Building Your First REST API with Spring Boot

A hands-on guide to building your first REST endpoints in Spring Boot using @RestController and @GetMapping, and returning JSON from your API.

August 17, 2026 · 30 min read

In the last chapter, we saw how logging works in Spring Boot. Now let's put everything we've built so far to use and create our first REST API.

What Is a REST API in Spring Boot?

A REST API lets other applications talk to your Spring Boot app over HTTP. A client sends a request to a URL, and your app sends back data, usually as JSON or XML. It's the standard way a frontend, a mobile app, or another service talks to a backend application like the one we're building.

Remember the Whitelabel error page we hit back in Chapter 3 when we opened http://localhost:8080? That happened because there was no endpoint to handle the request. Let's fix that now.

@RestController and @GetMapping Annotations Explained

Back in Chapter 6, we previewed two annotations we'd need for this: @RestController and @GetMapping. Here's what they actually do.

@RestController marks a class as a REST endpoint handler. It's a specialization of @Controller (itself a @Component), so Spring's component scanning picks it up automatically, just like any other bean. It also tells Spring to write every method's return value straight into the HTTP response body, instead of resolving it to an HTML view.

@GetMapping("/path") maps HTTP GET requests for that path to the method it's placed on.

Creating Your First REST Endpoint

Add this HelloController class to your project, right alongside the main class containing @SpringBootApplication:

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
 
@RestController
public class HelloController {
 
    @GetMapping("/hello")
    public String sayHello() {
        return "Hello, Spring Boot!";
    }
}

Run the app (./gradlew bootRun) and open http://localhost:8080/hello in your browser. Instead of the Whitelabel Error Page, you'll see:

Hello, Spring Boot!

That's your first working REST endpoint.

Returning a JSON Response from a Spring Boot Controller

This String response is sent back as text/plain, not JSON — which is fine for a quick test, but not what most real-world APIs return.

To return JSON, just return any Java object other than a String — a Map, a List, a record, a class with getters. Spring Boot uses the Jackson library added on classpath (via spring-boot-starter-web) to serialize these Java objects into JSON automatically.

Let's expand sayHello() to return a small status payload instead of a plain greeting:

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
 
import java.util.Map;
 
@RestController
public class HelloController {
 
    @GetMapping("/hello")
    public Map<String, Object> sayHello() {
        return Map.of(
                "message", "Hello, Spring Boot!",
                "status", "UP",
                "timestamp", System.currentTimeMillis()
        );
    }
}

Run the app again and refresh http://localhost:8080/hello. You'll see real JSON this time:

{
  "message": "Hello, Spring Boot!",
  "status": "UP",
  "timestamp": 1755400000000
}

Testing REST API with cURL

A browser works fine for GET requests, but it won't show you response headers. For that, we could either use a REST client like Postman or the curl command:

curl -i http://localhost:8080/hello

The -i flag prints the response headers along with the body — look for Content-Type: application/json, which is Spring Boot confirming it serialized your response as JSON:

HTTP/1.1 200 OK
Content-Type: application/json
...
 
{"message":"Hello, Spring Boot!","status":"UP","timestamp":1755400000000}

Notice we didn't have to set anything to get a 200 OK back — Spring Boot returns that by default whenever a controller method completes without throwing an exception.

We'll get into customizing status codes and handling errors properly starting in Chapter 19, Centralized Error Handling.

Building a CRUD REST API with Spring Boot

In RESTful architecture, HTTP is not just a transport protocol, but it is language. Different actions are expressed using different HTTP Verbs (or HTTP methods). These verbs map directly to the classic CRUD (Create, Read, Update, Delete) database operations:

HTTP VerbOperationSpring AnnotationDescription
GETRead@GetMappingRead data without altering the server state.
POSTCreate@PostMappingSend new data to the server to create a resource.
PUTUpdate@PutMappingReplace or update an existing resource.
PATCHPartial Update@PatchMappingApply partial modifications to an existing resource.
DELETEDelete@DeleteMappingRemove a resource from the server.

Let's build an interactive Weather API. We will use a mock service that holds an in-memory mutable state so you can actually create, read, update, and reset forecasts in real time without using any database yet.

Creating a DTO with a Java Record

Let's create a DTO class to hold our weather information. Using Java record is perfect here: Jackson has built-in support for records, so it serializes each record component (e.g. location(), temperatureCelsius()) straight into a matching JSON property, with no getters, no-args constructor, or field annotations needed. A regular class would need to expose either public getters or public fields for Jackson to do the same.

public record WeatherForecast(String location, double temperatureCelsius, String condition) {
 
}

Building a Mock Service Layer

Next, we'll build a service that maintains a mutable "current" forecast, plus a running history of every forecast that's ever been logged via POST. This lets our POST, PUT, and DELETE methods modify real data in memory:

import org.springframework.stereotype.Service;
 
import java.util.ArrayList;
import java.util.List;
 
@Service
public class WeatherService {
 
    private WeatherForecast currentForecast = new WeatherForecast("New York", 22.5, "Partly Cloudy");
    private final List<WeatherForecast> forecastHistory = new ArrayList<>();
 
    public WeatherForecast getForecast() {
        return this.currentForecast;
    }
 
    public WeatherForecast addForecast(WeatherForecast newForecast) {
        this.forecastHistory.add(newForecast);
        this.currentForecast = newForecast;
        return this.currentForecast;
    }
 
    public WeatherForecast updateForecast(WeatherForecast newForecast) {
        this.currentForecast = newForecast;
        return this.currentForecast;
    }
 
    public List<WeatherForecast> getForecastHistory() {
        return this.forecastHistory;
    }
 
    public void resetForecast() {
        this.currentForecast = new WeatherForecast("New York", 0.0, "Unknown");
    }
}

Notice addForecast() and updateForecast() both change the current forecast, but only addForecast() logs the change to forecastHistory. That's the real difference between the two: POST creates a new, independent record, while PUT just overwrites the current one in place.

Wiring CRUD Endpoints in the Controller

Now, let's create a WeatherController to expose these operations. We'll map GET, POST, PUT, and DELETE requests to the /weather base path, plus a small GET on /weather/history to inspect the log:

import org.springframework.web.bind.annotation.*;
 
import java.util.List;
 
@RestController
@RequestMapping("/weather")
public class WeatherController {
 
    private final WeatherService weatherService;
 
    public WeatherController(WeatherService weatherService) {
        this.weatherService = weatherService;
    }
 
    @GetMapping
    public WeatherForecast getWeather() {
        return weatherService.getForecast();
    }
 
    @PostMapping
    public WeatherForecast addWeather(@RequestBody WeatherForecast newForecast) {
        return weatherService.addForecast(newForecast);
    }
 
    @PutMapping
    public WeatherForecast updateWeather(@RequestBody WeatherForecast newForecast) {
        return weatherService.updateForecast(newForecast);
    }
 
    @DeleteMapping
    public String resetWeather() {
        weatherService.resetForecast();
        return "Weather forecast has been reset to default values.";
    }
 
    @GetMapping("/history")
    public List<WeatherForecast> getWeatherHistory() {
        return weatherService.getForecastHistory();
    }
}

Let's call out four crucial details in this controller:

  • @RequestMapping("/weather") sets the base URL path for this entire class.
  • Method-level annotations such as @GetMapping, @PostMapping, @PutMapping, and @DeleteMapping, used without an explicit path, map directly to that base path — making them handle GET /weather, POST /weather, PUT /weather, and DELETE /weather respectively.
  • @GetMapping("/history") does take an explicit path, so it's appended to the class-level base path, giving us GET /weather/history — a second endpoint living alongside the others.
  • @RequestBody on addWeather() and updateWeather() tells Spring to parse the incoming JSON payload in the request body and deserialize it directly into our WeatherForecast Java record.

Testing the CRUD REST API with curl

For testing these API's we can either use REST clients like Postman or cURL API clients.

Step 1: Fetch the Current Forecast (GET)

Let's see what the initial data looks like:

curl -i http://localhost:8080/weather

Response:

HTTP/1.1 200 OK
Content-Type: application/json
...
 
{"location":"New York","temperatureCelsius":22.5,"condition":"Partly Cloudy"}

Step 2: Log a New Forecast (POST)

Now, let's send a POST request with a JSON payload to create a new forecast entry:

curl -i -X POST \
  -H "Content-Type: application/json" \
  -d '{"location":"Paris","temperatureCelsius":18.0,"condition":"Cloudy"}' \
  http://localhost:8080/weather

Response:

HTTP/1.1 200 OK
Content-Type: application/json
...
 
{"location":"Paris","temperatureCelsius":18.0,"condition":"Cloudy"}

Paris is now the current forecast, and it's also been logged to history — we'll confirm that in Step 5.

Step 3: Update the Forecast (PUT)

Now, let's send a PUT request to overwrite the current forecast in place:

curl -i -X PUT \
  -H "Content-Type: application/json" \
  -d '{"location":"London","temperatureCelsius":14.0,"condition":"Rainy"}' \
  http://localhost:8080/weather

Response:

HTTP/1.1 200 OK
Content-Type: application/json
...
 
{"location":"London","temperatureCelsius":14.0,"condition":"Rainy"}

Step 4: Verify the State Change (GET)

To prove our stateful in-memory service successfully stored the updated data, run another standard GET request:

curl -i http://localhost:8080/weather

Response:

HTTP/1.1 200 OK
Content-Type: application/json
...
 
{"location":"London","temperatureCelsius":14.0,"condition":"Rainy"}

It changed! Your API successfully updated its in-memory record.

Step 5: Check the Forecast History (GET)

Now let's inspect the history log:

curl -i http://localhost:8080/weather/history

Response:

HTTP/1.1 200 OK
Content-Type: application/json
...
 
[{"location":"Paris","temperatureCelsius":18.0,"condition":"Cloudy"}]

Only Paris shows up here — the POST from Step 2 got logged, but the PUT from Step 3 didn't. That's POST and PUT behaving exactly as they're meant to: one creates a new record, the other replaces an existing one in place.

Step 6: Reset the Forecast (DELETE)

Finally, let's trigger our reset action by sending a DELETE request:

curl -i -X DELETE http://localhost:8080/weather

Response:

HTTP/1.1 200 OK
Content-Type: text/plain;charset=UTF-8
...
 
Weather forecast has been reset to default values.

Summary

In this chapter, we built our first REST endpoint, saw the difference between a plain-text and JSON reponse.

In the next chapter, we'll fix that: Handling Request Parameters, Path Variables, and Request Bodies covers how to accept input from the client so your endpoints can actually respond to what's being asked.


Related articles