Skip to main content

App Shell & Core Providers

Overview

client-web-2026 is the end-user facing web application of the LINE Management platform, built on Next.js 16 App Router with React 19. Almost every page runs as a LIFF mini-app opened from inside the LINE app, whether through a rich menu, a link in a chat, or a scanned QR code.

This document covers the shared shell that every page sits on: the root layout, AppProvider, the React Query layer, Ant Design theming, the Zustand stores that carry state across pages, and the single axios instance used by every service. It is aimed at developers who are about to add a new page or change application-wide behaviour.

One point matters before reading any feature document: this project has no middleware and no server-side route guards at all. All authorization happens on the API side through the x-liff-token HTTP header. The web app only initializes LIFF, retrieves the token, and attaches it to each request. When the API responds with 401 or 403, the page renders the appropriate state instead.

Business Flow

  1. The user opens a URL, normally through LIFF. Next.js renders the root layout, which wraps everything in AppProvider.
  2. AppProvider first waits for Zustand to rehydrate its values from localStorage, preventing a hydration mismatch. A loading screen is shown while it waits.
  3. Once rehydration completes, providers are composed in order: ReactQueryProviderAntdRegistryConfigProvider (theme) → Ant Design's App component, which is required for message and modal calls.
  4. Each page reads the hash parameter from the URL and starts its own flow (see The hash Route & LINE OA Resolution).
  5. Every request goes through the shared axios instance, whose base URL comes from the NEXT_PUBLIC_BASE_API_CLIENT_URL environment variable (defaulting to /api).
  6. The root path / plays a special role: LIFF redirects users back to the root endpoint with a liff.state query parameter, so this page reads that value and calls router.replace to reach the real destination path.

Key Screens & Components

Layout and provider stack

  • The root layout (src/app/layout.tsx) sets site-wide metadata from the NEXT_PUBLIC_SEO_TITLE and NEXT_PUBLIC_SEO_DESCRIPTION environment variables.
  • The entry page (src/app/page.tsx) handles liff.state, performs the redirect, and triggers LIFF authentication.
  • AppProvider (src/providers/app.provider.tsx) is responsible for store rehydration, registering the Ant Design registry, and installing the theme.
  • ReactQueryProvider (src/providers/react-query.provider.tsx) configures a 5-minute stale time, a 10-minute garbage collection time, disabled automatic retries, and no refetch on window focus.

Theming and styling

  • useThemeConfig() (src/hooks/use-theme-config.ts) returns the Ant Design theme configuration, with #1677ff as the primary colour.
  • Tailwind CSS 4 is used alongside Ant Design's inline styles, with a cn() helper (src/utils/tailwind.util.ts) for safely merging class names.

Cross-page state (Zustand stores)

  • useAppStore (src/store/app.store.ts) holds redirectUri, lineLiffId, and webhookKey, persisted to localStorage under the APP key.
  • useUserStore (src/store/user.store.ts) holds the user profile (id, display name, avatar URL) under the APP:USER key.

API layer

  • The shared axios instance and its error handler handleAxiosError() live in src/service/axios-instance.ts.
  • Base URL constants per destination (BASE_API_CLIENT_URL, BASE_API_ADMIN_URL, BASE_API_LINE_URL) and the list of API module names are collected under src/service/constants/.

Reusable UI components

  • Page scaffolding, header, and footer components live in src/components/layout-app/.
  • Primitives such as the loading overlay, card, and skeleton live in src/components/ui/.

Dependencies

  • Next.js 16 App Router + React 19 — every page is a client component, except layouts that need server-side generateMetadata.
  • Ant Design 6 together with @ant-design/nextjs-registry, which is required for React 19 compatibility.
  • TanStack Query v5 powers all data fetching, including queries, infinite queries, and mutations.
  • Zustand 5 with the persist middleware for state that must survive a page refresh.
  • axios is the single HTTP client shared by every service.
  • @line/liff underpins authentication; see LINE Login via LIFF.
  • The project has no middleware file. All redirects and access checks happen inside client components.

