Back to notes
Tech shareArchitectureAWSNext.jsHono

SiyaMap — Concert Venue Seat-View Sharing Platform: System Architecture

Full-stack architecture of a concert seat-view sharing platform: AWS serverless infrastructure, the watermark and resize image pipeline, PostgreSQL modeling, and a layered Hono backend.

Updated 16 min22 viewsArticle language

NOTE

The architecture diagrams embedded below are labeled in Chinese; the text around each diagram explains it in English.

Overview

When buying concert tickets, the most common worries are: "Can I see the stage from this seat? Will a pillar block my view? Is the air conditioning too cold? How long does it take to get out after the show?" Official ticketing sites only show a flat seat map, and the genuinely useful information is scattered across social media posts and comments, with no way to look it up by venue → section → row → seat.

SiyaMap tackles exactly that. It uses the venue seat map as an index: people who have attended a show submit a "real photo of the view + experience rating" for the seat they sat in, and later visitors can click a section on the seat map to see what that seat actually looks like.

Core conceptDescription
VenueBasic information about venues such as Taipei Arena or Kaohsiung Arena: region, transit directions, locker count
Seat MapAn interactive venue floor plan drawn in SVG, where each shape maps to a seating section
SeatSection → row → seat number, with standing / seated / accessible seat types
ContributionA user's experience at a specific seat: satisfaction, obstructed view, aisle seat, distance to exit, exit time, air-conditioning strength, comments
Contribution ImageReal photos attached to a contribution, automatically watermarked after upload and served in multiple sizes

Monorepo Architecture

Module Responsibilities

The project is a single pnpm workspace made up of three modules, each with its own build output and deployment target:

siyamap/
├── apps/
│   ├── web/               # @siyamap/web: Next.js 16 SSR pages + Hono REST API + database schema
│   └── image-processor/   # @siyamap/image-processor: watermarking and resizing Lambda (sharp)
├── packages/
│   └── ui/                # @siyamap/ui: shared UI component library + Storybook
├── .github/workflows/     # CI/CD that deploys each module in its own stage
├── pnpm-workspace.yaml    # workspace scope + catalog (single React version)
└── turbo.json             # task dependencies and caching for build / typecheck / lint
ModuleResponsibilityDeployment targetUsed by
apps/webPage rendering, REST API, authentication, database access, issuing upload URLsLambda (container image) + S3 static assetsThe user's browser
apps/image-processorWatermarking originals, on-demand resizingLambda (container image)S3 events, the CDN's /resize/* path
packages/uiShared React components and design tokensWorkspace package + static Storybook siteBundled by apps/web at build time

Request Flow Between Modules

