Warning
This article was automatically translated by OpenAI (gpt-4.1).It may be edited eventually, but please be aware that it may contain incorrect information at this time.
Many Spring Boot users are backend engineers, and many may not be comfortable with frontend implementation. I’m the same way—when creating demo or sample apps, I often just implement the API and check its operation with curl. Sometimes I make a simple UI just to get by. However, having a UI makes even the same API look much better, and the impression of your demo or sample app improves greatly.
Recently, I’ve been using LLMs (Claude) to have them create UIs for APIs I’ve built with Spring Boot. Here, I’ll introduce how I do this.
Table of Contents
- Create an API Design Document
- Implement the API with Spring Boot
- Have Claude Create the UI
- Integrate the API and UI
Create an API Design Document
First, summarize what kind of API you want to create in a design document. As long as it’s text-based, any format is fine. You can even have the LLM write the document for you.
In this article, I’ll create a simple bulletin board as a sample app. Here’s a simple API I designed:
https://gist.github.com/making/9f14e2024654d1134ad03d14fed00936
This document is written in English, but Claude can understand it even if it’s in Japanese.
Implement the API with Spring Boot
Now, let’s implement the API according to this document. With this content, you could have Claude read the document and implement the API, but here I’ll implement it myself.
Set /tmp as your working directory and create a Spring Boot project.
cd /tmp
Create the Spring Boot project using Spring Initializr.
curl -s https://start.spring.io/starter.tgz \
-d artifactId=bbs-api \
-d baseDir=bbs-api \
-d type=maven-project \
-d dependencies=web,actuator,configuration-processor,native \
-d packageName=com.example.bbs \
-d applicationName=BbsApiApplication | tar -xzvf -
cd bbs-api
First, create the Comment class.
cat <<EOF > src/main/java/com/example/bbs/Comment.java
package com.example.bbs;
import java.time.Instant;
import java.util.Objects;
import java.util.UUID;
/**
* Record representing a comment on the bulletin board
*/
public record Comment(String id, String author, String content, Instant createdAt) {
/**
* Factory method to create a new comment from author and content
*/
public static Comment create(String author, String content) {
return new Comment(UUID.randomUUID().toString(), Objects.requireNonNull(author, "'author' is required"),
Objects.requireNonNull(content, "'content' is required"), Instant.now());
}
}
EOF
Next, implement the main Comment API in CommentController. Here, we’ll use an in-memory implementation.
cat <<EOF > src/main/java/com/example/bbs/CommentController.java
package com.example.bbs;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* REST controller for bulletin board operations
*/
@RestController
@RequestMapping("/api/v1")
public class CommentController {
// In-memory storage
final ConcurrentMap<String, Comment> comments = new ConcurrentHashMap<>();
/**
* Create a new comment
*/
@PostMapping("/comments")
public ResponseEntity<Comment> createComment(@RequestBody CommentRequest request) {
Comment comment = Comment.create(request.author(), request.content());
comments.put(comment.id(), comment);
return ResponseEntity.status(HttpStatus.CREATED).body(comment);
}
/**
* Get comments with pagination
*/
@GetMapping("/comments")
public ResponseEntity<CommentsResponse> getComments(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int limit, @RequestParam(defaultValue = "newest") String sort) {
// Get all comments and sort them
List<Comment> allComments = new ArrayList<>(comments.values());
if ("newest".equalsIgnoreCase(sort)) {
allComments.sort(Comparator.comparing(Comment::createdAt).reversed());
}
else {
allComments.sort(Comparator.comparing(Comment::createdAt));
}
// Calculate pagination
limit = Math.min(limit, 100);
int totalComments = allComments.size();
int totalPages = (int) Math.ceil((double) totalComments / limit);
// Apply pagination
int startIndex = (page - 1) * limit;
int endIndex = Math.min(startIndex + limit, totalComments);
List<Comment> pagedComments;
if (startIndex < totalComments) {
pagedComments = allComments.subList(startIndex, endIndex);
}
else {
pagedComments = List.of();
}
// Create pagination info
PaginationInfo paginationInfo = new PaginationInfo(totalComments, totalPages, page, limit, page < totalPages,
page > 1);
// Create and return the response
CommentsResponse response = new CommentsResponse(pagedComments, paginationInfo);
return ResponseEntity.ok(response);
}
/**
* Delete a comment by ID
*/
@DeleteMapping("/comments/{commentId}")
public ResponseEntity<Void> deleteComment(@PathVariable String commentId) {
comments.remove(commentId);
return ResponseEntity.noContent().build();
}
/**
* Request DTO for comment creation
*/
public record CommentRequest(String author, String content) {
}
/**
* Response DTO for paginated comments
*/
public record CommentsResponse(List<Comment> data, PaginationInfo pagination) {
}
/**
* DTO for pagination metadata
*/
public record PaginationInfo(int totalComments, int totalPages, int currentPage, int limit, boolean hasNextPage,
boolean hasPrevPage) {
}
}
EOF
Let’s also create an Integration Test.
cat <<EOF > src/test/java/com/example/bbs/BbsApiIntegrationTest.java
package com.example.bbs;
import com.example.bbs.CommentController.CommentRequest;
import com.example.bbs.CommentController.CommentsResponse;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration test for Bulletin Board REST API
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class BbsApiIntegrationTest {
@LocalServerPort
int port;
@Autowired
TestRestTemplate restTemplate;
@Autowired
CommentController commentController;
String baseUrl;
@BeforeEach
public void setUp() {
baseUrl = "http://localhost:" + port + "/api/v1";
commentController.comments.clear();
}
/**
* Test creating a comment
*/
@Test
public void testCreateComment() {
// Create a comment request
CommentRequest request = new CommentRequest("TestUser", "This is a test comment");
// Send POST request
ResponseEntity<Comment> response = restTemplate.postForEntity(baseUrl + "/comments", request, Comment.class);
// Verify response
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().author()).isEqualTo("TestUser");
assertThat(response.getBody().content()).isEqualTo("This is a test comment");
assertThat(response.getBody().id()).isNotNull();
assertThat(response.getBody().createdAt()).isNotNull();
}
/**
* Test creating multiple comments and fetching with pagination
*/
@Test
public void testCreateAndGetComments() {
List<Comment> createdComments = new ArrayList<>();
// Create multiple comments
for (int i = 1; i <= 5; i++) {
CommentRequest request = new CommentRequest("User" + i, "Test comment " + i);
ResponseEntity<Comment> response = restTemplate.postForEntity(baseUrl + "/comments", request,
Comment.class);
createdComments.add(response.getBody());
}
// Test getting comments with default parameters
ResponseEntity<CommentsResponse> response = restTemplate.exchange(baseUrl + "/comments", HttpMethod.GET, null,
new ParameterizedTypeReference<>() {
});
// Verify response
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().data()).isNotNull();
assertThat(response.getBody().pagination()).isNotNull();
// Verify pagination
assertThat(response.getBody().pagination().totalComments()).isEqualTo(createdComments.size());
assertThat(response.getBody().data()).isNotEmpty();
// Test that comments are sorted by newest first (default)
List<Comment> comments = response.getBody().data();
for (int i = 0; i < comments.size() - 1; i++) {
assertThat(comments.get(i).createdAt()).isAfterOrEqualTo(comments.get(i + 1).createdAt());
}
}
/**
* Test pagination with different parameters
*/
@Test
public void testPagination() {
List<Comment> createdComments = new ArrayList<>();
// Create multiple comments
for (int i = 1; i <= 5; i++) {
CommentRequest request = new CommentRequest("User" + i, "Test comment " + i);
ResponseEntity<Comment> response = restTemplate.postForEntity(baseUrl + "/comments", request,
Comment.class);
createdComments.add(response.getBody());
}
// Test with limit = 2
ResponseEntity<CommentsResponse> response = restTemplate.exchange(baseUrl + "/comments?limit=2", HttpMethod.GET,
null, new ParameterizedTypeReference<>() {
});
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody().data()).hasSize(2);
assertThat(response.getBody().pagination().limit()).isEqualTo(2);
// Calculate expected total pages
int expectedTotalPages = (int) Math.ceil((double) createdComments.size() / 2);
assertThat(response.getBody().pagination().totalPages()).isEqualTo(expectedTotalPages);
// Test second page
response = restTemplate.exchange(baseUrl + "/comments?limit=2&page=2", HttpMethod.GET, null,
new ParameterizedTypeReference<CommentsResponse>() {
});
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody().data()).isNotEmpty();
assertThat(response.getBody().pagination().currentPage()).isEqualTo(2);
// Test oldest sort order
response = restTemplate.exchange(baseUrl + "/comments?sort=oldest", HttpMethod.GET, null,
new ParameterizedTypeReference<CommentsResponse>() {
});
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<Comment> comments = response.getBody().data();
for (int i = 0; i < comments.size() - 1; i++) {
assertThat(comments.get(i).createdAt()).isBeforeOrEqualTo(comments.get(i + 1).createdAt());
}
}
/**
* Test deleting a comment
*/
@Test
public void testCreateAndDeleteComment() {
// Create a comment request
CommentRequest request = new CommentRequest("TestUser", "This is a test comment");
// Send POST request
ResponseEntity<Comment> created = restTemplate.postForEntity(baseUrl + "/comments", request, Comment.class);
// Delete the first comment
Comment commentToDelete = created.getBody();
restTemplate.delete(baseUrl + "/comments/" + commentToDelete.id());
// Get all comments to verify deletion
ResponseEntity<CommentsResponse> response = restTemplate.exchange(baseUrl + "/comments", HttpMethod.GET, null,
new ParameterizedTypeReference<>() {
});
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody().pagination().totalComments()).isEqualTo(0);
}
/**
* Test complete API flow - create, get and delete
*/
@Test
public void testCompleteApiFlow() {
// Create a new comment
CommentRequest request = new CommentRequest("FlowTestUser", "Testing complete API flow");
ResponseEntity<Comment> createResponse = restTemplate.postForEntity(baseUrl + "/comments", request,
Comment.class);
assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
Comment createdComment = createResponse.getBody();
assertThat(createdComment).isNotNull();
// Get comments and ensure the new one is included
ResponseEntity<CommentsResponse> getResponse = restTemplate.exchange(baseUrl + "/comments", HttpMethod.GET,
null, new ParameterizedTypeReference<>() {
});
assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
boolean commentFound = getResponse.getBody()
.data()
.stream()
.anyMatch(comment -> comment.id().equals(createdComment.id()));
assertThat(commentFound).isTrue();
// Delete the comment
restTemplate.delete(baseUrl + "/comments/" + createdComment.id());
// Verify comment was deleted
getResponse = restTemplate.exchange(baseUrl + "/comments", HttpMethod.GET, null,
new ParameterizedTypeReference<>() {
});
commentFound = getResponse.getBody()
.data()
.stream()
.anyMatch(comment -> comment.id().equals(createdComment.id()));
assertThat(commentFound).isFalse();
}
}
EOF
This isn’t required, but let’s set a property to output error messages in the error JSON.
cat <<EOF >> src/main/resources/application.properties
server.error.include-message=always
EOF
Run tests and build, then run the generated jar file.
./mvnw clean package
java -jar target/bbs-api-0.0.1-SNAPSHOT.jar
Let’s try hitting the API with curl.
$ curl localhost:8080/api/v1/comments
{"data":[],"pagination":{"totalComments":0,"totalPages":0,"currentPage":1,"limit":20,"hasNextPage":false,"hasPrevPage":false}}
Have Claude Create the UI
Next, let’s have Claude create a UI for the API we just built.
In this article, I’ll use Claude Desktop. Also, by using Claude Code as an MCP server, you can have Claude Desktop directly edit local files.
Note
For instructions on how to use Claude Code as an MCP server with Claude Desktop, see the following article:
https://zenn.dev/myui/articles/7d9c8ba9231b49
Attach the design document you created earlier to Claude Desktop, and give the following prompt:
Create a simple BBS according to the attached API design document. The API is implemented with Spring Boot, and the UI should be implemented with React + React Router + Typescript + vite + Tailwind.
The API is already implemented and accessible at http://localhost:8080. Eventually, it will be bundled on the same server, so please make sure the UI can access `/api/v1` via vite’s proxy.
Please use your tool to implement a modern UI. Do not use artifacts.
Work in /tmp/bbs-api/ui. Please write comments in English in the code.
For HTTP access, use fetch + swr.
Please implement both light and dark modes with Tailwind. Also, instead of using Tailwind classes ad hoc, try to componentize as much as possible for maintainability.
Since there may be compatibility issues with the latest packages, please always specify the version you know when installing packages.

