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

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.

image

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.

image

cd /tmp/bbs-api/ui
npm run dev

When you access http://localhost:5173 in your browser, you’ll see a screen like this:

image

Dark mode is also supported.

image

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.

image

Here’s how dark mode looks:

image

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

image

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.

image

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:

image

image

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:

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.

Found a mistake? Update the entry.
Share this article: