Spring Bootで作ったAPIのUIをClaudeに作らせるTips

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のクラスを場当たり的に使うのではなく、メンテナンス性が高くなるようにできるだけコンポーネント化を意識してください。
また最新のパッケージだと互換性の問題が発生する可能性があるので、パッケージをインストールする際はあなたが知っているバージョンを必ず明示してください。

image

Claude DesktopがClaude Codeを使って、/tmp/bbs-api/uiに対して作業を開始します。

Note

このチャットの実際のやり取りは次のリンクから参照できます。
https://claude.ai/share/a00afde5-0e3e-47bc-a7dc-97541ae5c1ac

次のようなレスポンスが返り、作業が完了したら、動作確認します。

image

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

ブラウザでhttp://localhost:5173にアクセスすると、次のような画面が表示されます。

image

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

image

どんなUIが実装されるかはガチャですが、デザインが苦手な自分で実装するよりも圧倒的に早く、かつ見栄えの良いUIが実装されます。

少し色合いを変えてみます。チャットに続けて、次のプロンプトを与えます。

黄色を基調としたカラーにしてください

今度は次のようなUIが実装されました。

image

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

image

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

image

さて、ここまでできたら、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 lintnpm 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 installnpm 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.propertiesspring.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が表示されると成功です。

image

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風な見栄えにしてください

最新のパッケージだと互換性の問題が発生する可能性があるので、パッケージをインストールする際はあなたが知っているバージョンを必ず明示してください。

次のような仕上がりになります。

image

image

Minecraft版のソースコードはこちらです https://github.com/making/bbs-api/tree/minecraft-style


デモアプリや小規模アプリなアプリであれば上記の手法でClaudeにUIを作成してもらえ、圧倒的に生産性が上がります。

このブログを含めて、以下のアプリは今回の記事とほぼ同じ手法でUIをClaudeに作成してもらいました。

ソースコードが大きくなるとコンテキストサイズの制限が出てきそうです。 また、どこまでメンテナンス可能なソースコードを生成してくれるかは未検証です。