Claude Desktop will start working on /tmp/bbs-api/ui using Claude Code.
Note
You can see the actual chat exchange at the following link:
https://claude.ai/share/a00afde5-0e3e-47bc-a7dc-97541ae5c1ac
Once you get a response like the following and the work is complete, check the operation.

cd /tmp/bbs-api/ui
npm run dev
When you access http://localhost:5173 in your browser, you’ll see a screen like this:

Dark mode is also supported.

What kind of UI gets implemented is a bit of a gacha, but it’s overwhelmingly faster and better-looking than if I tried to implement it myself, since I’m not good at design.
Let’s try changing the color scheme a bit. Continue the chat and give the following prompt:
Please use a yellow-based color scheme.
This time, a UI like the following was implemented.

Here’s how dark mode looks:

By the way, if you add more comments, pagination links will also be displayed.

Once you’ve gotten this far, commit the source code created by Claude to Git. It’s a good idea to commit by task so you can revert if subsequent tasks don’t go as expected.
cd /tmp/bbs-api
git init
git add ui
git commit -m "Add initial UI"
To avoid the context getting too large, it’s better to request additional tasks in a separate chat.
For additional tasks, you can give a prompt like this and have Claude continue working:
/tmp/bbs-api/ui is an admin BBS implemented with React + React Router + Typescript + vite + Tailwind.
I’d like you to use your tool to make improvements to this UI. Please do the following tasks:
* <Describe your tasks here>
Since there may be compatibility issues with the latest packages, please always specify the version you know when installing packages.
Have Claude fix the UI source code until npm run lint and npm run build pass, like this:
$ npm run lint
> ui@0.0.0 lint
> eslint src --ext ts,tsx --max-warnings 0
$ npm run build
> ui@0.0.0 build
> vite build
vite v4.3.9 building for production...
✓ 375 modules transformed.
dist/index.html 0.46 kB │ gzip: 0.30 kB
dist/assets/index-9eb533d8.css 15.13 kB │ gzip: 3.01 kB
dist/assets/index-7fc96fff.js 185.93 kB │ gzip: 59.67 kB │ map: 575.06 kB
✓ built in 652ms
Lint and build errors occur frequently. Sometimes, no matter how many times you chat, these errors aren’t fixed, so to avoid context bloat, it’s better to handle lint and build errors as separate tasks. Give a prompt like this to have the errors fixed:
/tmp/bbs-api/ui is a simple BBS implemented with React + React Router + Typescript + vite + Tailwind.
I’d like you to use your tool to make improvements to this UI. Please do the following tasks:
* When I run `npm run lint`, I get the following error. Please fix it.
xxxxxxxxxxxxxxxx
Since there may be compatibility issues with the latest packages, please always specify the version you know when installing packages.
Once the UI work is complete, commit the source code.
cd /tmp/bbs-api
git add .
git commit -m "Add REST API"
Integrate the API and UI
Finally, let’s integrate the API and UI.
First, modify the UI’s package.json so that assets built with npm run build are output to Spring Boot’s target/classes/META-INF/resources.
{
// ...
"scripts": {
// ...
"build": "tsc && vite build --outDir ../target/classes/META-INF/resources --emptyOutDir",
// ...
},
// ...
}
Tip
/META-INF/resources directly under the classpath is the default location for static resources in Spring Boot.
Next, add frontend-maven-plugin to the API’s pom.xml so that during Maven compilation, it moves to the ui directory and runs npm install and npm run build.
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>1.15.1</version>
<configuration>
<installDirectory>target</installDirectory>
<nodeVersion>v22.15.0</nodeVersion>
</configuration>
<executions>
<execution>
<id>install node and npm</id>
<goals>
<goal>install-node-and-npm</goal>
</goals>
</execution>
<execution>
<id>npm install ui</id>
<goals>
<goal>npm</goal>
</goals>
<configuration>
<arguments>install</arguments>
<workingDirectory>${project.basedir}/ui</workingDirectory>
</configuration>
</execution>
<execution>
<id>npm run lint ui</id>
<goals>
<goal>npm</goal>
</goals>
<phase>test</phase>
<configuration>
<arguments>run lint</arguments>
<workingDirectory>${project.basedir}/ui</workingDirectory>
</configuration>
</execution>
<execution>
<id>npm run build ui</id>
<goals>
<goal>npm</goal>
</goals>
<phase>compile</phase>
<configuration>
<arguments>run build</arguments>
<workingDirectory>${project.basedir}/ui</workingDirectory>
</configuration>
</execution>
</executions>
</plugin>
As a tip, when containerizing with Paketo Buildpack, file creation times are fixed. By default, Spring Boot sets the file creation time as the Last-Modified header. As a result, static resources are cached as if they haven’t changed since the past. To prevent this, set spring.web.resources.cache.use-last-modified=false in application.properties. Instead of setting the file creation time as the Last-Modified header, specify the cache expiration with spring.web.resources.cache.period.
Add the following properties to application.properties:
cat <<EOF >> src/main/resources/application.properties
server.compression.enabled=true
spring.web.resources.cache.period=365d
spring.web.resources.cache.use-last-modified=false
EOF
Once you’ve set this up, build the project.
./mvnw clean package
Check that the UI build assets are output under target/classes/META-INF/resources/.
$ ls -la target/classes/META-INF/resources/
total 16
drwxr-xr-x@ 5 toshiaki wheel 160 5 15 14:14 .
drwxr-xr-x@ 3 toshiaki wheel 96 5 15 14:14 ..
drwxr-xr-x@ 5 toshiaki wheel 160 5 15 14:14 assets
-rw-r--r--@ 1 toshiaki wheel 457 5 15 14:14 index.html
-rw-r--r--@ 1 toshiaki staff 1497 5 15 14:14 vite.svg
Run the generated jar file.
java -jar target/bbs-api-0.0.1-SNAPSHOT.jar
If you access http://localhost:8080 and see the UI created by Claude, you’ve succeeded.