The diagram below shows how the three modules divide the work with external services, along with the three main request flows:

  1. Pages and API: browser → CloudFront → apps/web (SSR pages and the Hono API run in the same Lambda) → Neon PostgreSQL
  2. Image upload: the browser first gets a presigned URL from apps/web, then uploads straight to S3; the S3 event triggers apps/image-processor to add the watermark
  3. Image read: browser → cdn.siyamap.com; original sizes are served directly from S3, while /resize/* is handed to apps/image-processor for on-the-fly resizing
Module responsibilities and request flow · Open in Miro

The three modules never call each other's code directly: web and image-processor cooperate through S3 folder conventions (origin/, watermark/, static/) and the CDN URL format, while ui is only bundled into web at build time. Any module can be deployed on its own without affecting the others.

Engineering Setup

  • pnpm Catalog: the versions of React, React DOM and their types are defined once in pnpm-workspace.yaml and referenced by every module through catalog:, preventing multiple copies of React in the monorepo and the hooks errors that come with them
  • Turborepo task dependencies: build declares dependsOn: ["^build"], guaranteeing that web is bundled against the latest compiled ui; typecheck is cached, so unchanged modules are not re-checked
  • Husky pre-commit: runs turbo typecheck before every commit, so a type error in any module cannot be committed
  • Shared lint rules: the root ESLint flat config (typescript-eslint, simple-import-sort, eslint-plugin-storybook) and Prettier apply to every module

apps/web: Next.js Full-Stack App

Responsibilities

apps/web is the core of the system and is responsible for:

  • Pages: Next.js 16 App Router SSR pages (venue list, seat map, upload, profile, login)
  • REST API: /api/* built with Hono, covering eight domain modules: auth, oauth, user, profile, file, venue, seat-map and health-check
  • Data layer: Drizzle ORM schema definitions and data access, covered in the next chapter, "Database Design"

Integration with Other Modules

CounterpartHow
packages/uiDepends on "@siyamap/ui": "workspace:*"; next.config.ts sets transpilePackages: ['@siyamap/ui'] so it is compiled together at build time
apps/image-processorNo direct calls. POST /api/files uses withWatermark to decide whether the presigned URL targets origin/ (needs a watermark) or static/ (does not); image-processor only listens to events from origin/
S3Only issues presigned PUT URLs; the images themselves never pass through this Lambda
Neon PostgreSQLQueried through the HTTP driver of @neondatabase/serverless

SSR and API Share One Deployment Unit

The API is not a separate server. Hono is mounted on a Next.js catch-all route handler (app/api/[[...route]]/route.ts), so the SSR pages and the REST API ship as one Docker image and one Lambda. For a side project, that means a single deployment, a single place to monitor and a single bill; and because the API layer is written in framework-agnostic Hono, it can be moved as-is to its own Lambda or container once traffic justifies splitting it out.

Backend Layering and Request Pipeline

Every domain module follows the same three-layer structure:

server/
├── middlewares/
│   ├── auth.middleware.ts        # setUser / requireLoggedIn / requireAdmin
│   └── validator.middleware.ts   # wraps zValidator with a unified validation error format
├── modules/
│   └── venue/
│       ├── venue.route.ts        # HTTP binding + middleware chain + Swagger JSDoc
│       ├── venue.controller.ts   # parses requests, builds responses, maps error codes
│       └── venue.service.ts      # data access and business logic (Drizzle)
└── schema/                       # Drizzle table definitions + relations
types/dto/                        # Zod DTOs (shared by frontend and backend)

The full path of a request from entering Hono to the response, and which errors each layer stops:

Backend request pipeline · Open in Miro

Dependency Injection (Constructor Injection)

Controllers and services receive their dependencies through constructor parameters, with default values that provide the production instances; each module exports a pre-assembled singleton:

export class FileService {
  constructor(
    private readonly db = _db,
    private readonly s3Client = _s3Client,
    private readonly getSignedUrl = _getSignedUrl
  ) {}
  // ...
}

export default new FileService()

This avoids the complexity of a DI container while keeping DI's most important benefit: a unit test can simply call new FileService(mockDb, mockS3, mockSigner) without monkey-patching modules. Even functions such as bcrypt.compare and setSessionCookie are injected, so the authentication flow can be tested without touching real hashing or cookies.

Zod DTOs: One Schema, Three Uses

DTOs are defined with Zod, and each operation's variant is derived from one base schema with pick / omit / partial, so every field is defined only once:

export const InsertVenueDto = VenueDto.pick({ name: true, area: true, address: true, /* ... */ })
export const UpdateVenueDto = VenueDto.omit({ id: true, status: true, createdAt: true, updatedAt: true }).partial()
UseWhereEffect
Request validationreqValidator.json(InsertVenueDto)Invalid bodies / params get a 400 before reaching the controller
Response filteringVenueDto.parse(venue)Responses only include fields defined in the DTO, so new database columns never leak by accident
Type inferencez.infer<typeof InsertVenueDto>The frontend API client and the backend share one type; changing it breaks both sides at compile time

Unified Error Handling

  • An ErrorCode union type defines every error code in one place (UNAUTHORIZED, DATA_NOT_FOUND, CSRF_ERROR…) and is shared by frontend and backend
  • Every response goes through ResponseFormatter: success is always { data, pagination? }, failure is always { code, message, details? }; status codes that must not carry a body, such as 204 / 304, are handled correctly
  • Hono's app.onError is the last line of defense, turning any unexpected exception into INTERNAL_SERVER_ERROR
  • The frontend RequestClient normalizes non-2xx responses and network errors into the same ApiErrorResponse shape, and getErrorConfig maps each code to a user-friendly message

Authentication and Authorization

