@RestController is a shortcut. It’s an annotation that is itself annotated with @Controller and @ResponseBody, so putting it on a class is the same as putting both of those there. The practical difference is what happens to the value a handler method returns.
In a plain @Controller, the return value is about rendering a page: a String is treated as a view name, Spring finds a template with that name (Thymeleaf, FreeMarker, JSP) and renders it with the model. In a @RestController, the return value is the response itself: Spring serialises it straight into the HTTP body, usually as JSON through Jackson. Use @Controller for server-rendered HTML and @RestController for APIs. That’s the answer; the rest of this guide is what goes wrong when the two get mixed up, because that’s what interviewers probe.
The two side by side
@Controller |
@RestController |
|
|---|---|---|
| Defined as | @Component specialisation for web handlers |
@Controller + @ResponseBody |
A returned String means |
A view (template) name | Text written to the body as-is |
| A returned object means | A model attribute; the view name comes from the URL | Serialised to the body (JSON by default) |
| Who produces the output | ViewResolver + template engine |
HttpMessageConverter (Jackson, String, byte array…) |
Model parameter |
Fills the template | Rarely useful |
ResponseEntity<T> return |
Written to the body, no view | Written to the body |
| Error handling partner | @ControllerAdvice |
@RestControllerAdvice (@ControllerAdvice + @ResponseBody) |
| Typical client | A browser loading pages | JavaScript apps, mobile apps, other services |
One feature, two styles
Imagine showing an order. The server-rendered version returns a view name and puts data in the model:
@Controller
@RequestMapping("/orders")
class OrderPageController {
private final OrderService orders;
OrderPageController(OrderService orders) {
this.orders = orders;
}
@GetMapping("/{id}")
String show(@PathVariable long id, Model model) {
model.addAttribute("order", orders.find(id));
return "orders/detail"; // renders src/main/resources/templates/orders/detail.html
}
}The API version returns the data itself:
@RestController
@RequestMapping("/api/orders")
class OrderApiController {
private final OrderService orders;
OrderApiController(OrderService orders) {
this.orders = orders;
}
@GetMapping("/{id}")
OrderDto show(@PathVariable long id) {
return OrderDto.from(orders.find(id)); // written as JSON
}
}A request to GET /api/orders/42 with Accept: application/json gets something like:
{"id":42,"status":"PAID","total":59.90}These snippets are illustrative Spring Boot 3 code (with spring-boot-starter-web, plus spring-boot-starter-thymeleaf for the page version); they weren’t executed for this page, and the JSON assumes OrderDto is a record with those three fields.
What happens under the hood: Spring MVC picks a HandlerMethodReturnValueHandler for each return value. Because @ResponseBody is present (inherited from @RestController), the RequestResponseBodyMethodProcessor handles it. It looks at the Accept header and the return type, picks a matching HttpMessageConverter (the Jackson converter for objects and JSON, MappingJackson2HttpMessageConverter in Spring Boot 3) and writes the bytes. No view resolver is involved. Without @ResponseBody, the String goes to the view-name handler instead, and a ViewResolver turns "orders/detail" into a template.
What breaks when you pick the wrong one
An API method in a plain @Controller
This is the classic beginner bug:
@Controller
class PingController {
@GetMapping("/ping")
String ping() {
return "pong";
}
}The author expects the text pong. Spring sees a String in a @Controller and treats it as a view name, so it looks for a template called pong. With Thymeleaf on the classpath you get a 500 with an error saying the template pong can’t be resolved. Without a template engine, Spring falls back to forwarding to a resource named pong, which doesn’t exist, and you usually see a 404 Whitelabel Error Page. Neither message mentions @ResponseBody, which is why this costs people an hour.
Returning an object from a @Controller is just as confusing: Spring adds it to the model and derives a view name from the request path. Depending on the setup, that ends in a missing-template error or a Circular view path exception, when the derived view name would dispatch back to the same URL.
Fix it by switching the class to @RestController, or by adding @ResponseBody to the method if the class also serves pages.
A page method in a @RestController
The opposite mistake is quieter. A @RestController method that returns "orders/detail" sends the literal text orders/detail to the browser with status 200. No error, no template, just a page containing a path. If you see a template name printed on screen, check for @ResponseBody somewhere it shouldn’t be.
Mixing both in one class
Since @RestController is only @Controller + @ResponseBody at class level, you can get the same effect per method:
@Controller
class DashboardController {
@GetMapping("/dashboard")
String page(Model model) {
model.addAttribute("title", "Sales");
return "dashboard"; // a view
}
@GetMapping("/dashboard/data")
@ResponseBody
List<SalesPoint> data() { // JSON for the page's charts
return salesService.lastThirtyDays();
}
}ResponseEntity is the other escape hatch. Returning ResponseEntity<T> from a plain @Controller writes the body through message converters, no @ResponseBody needed, because HttpEntityMethodProcessor handles it regardless. In a @RestController, ResponseEntity is how you control the status code and headers:
@PostMapping
ResponseEntity<OrderDto> create(@RequestBody @Valid NewOrder request) {
OrderDto created = OrderDto.from(orders.create(request));
return ResponseEntity
.created(URI.create("/api/orders/" + created.id())) // 201 with a Location header
.body(created);
}That said, keeping pages and APIs in separate controller classes (and usually separate URL prefixes such as /api) makes security rules, CORS, error handling and documentation simpler.
Details that come up as follow-ups
- Strings aren’t JSON-quoted. A
@RestControllermethod returningStringgoes throughStringHttpMessageConverter, so the body ispong, not"pong", with a text content type (text/plainortext/html, depending on theAcceptheader). Return an object or aMapif the client expects JSON. - Content negotiation. The
Acceptheader and theproducesattribute decide the format. If no converter can produce what the client asks for, Spring responds 406 Not Acceptable. Addjackson-dataformat-xmland the same method can return XML forAccept: application/xml. - Request bodies are separate.
@ResponseBodyis about output. Reading JSON input needs@RequestBodyon the parameter, in either kind of controller. - Error responses. A
@ControllerAdvicehandler method returning an object also needs@ResponseBody, which is exactly what@RestControllerAdviceadds. Spring Boot 3 can return RFC 9457 problem details withProblemDetail. - Testing.
@WebMvcTest(OrderApiController.class)withMockMvccovers both styles: assertjsonPath("$.id").value(42)for the API andview().name("orders/detail")for the page.
Practice question: the controller that returns a template name
A teammate migrates a Thymeleaf app to a React front end. They change every @Controller to @RestController with a search-and-replace. The new API endpoints work, but the login page, which is still server-rendered, now shows the text login in the browser instead of the form. Explain what happened and how you’d structure the fix.
Answer
The login handler returns "login", intended as the view name for login.html. After the replace, its class is a @RestController, which adds @ResponseBody to every method, so the string is written straight to the response body as text instead of being resolved to a template. The browser gets a 200 response containing the word login.
The targeted fix is to put the login handler back in a class annotated with @Controller. The better structural fix is to separate the two concerns: keep server-rendered pages in @Controller classes and API endpoints in @RestController classes under an /api prefix. That lets Spring Security apply form login and CSRF to pages and token authentication to the API, lets each side have its own advice (@ControllerAdvice rendering an error page, @RestControllerAdvice returning JSON problem details), and makes a future removal of the server-rendered pages a matter of deleting classes rather than editing annotations method by method.