Tip
In this example, the app is so simple that there’s no frontend routing with React Router.
If you do use React Router for routing, you’ll need to write a Controller like the following so that when you access a frontend-routed path directly on the server side, it forwards to index.html.
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class HomeController {
@GetMapping(path = { "/", "/comments", "/comments/**" })
public String index() {
return "forward:/index.html";
}
}
Commit everything so far to Git.
git add -A
git commit -m "Integrate UI and API"
The actual source code created is here: https://github.com/making/bbs-api
If you don’t like the style, you can give a prompt like the following to have Claude change it. For example:
/tmp/bbs-api/ui is a simple BBS implemented with React + React Router + Typescript + vite + Tailwind.
I’d like you to use your tool to make improvements to this UI. Please do the following tasks:
* Please make it look like Minecraft.
Since there may be compatibility issues with the latest packages, please always specify the version you know when installing packages.
Here’s how it turned out:


The Minecraft-style source code is here: https://github.com/making/bbs-api/tree/minecraft-style
For demo apps and small-scale apps, you can have Claude create the UI using the above method, and your productivity will skyrocket.
Including this blog, the following apps had their UIs created by Claude using almost the same method as described in this article:
- https://github.com/making/spring-batch-dashboard
- https://github.com/categolj/room-reservation
- https://github.com/making/hello-spring-ai
- https://github.com/making/oauth2-sso-demo
- https://github.com/making/ssr-react-spring-boot-graalvm-js
- https://github.com/categolj/blog-frontend
If the source code gets too large, you may run into context size limitations. Also, it’s still untested how maintainable the generated source code will be.