I tried building and running in production a tournament management app for an AWS-hosted futsal tournament using a serverless architecture!

I tried building and running in production a tournament management app for an AWS-hosted futsal tournament using a serverless architecture!

2026.06.11

This page has been translated by machine translation. View original

Hello, I'm Shunta Toda from the Retail App Co-Creation Division.

I participated in the AWS-hosted APN Partner Cup (an event where AWS partner companies gather to bond over futsal) through the futsal club inside Classmethod.

IMG_3391.jpg

Every year we managed the timetable and match results using Google Sheets or Excel, but this year we thought "why not build a dedicated web app for the tournament?" — so we built a tournament management and spectating support app called Kick Summit App using Claude Code and used it on the day of the event.

The code is publicly available in this GitHub repository.

https://github.com/ShuntaToda/kick-summit-app

In this article, I'll cover why I built it, what the structure looks like, how I built it with Claude Code, and how it actually worked on the day.

What I Built

It's a web app designed to be used on a smartphone. It brings together the timetable, standings, score input, and more in one place.

CleanShot 2026-06-11 at 11.44.04@2x.png

Screen Purpose
Home Your team's next match, recent results, and ongoing matches
Timetable List of all matches
League Table Group standings, points, and goal difference
Score Input Only participating teams / referee teams / admins can enter scores

Feature: UI is personalized by selecting "your team" at the start

The thing I cared about most in this app is the spec where you choose your own team on your first visit. Once selected, the choice is saved on that device, and from then on a UI optimized from your team's perspective is displayed.

For example, on the home screen you can see at a glance:

  • Your team's next match — what time, which court, and who you're playing against
  • When your team is on referee duty — which match and when
  • Your team's current standing, points, and goal difference

When browsing the full match list in a spreadsheet, it was surprisingly tedious to filter out only the matches relevant to your team, and "when's our next match?" and "when are we refereeing?" would come up every time. By making the team selection the starting point to narrow down what's shown, that effort became almost zero.

Feature: Team selection also used for score input permission management

Furthermore, the selected team information is also repurposed for score input permission management. It's a simple mechanism, but only the following people can enter scores:

  • Members of the teams playing in the match
  • Members of the referee team for that match
  • Admins (password login)

The idea was to shift from a setup where a single person on the organizing team entered everything to one where "anyone who notices can go ahead and enter it." By reusing the team selection directly for permission checks, access control works without building a full login system.

Features by Screen

Here's a look at what each screen can do.

Team Selection Screen

CleanShot 2026-06-11 at 11.43.00@2x.png

  • This screen appears on your first visit, where you choose your team
  • Your selected team is saved on your device (localStorage), and from the next visit your team's perspective UI is shown automatically
  • You can tap your team name in the header at any time to change your selection

Home Screen

CleanShot 2026-06-11 at 11.44.04@2x.png

Based on your selected team, it shows only the information relevant to your team in one place.

  • Next match: What time, which court, and who you're playing against
  • Next referee duty: When and which match you're refereeing
  • Ongoing matches: Real-time score of the match currently being played on the court
  • Recent results: Your team's most recent match results
  • Current standing: Your ranking, points, and goal difference within your group

It auto-refreshes every 30 seconds, so keeping your phone open will always show the latest state.

Timetable Screen

CleanShot 2026-06-11 at 11.46.31@2x.png

  • Displays all matches in chronological order
  • Each match shows the matchup / court / start time / score / status (scheduled / in progress / complete)
  • Matches involving your team (playing or refereeing) are highlighted, making them easy to spot while scrolling
  • Tapping a match card opens the score input dialog (only if you have permission)

League Table Screen

CleanShot 2026-06-11 at 11.47.11@2x.png

  • Displays group standings via tab switching
  • Columns: Rank / Team Name / Matches Played / Wins / Draws / Losses / Goals For / Goals Against / Goal Difference / Points
  • When a match result is confirmed (match complete toggle ON), standings are automatically recalculated
  • Your team's row is highlighted

Score Input Dialog

CleanShot 2026-06-11 at 11.47.46@2x.png