MechanismImplementation
Email / passwordStored as a bcrypt hash (10 salt rounds)
Google OAuth 2.0Authorization Code Flow without the Google SDK, calling the token and userinfo endpoints directly; accounts are created automatically with a find-or-insert by email
CSRF protectionGenerates a 64-byte random token stored in an httpOnly cookie (5 minutes) and, together with the "page to return to after login", base64-encoded into the OAuth state; the callback compares the two and deletes the cookie immediately to prevent replay
SessionHS256 JWT signed with jose, stored in an httpOnly, SameSite=Lax cookie (Secure in production) for 7 days; frontend JavaScript cannot read it
Authorization (RBAC)A global setUser middleware parses the session and puts the user into the Hono context; admin operations such as writing venues and seat maps add requireAdmin, and operations such as file uploads add requireLoggedIn

Page Route Guards: Chain of Responsibility

Page-level access control (/api-doc visible only in development, pages that require login, and /login, which logged-in users should not see) is written as several FunctionFactory guards, composed back-to-front into a single Next.js proxy by a generic chain():

export default chain(
  [withDevOnly, withLoggedInOnly, withLoggedOutOnly],
  0,
  () => NextResponse.next()
)

Each guard does exactly one thing and hands off to the next when it has nothing to intercept; adding a rule only takes a new factory added to the array, where array order is priority.

Isomorphic API Client and Data Fetching

RequestClient runs both in Server Components and in the browser. On the server it dynamically imports next/headers to read the current request's cookies and forwards them to the API, so authenticated endpoints can be called as the user during SSR; in the browser it uses credentials: 'include' instead. Pages fetch data on the server first and hand it to the client through React Query's initialData, so the first screen needs no extra request; subsequent creates, updates and deletes run as mutations followed by invalidateQueries to resync.

API Documentation

Each route is described with OpenAPI 3.0 JSDoc above it; next-swagger-doc scans server/modules to generate the spec automatically, and Swagger UI renders it at /api-doc, which the proxy restricts to development only.


Database Design (apps/web)

The database schema lives in apps/web/server/schema/. The Drizzle ORM TypeScript definitions are the source of truth (schema-as-code), with a DBML file maintained alongside as design documentation; on deployment, CI syncs the schema to Neon PostgreSQL with drizzle-kit push.

ER Diagram

There are 9 tables in three domains: users and files (users, files), contributions (contributions, contribution_images), and venues and seats (venues, seat_maps, seat_areas, seats, restrooms).

Database ER diagram (drawDB, imported from schema.dbml in the repo) · Open in drawDB

Primary Keys: UUID v7

Every table uses an application-generated UUID v7 primary key ($defaultFn(() => uuidv7())):

  • Time-ordered: the first 48 bits are a millisecond timestamp, so new rows always land at the end of the B-tree index, avoiding the page splits and index bloat caused by random UUID v4 inserts
  • Non-enumerable: IDs are used directly in the public API and in image file names; unlike auto-increment integers, they don't reveal data volume or invite sequential scraping
  • Generated in the application: the ID is known before the write, so it can be used to build the S3 file name first and then inserted, without waiting for the database to return it

Modeling the Venue and Seat Hierarchy

Seat data follows a venue → seat map → seat area → row → seat hierarchy:

  • seat_maps.map_svg stores the whole SVG seat map as text. The seat map editor sanitizes the exported SVG with DOMPurify (USE_PROFILES: { svg: true }) to prevent stored XSS from scripts embedded in SVG; each shape in the SVG (rect / polygon / path) maps to one seat_areas row
  • seat_areas → seat_maps uses ON DELETE CASCADE: seat areas are part of a seat map, so re-uploading a seat map should remove the old areas with it
  • seats deliberately denormalizes venue_id in addition to seat_area, so listing all seats of a venue doesn't require joining three tables
  • The composite index (seat_area, seat_row) matches the main query path on the frontend: click a section → list seats by row

