TODO: Claude Code版を書く
Spring Bootユーザーの多くはバックエンドエンジニアであり、フロントエンドの実装は苦手な方が多いのではないでしょうか。
私もそうなのですが、デモアプリ、サンプルアプリを作る際はAPIだけを実装し、curlで動作確認をしていることが多いです。
あるいは、簡素なUIを作ってお茶を濁すことが多いです。
一方でUIがあると同じAPIでも見栄えが良くなり、デモやサンプルアプリの印象がとても良くなります。
そこで、最近は専らLLM (Claude) を使ってSpring Bootで作ったAPIのUIを作成してもらっているので、どのようにしているのかを紹介します。
目次
APIの設計ドキュメントを作成する
まずはどのようなAPIを作成するのかを設計ドキュメントにまとめます。テキストベースであれば、フォーマットはどんな形式でも良いです。
ドキュメント時代をLLMに書かせても良いでしょう。
本記事ではサンプルアプリとして簡易掲示板を作成することにします。
次の簡単なAPIを設計しました。
https://gist.github.com/making/9f14e2024654d1134ad03d14fed00936
このドキュメントは英語で書かれていますが、日本語で書いてもClaudeは認識してくれます。
APIをSpring Bootで実装する
では、このドキュメントに従ってAPIを実装していきます。この内容であれば、どのドキュメントをClaudeに読み込ませて、APIを実装させることも可能ですが、ここでは自分で実装することにします。
/tmpを作業ディレクトリにして、Spring Bootのプロジェクトを作成します。
cd /tmp
Spring Bootのプロジェクトは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
まずはCommentクラスを作成します。
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
次に、メインとなるComment APIの実装をCommentControllerに行います。ここではインメモリな実装にします。
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
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
必須ではないですが、エラーのJSONにエラーメッセージを出力するためのプロパティを設定します。
cat <<EOF >> src/main/resources/application.properties
server.error.include-message=always
EOF
テスト&ビルドを実行し、作成されたjarファイルを実行します。
./mvnw clean package
java -jar target/bbs-api-0.0.1-SNAPSHOT.jar
curlでAPIを叩いてみます。
$ curl localhost:8080/api/v1/comments
{"data":[],"pagination":{"totalComments":0,"totalPages":0,"currentPage":1,"limit":20,"hasNextPage":false,"hasPrevPage":false}}
UIをClaudeに作成してもらう
次に、作成したAPIに対するUIを作成してもらいます。
本記事ではClaude Desktopを使用します。また、Claude CodeをMCPサーバーとして使用し、
Claude Desktopからローカルのファイルを直接編集してもらいます。
Note
Claude CodeをMCPサーバーとして使用してClaude Desktopから使うための設定方法は次の記事が参考になります。
https://zenn.dev/myui/articles/7d9c8ba9231b49
最初に作成した設計ドキュメントをClaude Desktopに添付し、次のプロンプトを与えます。
添付のAPI設計書に従って、簡易BBSを作成します。APIはSpring Boot、UIはReact + React Router + Typescript + vite + Tailwindで実装します。
APIは実装済みで http://localhost:8080 でアクセス可能です。最終的には同じサーバーに同梱されるので、UIからはviteのproxyを経由して`/api/v1`でアクセスできるようにしてください。
あなたはtoolを使ってモダンなUIを実装してください。アーティファクトは使わないでください。
/tmp/bbs-api/uiで作業してください。コード中のコメントは英語にしてください。
HTTPアクセスはfetch + swrを使用してください。
Tailwindでライトモードとダークモードも実装してください。また、Tailwindのクラスを場当たり的に使うのではなく、メンテナンス性が高くなるようにできるだけコンポーネント化を意識してください。
また最新のパッケージだと互換性の問題が発生する可能性があるので、パッケージをインストールする際はあなたが知っているバージョンを必ず明示してください。

Claude DesktopがClaude Codeを使って、/tmp/bbs-api/uiに対して作業を開始します。
Note
このチャットの実際のやり取りは次のリンクから参照できます。
https://claude.ai/share/a00afde5-0e3e-47bc-a7dc-97541ae5c1ac
次のようなレスポンスが返り、作業が完了したら、動作確認します。

cd /tmp/bbs-api/ui
npm run dev
ブラウザでhttp://localhost:5173にアクセスすると、次のような画面が表示されます。

ダークモードもサポートされています。

どんなUIが実装されるかはガチャですが、デザインが苦手な自分で実装するよりも圧倒的に早く、かつ見栄えの良いUIが実装されます。
少し色合いを変えてみます。チャットに続けて、次のプロンプトを与えます。
黄色を基調としたカラーにしてください
今度は次のようなUIが実装されました。

ダークモードは次のようになります。

ちなみに、コメントを増やすとパジネーションリンクも表示されます。

さて、ここまでできたら、Claudeが作成したソースコードを一旦Gitコミットします。
この後引き続きタスクを与えて、思うように実装されなかった場合に戻せるように、タスク単位でGitコミットしておくと良いでしょう。
cd /tmp/bbs-api
git init
git add ui
git commit -m "Add initial UI"
コンテキストが大きくなりすぎないように、追加のタスクは別チャットで改めて依頼した方が良いです。
追加のタスクは次のようなプロンプトを与えると、継続して作業をしてくれます。
/tmp/bbs-api/uiはReact + React Router + Typescript + vite + Tailwindで実装された管理BBSです。
このUIに対してあなたにtoolで改修してもらいたいです。以下のタスクをお願いします。
* <ここにタスクを記述>
最新のパッケージだと互換性の問題が発生する可能性があるので、パッケージをインストールする際はあなたが知っているバージョンを必ず明示してください。
UIのソースコードは次のようにnpm run lintとnpm run buildがパスするまでClaudeに修正してもらいます。
$ 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エラーやbuildのエラーは頻繁に出ます。これらのエラーが何度チャットしてもfixされないこともあるので、コンテキストが増大しないように、lintやbuildエラーは別タスクで行った方が良いです。
次のようなプロンプトを与えて、エラーをfixしてください。
/tmp/bbs-api/uiはReact + React Router + Typescript + vite + Tailwindで実装された管理BBSです。
このUIに対してあなたにtoolで改修してもらいたいです。以下のタスクをお願いします。
* `npm run lint`を実行すると次のエラーが発生するので解消してください
xxxxxxxxxxxxxxxx
最新のパッケージだと互換性の問題が発生する可能性があるので、パッケージをインストールする際はあなたが知っているバージョンを必ず明示してください。
UIの作業が完了したら、一旦ソースコードをコミットします。
cd /tmp/bbs-api
git add .
git commit -m "Add REST API"
APIとUIを統合する
最後にAPIとUIを統合します。
まずはUIのpackage.jsonを次のように修正し、npm run buildでビルドしたアセットをSpring Bootのtarget/classes/META-INF/resourcesに出力するようにします。
{
// ...
"scripts": {
// ...
"build": "tsc && vite build --outDir ../target/classes/META-INF/resources --emptyOutDir",
// ...
},
// ...
}
Tip
クラスパス直下の/META-INF/resourcesはSpring Bootのデフォルトの静的リソースの配置場所です。
まず、APIのpom.xmlにfrontend-maven-pluginを追加し、Mavenによるコンパイル時に、uiディレクトリに移動して、npm installと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>
Tipですが、Paketo Buildpackでコンテナ化するとファイルの作成時刻が固定になります。
Spring Bootはデフォルトでファイルの作成時刻がLast-Modifiedヘッダーに設定されます。
その結果、静的リソースは過去からずっと変更されていないものとしてキャッシュされてしまいます。
それを防ぐためにapplication.propertiesにspring.web.resources.cache.use-last-modified=falseを設定します。
ファイルの作成時刻をLast-Modifiedヘッダーに設定する代わりに、キャッシュの有効期限をspring.web.resources.cache.periodで指定します。
次のようにプロパティを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
ここまで設定できたら、ビルドします。
./mvnw clean package
target/classes/META-INF/resources/以下にuiのビルドアセットが出力されていることを確認します。
$ 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
作成されたjarファイルを実行します。
java -jar target/bbs-api-0.0.1-SNAPSHOT.jar
http://localhost:8080にアクセスし、Claudeが作成したUIが表示されると成功です。

Tip
今回の題材は簡単すぎてReact Routerによるフロントエンドのルーティングが発生しませんでした。
React Routerでルーティングを行う場合は、フロントエンドでルーティングされるパスにサーバーサイドで直接アクセスした場合にindex.htmlへフォワードされるように
次のようなControllerを書く必要があります。
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";
}
}
ここまでの内容をGitにコミットします。
git add -A
git commit -m "Integrate UI and API"
実際に作成されたソースコードはこちら https://github.com/making/bbs-api です。
もし、スタイルが気に入らない場合は、次のようなプロンプトを与えて、スタイルを変更してもらいます。
例えば次のように依頼できます。
/tmp/bbs-api/uiはReact + React Router + Typescript + vite + Tailwindで実装された管理BBSです。
このUIに対してあなたにtoolで改修してもらいたいです。以下のタスクをお願いします。
* Minecraft風な見栄えにしてください
最新のパッケージだと互換性の問題が発生する可能性があるので、パッケージをインストールする際はあなたが知っているバージョンを必ず明示してください。
次のような仕上がりになります。


Minecraft版のソースコードはこちらです https://github.com/making/bbs-api/tree/minecraft-style
デモアプリや小規模アプリなアプリであれば上記の手法でClaudeにUIを作成してもらえ、圧倒的に生産性が上がります。
このブログを含めて、以下のアプリは今回の記事とほぼ同じ手法でUIをClaudeに作成してもらいました。
- 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
ソースコードが大きくなるとコンテキストサイズの制限が出てきそうです。
また、どこまでメンテナンス可能なソースコードを生成してくれるかは未検証です。