Opens when you tap a match card on the timetable or home screen.

  • Enter scores for each half (first / second)
  • Turning the match complete toggle ON triggers standing recalculation and tournament bracket updates
    • Leave it OFF when updating the score partway through a match
  • Only the following people can enter scores (the dialog won't even open for people without permission):
    • Members of the teams playing in the match
    • Members of the referee team for that match
    • Admins (password login)

Tournament Info Screen

CleanShot 2026-06-11 at 11.49.32@2x.png

  • A static page with the day's operational rules, time schedule, and score input procedures summarized in Markdown
  • Set up as a reference page for first-time participants and anyone who wants to recall the procedures on the day

Other / Admin Login Screen

CleanShot 2026-06-11 at 11.50.01@2x.png

  • Link to the tournament info page
  • Admin login (password entry)
  • Once logged in as admin, score input is enabled for all matches (for organizers)

Admin Panel Features

A set of organizer-only features accessible after admin login. Skipping screenshots and describing in text.

  • Edit tournament basic info — Change tournament name / date / admin password
  • Team management — Add, edit, and delete teams; set team names and team colors (HEX)
  • Group management — Create and edit preliminary league groups; assign teams to each group
  • Match management — Add, edit, and delete matches; set start time, court, matchup, and referee team
  • Full match score input — Enter and edit scores for all matches even if not a participating or referee team
  • Force match status changes — Manually switch between in-progress / complete (e.g., to roll back erroneous input)
  • Seed data injection — Bulk insert sample data for development and testing

In actual operation, the expectation is to use this panel mainly for pre-tournament data setup (registering teams, groups, and matches) and correcting participant input errors, but it's set up as a "last resort" with a full set of management features.

Why I Built It

Every year at the futsal tournament, I felt the following pain points from both an organizer and participant perspective.

  • The timetable ended up being managed in duplicate across paper and spreadsheets, with no way to see real-time progress on the day
  • Tallying results, calculating standings, and updating the tournament bracket were all manual tasks for the organizers
  • Spectators had a hard time checking "when's my next match?" from a match schedule image or spreadsheet

I started thinking that a web app that could solve all of these at once would reduce the organizer's workload and improve the experience for participants.

In addition, I had the following technical motivations I wanted to explore:

  • Seriously using Next.js 16 (App Router) + Server Actions
  • Validating a setup where Next.js runs on AWS Lambda with SSR via Lambda Web Adapter
  • Validating a setup for low-cost operation with Lambda + DynamoDB

I also wanted to get some practice in a familiar domain before using these in production work.

Architecture

Tech Stack

Layer Technology
Framework Next.js 16 (App Router, Turbopack)
Runtime React 19, TypeScript 5.7
Styling Tailwind CSS v4, shadcn/ui
Validation Zod
Infrastructure AWS CDK v2 (TypeScript)
DB Amazon DynamoDB (multi-table + GSI)
Compute AWS Lambda (Docker / ARM64) + Lambda Web Adapter
API Amazon API Gateway (HTTP API)
CDN Amazon CloudFront
Package Management pnpm 10 + Turborepo 2.8
Local DB DynamoDB Local (Docker Compose)

Monorepo Structure

kick-summit-app/
├── packages/
│   ├── web/        # Next.js application
│   ├── infra/      # AWS CDK infrastructure definition
│   └── shared/     # Shared type definitions
├── scripts/        # Seed and setup scripts
└── docker-compose.yml

A minimal pnpm workspace + Turborepo setup. shared holds only the types referenced by both web and infra, keeping mutual dependencies minimal.

Overall Structure

This is a setup where Next.js is packaged as a Docker image and deployed to Lambda, with Lambda Web Adapter passing HTTP requests directly to the Node server. Since it avoids ECS or App Runner, it's a great fit for use cases like events where the app only runs for a few days or hours.

Frontend — Server Component First

App Router's Server Components are used throughout.

Page (Server Component)
  ├── Data fetching on the server (async/await)
  ├── Static display rendered on the server
  └── Only interactive parts separated into Client Components

Refresher (Client Component)
  └── Re-renders server components via router.refresh() every 30 seconds

Rather than WebSocket or SSE, I went with a simple implementation of 30-second polling + router.refresh() to re-execute server components. Given the frequency of match updates, this is sufficient, and Lambda billing is also easy to control by polling frequency.

Backend — DDD + Server Actions

Dependencies follow the inward direction, inspired by Clean Architecture.
The layers are as follows:

Layer Path Role
Outer shell (entry) src/lib/actions/<feature>.ts Server Actions. Entry point from the browser
Outer shell (exit) src/server/infrastructure/repositories/ Access to DynamoDB
Usecase src/server/usecase/<feature>/ Business logic
Domain src/server/domain/ Entities / domain services / repository interfaces

Server Actions and Infrastructure both belong to the same "outer shell" — one is the interface with the browser (entry), the other is the interface with DynamoDB (exit). Both may depend on the inside (Usecase / Domain), but the outer shell is never referenced from the inside.

The benefits of this concentric circle structure are as follows:

  • Hides DynamoDB from business logic — Since Usecases only touch repository interfaces, swapping out the DB later doesn't require rewriting Usecases

  • Can swap in an In-Memory repository for testing — Since repository implementations are injected via a DI container (src/server/container.ts), you can inject an in-memory version during testing to unit test Usecases without DynamoDB Local

  • The browser-side protocol (Server Actions / REST / GraphQL ...) is also swappable — Since the entry point is also in the outer shell, it can be replaced without touching the Usecase

  • No API Routes — everything goes through Server Actions

  • Zod validation is performed in 3 places: "domain layer schema," "DB response parsing," and "Server Actions input validation"

DynamoDB: Multi-Table + Composite Key GSI

DynamoDB is designed with multi-table + Composite Key GSIs. There are 4 tables: tournaments / groups / teams / matches.

I did not adopt single-table design. Given the conditions of this project —

  • Diverse access patterns (by time order / by group / by status / individual match / all teams...)
  • Being maintained long-term by a solo developer

— I decided that separating tables by entity keeps cognitive load lower, and localizes design changes when new access patterns are added.

The matches table has the following GSIs so that most screens can retrieve data with a single query.

GSI PK SK Purpose
schedule-index tournamentId scheduledTime Timetable
status-index [tournamentId, status] scheduledTime Ongoing matches
group-index groupId scheduledTime Matches within a group

By forming Composite Keys as [A, B], queries with compound conditions like "tournament × status" can also be written straightforwardly.

How I Built It with Claude Code

Almost all of the code in this app was written by Claude Code. On the other hand, the "structural skeleton" of architecture, directory layout, and technology choices was decided by me. Breaking down the division of responsibilities:

Responsibility Content
Myself Technology stack selection, infrastructure design, layer architecture, directory structure, DB schema design, operational rules
Claude Code Implementation following the above guidelines, tests, refactoring, documentation generation

The "skeleton" parts I decided myself

Specifically, I made the following decisions myself before handing things over to Claude Code.

Infrastructure design

CloudFront → API Gateway (HTTP API) → Lambda (Lambda Web Adapter) → DynamoDB

The reason I chose Lambda + Lambda Web Adapter over ECS / App Runner is that I wanted a scale-to-zero setup for something that only runs for a few days around an event. With Lambda Web Adapter, there's no need to rewrite the Next.js code for Lambda — the same code that runs locally runs as-is.

Backend / Frontend structure

  • 4-layer structure inspired by Clean Architecture (Server Actions / Application / Domain / Infrastructure)
  • No REST API — all writes go through Server Actions
  • Zod validation at 3 layers: domain / infrastructure / application
  • Full adoption of App Router's Server Components, with Client Components only where necessary
  • UI based on shadcn/ui (new-york), customized with Tailwind v4

For architectural choices like "Server Actions only," "use the repository pattern to decouple DynamoDB," and "use shadcn/ui" — leaving these to Claude Code tends to produce an overly standard setup (API Routes + fetch + plain Tailwind), so it was faster to decide these myself.

Directory structure

I also decided upfront on a feature-based structure with domain / application / infrastructure / components under packages/web/src/features/<feature>/. Left to its own devices, it tends to become a type-based structure with components/ lib/ utils/, so locking this down early is important.

The mechanism for passing "guidelines" to Claude Code

The skeleton I decided on is written into files that Claude Code reads automatically.

  • docs/architecture.md — Tech stack, page structure, layer structure
  • DB設計.md — Table / GSI / access pattern listing
  • CLAUDE.md in each directory — Guidelines to follow in that directory

CLAUDE.md is a file that Claude Code automatically reads at the start of a conversation. Writing guidelines there like "only write through Server Actions" and "use Zod at 3 layers: domain / infra / application" means I don't have to repeat them in every prompt.

For tricky parts, I had Claude Code "rebuild"

During development I tried a design where all business logic was consolidated into custom Hooks, but measured against React's official guidelines, it turned out to be excessive abstraction.

Instead of rewriting the files myself one by one, I asked Claude Code to "look up React's official criteria for creating custom hooks, reassess the current state, and rebuild by deleting the hooks/ directory." It handled web search → understanding the full existing codebase → generating the diff all in one session, and the rebuild was done in half a day.

Large-scale refactors that humans tend to give up on partway through and settle for partial fixes get carried through to completion calmly by an agent.

Overall impression

Roughly speaking:

  • Over 90% of the code was written by Claude Code
  • On the other hand, architecture, directory structure, and technology choices were decided by me
  • My own work centered on "directing the design," "PR review-equivalent checking," and "debugging assistance for broken parts"
  • A volume that would have taken 3–4 weekends if I'd written it myself was completed in 1–2 weekends

That's how it felt. The biggest benefit was keeping the design intent in my own hands while boosting implementation speed — by dividing it as "design by human, code by Claude Code." Conversely, leaving the design entirely to Claude Code tends to produce a safe but uninteresting structure.

How Did It Actually Go on the Day?

What Went Well

Distributing score input worked out perfectly

By opening score input to the playing and referee teams, the organizers only needed to oversee the overall flow. In previous years the organizers were glued to their phones tallying results, but this year we could actually watch and participate normally.

"When's our next match?" was answered entirely on-screen

By showing "your team's next match," "recent results," and "ongoing matches" on the home screen, verbal questions like "what time is [team]'s match?" nearly stopped coming from participants. Just not having to open a spreadsheet made a noticeable difference.

Lambda Web Adapter was surprisingly straightforward

The cold starts I was worried about were around 1–2 seconds noticeable by feel with the ARM64 / 1024MB combination. For use cases where the app doesn't need to be always-on, it works just fine.

Cost also stayed at 0.2 USD per day

AWS costs on the day of the tournament came to about 0.2 USD per day (roughly 30 yen). The combination of Lambda + DynamoDB on-demand billing + CloudFront only charges for actual requests, so I was reminded again that this is truly the ideal setup for "event apps that only run for a few days."

Reflections

30-second polling feels "just right" but is a little laggy

After scoring a goal, a certain number of people would swipe to refresh the screen asking "has it updated yet?" Next time I'd like to consider SSE or API Gateway WebSocket API. That said, designing for persistent connections on Lambda has its own overhead, so it depends on the use case.

An announcement feature would have been nice

During the tournament, match timings drifted slightly, accumulating up to about 2 minutes of delay. Being able to display announcements from the tournament organizers in those cases would help users keep track of match progress.

CleanShot 2026-06-11 at 11.58.16@2x.png

What Was Hard

The pressure of carrying the live tournament on this app

More than any technical difficulty, the heaviest thing was the sense of responsibility that came with "if this app goes down, match scheduling and score calculations for the tournament stop." Since we went fully app-based rather than keeping a spreadsheet backup, if the app had gone down on the day, the entire tournament would have halted.

In the end, the app never went down, and we made it safely through to the final results announcement, which was a great relief.

Summary

I built a straightforward solution to a nearby "pain point" using Next.js 16 + AWS Lambda + DynamoDB.

Building it and actually running it on the day reinforced how Lambda Web Adapter + App Router Server Components + Server Actions writes even better than I expected. For "short-lived internal tools" like this tournament app, the combination is a great fit — easy development environment setup and low cost — and I was reminded again that it's truly the ideal setup for this kind of use case.

And above all, the division of "architecture, directory structure, and technology choices decided by me; code written by Claude Code" worked out really well. It felt like keeping the design intent in my hands while only boosting implementation speed, and I don't think I could have built this much for a personal project without Claude Code.

I hope this is useful for anyone thinking "I want to run my next futsal tournament with a homegrown app."

References

https://github.com/ShuntaToda/kick-summit-app

Share this article

AWSのお困り事はクラスメソッドへ