Contributions and Images

  • contributions references both user_id and seat_id and denormalizes venue_id, with three indexes on venue_id, user_id and created_at supporting the venue page's contribution list, "my contributions" and the latest-contributions timeline respectively
  • satisfaction uses NUMERIC(2,1) (0.0–9.9) instead of a float to avoid precision errors when averaging ratings
  • files is a single file registry that records only the owner, URL and file name; domain tables reference it by foreign key instead of storing URLs themselves. contribution_images has two foreign keys to files:
    • file_id_with_watermark (NOT NULL): the publicly displayed version
    • file_id_without_watermark (NULLABLE): the original without a watermark, recorded separately from the public version
  • When one table has two relations to another, Drizzle distinguishes them with relationName, so relational queries such as with: { fileWithWatermark: true } can state the direction explicitly

Other Design Details

DesignNotes
PostgreSQL enumsBounded domains such as user_role, venue_status, taiwan_area, seat_type, distance and aircon_strength use pgEnum, rejecting invalid values at the database level
timestamptzEvery timestamp column stores a time zone; updated_at is written automatically on every update through Drizzle's $onUpdate
Nullable users.passwordAccounts registered through Google OAuth have no password; the login API uses this to return USER_FROM_OAUTH, prompting the user to sign in with the third-party provider
users.metadata (JSONB)Reserved for user attributes that are not settled yet, avoiding frequent migrations for experimental fields
Venue draft statusvenues.status defaults to DRAFT; admins publish a venue only after its seat map and details are complete

apps/image-processor: Image Processing Service

Responsibilities

Contribution photos are the platform's core content and also the assets most likely to be stolen. image-processor is a standalone Lambda with two jobs:

  • Watermarking: after an original is uploaded to origin/, it automatically produces a watermarked version in watermark/
  • On-demand resizing: requests to cdn.siyamap.com/resize/w_{width}/{folder}/{key} are resized on the fly and cached by CloudFront

Image uploads and processing never pass through the apps/web Lambda, so large files don't consume SSR execution time or memory.

Integration with Other Modules

There are no API calls between image-processor and apps/web. They cooperate only through S3 folder conventions and the same bucket environment variable (AWS_S3_BUCKET_IMAGE_UPLOAD): web decides which folder a file is written to, and image-processor decides from the folder whether to process it. The diagram below shows the full sequence of an image from upload to being viewed:

Image upload and processing sequence · Open in Miro

One Lambda, Two Event Sources

Watermarking (S3 events) and resizing (API Gateway HTTP events) are two completely different triggers, but they share the native sharp module, the S3 client and utility functions. So both are packaged into the same container image, and the entry point uses a TypeScript type guard to identify the event type and dispatch to the matching handler (Strategy pattern), leaving only one deployment unit to maintain:

export const handler = async (event: S3Event | APIGatewayProxyEventV2) => {
  if (isS3Event(event)) return watermarkHandler(event)
  if (isApiGatewayProxyEvent(event)) return resizeHandler(event)
  throw new Error(`[錯誤] 不支援的事件類型: ${JSON.stringify(event)}`)
}

Watermark Algorithm

The goal is a diagonal, staggered, low-opacity watermark that is hard to crop out yet doesn't get in the way of viewing:

  1. Scale the watermark to 10% of the image width and use linear() to scale only the alpha channel, bringing opacity down to 10%
  2. Tile it in a staggered brick pattern: every other row starts half a step further along
  3. Make the tiling canvas as wide as the image's diagonal plus a margin, so the whole layer still covers all four corners after rotating it -45°
  4. Center-crop the rotated layer back to the image size, then composite it over the original and output a JPEG

NOTE

In a sharp pipeline, rotate always runs before composite, regardless of the order of the calls in code. So the tiled result has to be rendered to a buffer first and rotated in a new pipeline; otherwise the rotation applies to the empty canvas rather than the tiled watermark.

Abuse Protection on the Resize Endpoint

The resize URL's width only accepts the whitelisted values 200, 400 and 800, with withoutEnlargement enabled. If any width were allowed, an attacker could keep changing the width parameter to force a cache miss on every request and hit the Lambda, which is billed by execution time, directly: a cost attack. The whitelist also keeps CloudFront's cache hit rate predictable.

Pitfall: 403 on Presigned URL Uploads