Backend Details (Client API)

Every call from the shared axios instance lands on the client-api service, written in Go (gin + sqlx with hand-written SQL, no ORM). It is deliberately a 1:1 port of the previous NestJS API: identical error shapes, validation messages, date serialization, and route paths — so the backend could be swapped without changing a single line in the web app.

The error envelope the web app must handle

Every error and panic is caught by an exception middleware and converted into the same envelope: {"statusCode", "message", "error"}, where

  • message may be either a single string or an array of strings (when several validation errors occur at once) — the web-side handleAxiosError() has to tolerate both shapes.
  • error is the HTTP reason phrase, e.g. Bad Request or Unauthorized.
  • Any error that is not a declared exception (a bug or a panic) becomes a 500 with the fixed message System Failure; no internal detail leaks out.
  • Calling a path that does not exist returns 404 inside the same envelope with the message Cannot <METHOD> <path> — not a framework 404 page.

The middleware stack every request passes through

From outermost inwards: metrics collection (status + latency) → CORS → security headers → exception handler. Once a request enters the /api group, two more layers apply: a request-scoped context and the rate limiter.

  • CORS is fully open (origin/methods/headers are all unconditionally *), kept for parity with the old service. The web app can therefore call cross-origin with no configuration, but it also means there is no server-side origin restriction — security rests entirely on x-liff-token.
  • Security headers are applied with the default set, except Cross-Origin-Resource-Policy, which is disabled (matching the old service) so cross-origin resources can still load.
  • The request-scoped context builds a transactionId from the incoming X-Request-Id header, or generates one when absent, and echoes it back in the X-Request-Id response header — extremely useful when reporting a problem, since that value traces directly to the logs. It also reads x-lang to choose the language of error messages.

Application-level rate limiting (why the web app sees 429)

  • Counting is keyed on client IP, not on the user, resolved in order from the first x-forwarded-for entry → x-real-ipx-client-ip → the socket.
  • It is a sliding window: once the limit is exceeded a block key is set, so every subsequent request returns 429 immediately for the whole block duration without being counted. Hammering retry therefore neither helps nor extends the block — but it does not shorten it either; the block must expire.
  • A side effect worth knowing: multiple users behind the same egress IP (office NAT, mobile carrier NAT) share a single quota — a real-world cause of 429s that appear to make no sense.
  • Counters live in Redis (replacing the old per-pod in-memory limiter, so the count is now shared across pods), and any Redis error lets the request through (fail open) — traffic is never rejected because storage is broken.

Health checks and what they mean for API availability

  • Liveness (/livez, /healthz — process only) is separated from readiness (/readyz — tied to ping results for every enabled dependency plus the draining flag).
  • Liveness is intentionally not tied to the database, because restarting a pod does not fix a downed DB and would only get the pod killed in a loop.
  • Ping results are cached by a background ticker; probes do not hit the database on every request.
  • On shutdown, readiness is made to fail before the server stops accepting requests, so the load balancer removes the pod first — a normal deploy should therefore not cut off in-flight requests from the web app.
  • GET /api returns a short text/plain message, useful as a smoke test that the API is really up, and GET /api/health returns a per-dependency breakdown (503 if any dependency is down).
  • /metrics and profiling live only on a separate admin port (:9100) and are never exposed on the port the web app calls.

Edge cases worth knowing

  • Database, Redis, and the message queue are all optional dependencies — the service boots even when some are missing. In that case the rate limiter and cache silently become no-ops rather than raising errors.
  • If a dependency stays down, the service retries, raises an alert after one minute, and after three minutes drains and exits so the orchestrator replaces the pod. During that window the web app may see transient errors before recovering on its own.
  • All configuration is validated at boot (fail-fast), so if the API is up its configuration is known-good — mid-flight config drift is not a failure mode to investigate.