This problem sits at the boundary between web and S3. Since around version 3.370, AWS SDK v3 defaults to requestChecksumCalculation: 'when_supported', which automatically adds x-amz-checksum-crc32 to presigned URLs, so S3 then requires the PUT request to carry a matching CRC32 header. The browser's fetch doesn't compute that value, so every direct upload returned 403. The fix is to set requestChecksumCalculation: 'WHEN_REQUIRED' on the S3 client, computing checksums only when the API requires them.

Testing

Both watermarking and resizing have processor tests written with Jest. addWatermarkToImage uses function overloads to support both "write to a file" and "return a Buffer", making it easy to inspect the output on real images locally.


packages/ui: Shared Component Library

Responsibilities

@siyamap/ui is the component library that connects implementation to the UI design, providing shared components such as buttons and icons along with the Tailwind theme. Components are developed and showcased in isolation in Storybook, so designs and implementation can be aligned without starting the whole app.

Integration with Other Modules

  • Used by apps/web: web depends on it through workspace:*; Turborepo makes sure ui is built first, and web then compiles it into its own bundle with transpilePackages
  • Storybook deployed independently: CI syncs storybook-static to S3, served through CloudFront at stage.ui.siyamap.com, independently of web's deployment

Bundling Design

  • tsup outputs both CJS and ESM plus type declarations, with code splitting and minification enabled, and react / react-dom marked external to avoid bundling them twice
  • A "use client" banner is injected at the top of the bundle, so components can be imported directly from Next.js App Router Server Components without wrapping each use in a client component
  • Each component has a subpath export (such as @siyamap/ui/Icon), so only the components in use are imported, which helps tree-shaking
  • React is listed under peerDependencies, with its version managed centrally by the pnpm Catalog

Storybook and Testing

  • Storybook 10 with the a11y addon shows accessibility checks while components are being built
  • Vitest browser mode runs component tests in a real browser through Playwright, which is closer to actual rendering behavior than jsdom

Infrastructure and Deployment

AWS Architecture

Every service runs in AWS ap-southeast-1. After Route 53 resolves the four domains, traffic is routed by domain and path to different CloudFront distributions and backends:

AWS domain and path routing · Open in Miro (the board also shows the full resolution chain from the ISP's recursive DNS to Route 53, and how each service is billed)

Why Serverless

This is a side project with low, unpredictable traffic, so the top priority is "don't pay when nobody is using it". Every layer therefore uses pay-per-use services that scale to zero:

NeedChoiceReason
SSR and API computeLambda + Lambda Web AdapterBilled by execution time and free when idle; the Web Adapter runs the Next.js standalone server.js without rewriting it as a Lambda handler
Deployment unitContainer image (ECR)The container image size limit (10 GB) is far higher than zip deployments, so the Next.js standalone output and native modules such as sharp never hit a size ceiling; Docker also keeps local and production environments identical
Static assetsS3 + CloudFrontHashed _next/static files are served with max-age=31536000, immutable and never reach Lambda
DatabaseNeon Serverless PostgreSQLAuto-suspends when idle; the HTTP driver (drizzle-orm/neon-http) makes every query a stateless HTTP request, avoiding the classic problem of exhausting TCP connection pools under Lambda cold starts
Cloud permissionsIAM execution roleThe AWS SDK inside Lambda gets temporary STS credentials from the role, so no long-lived access keys live in code or environment variables

CI/CD Pipeline

On a push to the stage branch, a single GitHub Actions workflow deploys the three modules in sequence. Every Docker image is tagged with the git commit SHA, so any deployment can be traced and rolled back to the exact code version.

CI/CD pipeline · Open in Miro
StageHighlights
Shared buildBesides the private ECR, also logs in to the public ECR to avoid anonymous rate limits when pulling the Lambda Web Adapter and Lambda base images from public.ecr.aws
packages/uiHashed assets need no cache clearing, so only the two entry files index.html and iframe.html are invalidated, shortening the time to go live
apps/webUpdates the database schema with drizzle-kit push before switching the code; lambda wait function-updated ensures the new version is ready before static assets are synced
apps/image-processorBuilt and deployed to its own Lambda separately from web, so it is deployed and scaled independently

Static assets get different cache policies depending on whether the file name is hashed: _next/static is cached for one year as immutable, while unhashed files in public/ are cached for only one hour.

Comments(0)

No comments yet. Be the first to share your thoughts!