CRUD for Brazilian federative units (states) — abbreviation, name, and
created/updated timestamps — consuming the Estado project's
REST API at /api/estado. An alternate React frontend for the same backend, alongside the
original Angular one. It also includes an Ask AI
page backed by the estado-ai-agent, which answers
questions using live state data. See the backend's
CASE_STUDY.md for the
end-to-end system write-up (architecture, decisions, trade-offs).
flowchart LR
Browser["Browser"]
subgraph Vercel["Vercel"]
direction LR
Static["Next.js app\n(React bundle)"]
Rewrite["/api route handler\n(BFF proxy)"]
end
subgraph EC2["EC2 (Docker)"]
direction LR
App["estado-app\n(Spring Boot + Spring Security)"]
DB[("Postgres")]
Agent["estado-ai-agent\n(Spring AI + Gemini)"]
end
subgraph OnDemand["Turn on the API on demand"]
direction LR
Lambda["Lambda start-api\n(Function URL)"]
Turnstile["Cloudflare Turnstile"]
Autostop["EventBridge\n(autostop rule)"]
end
Browser -- "/[locale] (SSR)" --> Static
Browser -- "/api/*" --> Rewrite
Rewrite --> App
App --> DB
App -- "/ask" --> Agent
Agent -- "tool calls: /estado/*" --> App
Browser -. "API unreachable:\nTurn on the API" .-> Lambda
Lambda -. "validates the challenge" .-> Turnstile
Lambda -. "starts / stops" .-> EC2
Autostop -. "stops it 2h after" .-> Lambda
The app never talks to the backend's own origin — the catch-all route
handler src/app/api/[...path]/route.ts forwards allowed /api/* requests, including /api/ask, to it
server-side (target: BACKEND_API_URL), so the browser only ever sees the
deployment's own origin
(see Deployment below). This diagram is infra/network
topology, not API routes — it doesn't distinguish GET from
POST/PUT/DELETE on /estado, so JWT auth (an internal concern of
the Spring Boot app itself) doesn't add a box or arrow here either.
POST /auth/login returns a short-lived JWT, kept in localStorage and
attached as Authorization: Bearer <token> by an axios request
interceptor on state-mutating calls only — listing and reading states
stays public, matching the backend's own authorization rule.
The Ask AI UI submits through the same BFF; its 50s server-side timeout
allows the backend's 45s AI-proxy timeout to return first. The ASK_API_KEY
remains configured only on the backend, never in this frontend's environment
or browser bundle.
Demo credentials (intentionally public): see the backend's
estado README.
- React 19 + TypeScript (strict) + Next.js (App Router)
- next-intl for locale-prefixed routing (
/en,/pt), localized UI and metadata - TanStack Query for data-fetching, caching, and invalidation
- Axios with request-id and timeout/retry interceptors
- react-markdown + remark-gfm for safe AI response rendering
- React Hook Form for form validation
- Tailwind CSS v4
- Vitest + React Testing Library for component tests
- ESLint (
eslint-config-next) + Prettier + Husky/lint-staged
src/
├── app/ # App Router: pages, layouts, Ask AI UI, /api proxy route, error.tsx
├── i18n/ # next-intl routing, locale-aware navigation and request config
├── messages/ # English and Brazilian Portuguese message catalogs
├── shared/
│ ├── Layout/ # app shell: title bar + children + Footer
│ ├── Footer/ # FE/BE build info, hides silently on failure
│ ├── StateForm/ # create/edit form (React Hook Form)
│ ├── FormPage/ # shared create/edit page shell (title + ErrorMessage wrapper)
│ ├── ErrorMessage/ # error alert (role="alert") + useErrorMessage hook
│ ├── ProtectedRoute/ # redirects to /login when the JWT is missing/expired
│ ├── Spinner/ # loading indicator (role="status")
│ ├── Icon/ # SVG icons centralized by name
│ └── RouteError/ # render-error fallback per route
├── hooks/ # TanStack Query hooks (list, get, create, update, delete,
│ login, frontend/backend version for the footer)
├── lib/ # httpClient (axios + interceptors), tokenStorage (JWT in
│ localStorage), error extractors, formatDate
├── interfaces/ # State / NewState / FrontendVersion / BackendInfo domain types
├── services/ # stateService + stateApiMapper (wire <-> domain), authService,
│ askService, infoService
└── proxy.ts # Next proxy (ex-middleware): sets the CSP header
scripts/
└── generate-version.mjs # writes public/version.json (commit + build date) on build,
read by the footer to show the deployed frontend version
- Function components + hooks, no classes.
- Locale is explicit in the URL (
/en/...or/pt/...); the locale switcher preserves the current page, and the/api/*BFF routes remain locale-neutral. Add a language by updatingi18n/routing.tsand supplying a matchingmessages/<locale>.jsoncatalog. Validation and API fallback messages are translated at their call sites rather than inferred from display text. - Ask AI requests use the locale in the URL and pass it to the same-origin BFF.
- Data cache via TanStack Query (
hooks/) instead of manual per-page fetching — every mutation invalidates the affected query, keeping the list in sync across navigations. httpClient(lib/httpClient.ts) centralizes a 15s timeout and retry (up to 2 attempts, GET only) via axios interceptors; theX-Request-Idheader is generated once per action and preserved across retries, so all attempts correlate as a single action in the backend logs.- The backend's actual JSON contract uses Portuguese field names
(
sigla,nome,dataHoraCadastro,dataHoraUltimaAtualizacao) and the API path segment is/estado— both are the real wire contract and are not translated.services/stateApiMapper.tsis a small anti-corruption layer that maps that wire format to/from the EnglishStatedomain type, so the rest of the app never sees the Portuguese field names. - Accessibility:
aria-invalid/aria-describedbyon form fields, dynamicaria-labelon the per-row action buttons,role="status"/role="alert"for loading/error states. - Request errors (query/mutation) are handled per page via
useErrorMessage; unexpected render errors are caught by the App Router'sapp/error.tsx(RouteError), avoiding a blank screen. stateService.listfetches the whole dataset in a single request instead of exposing page-size/pagination controls in the UI: the domain is closed at 27 items (the Brazilian states), so real pagination never triggers in practice. The backend only exposes the paginated endpoint (/estado/paginado, see ADR 0018 in theestadorepo) — the frontend still consumes that contract, it just doesn't surface paging in the UI for a dataset that always fits on one page.
To save cost, the backend instance is stopped while the project isn't in use. When the API doesn't
answer (no response, timeout or 502/503/504), the error message says so and offers a Turn on the
API link. It opens a page served by the backend's start-api Lambda (anti-bot challenge, starts the
instance, waits for the API to come back; the instance stops by itself after 2 hours), see
ADR 0026. Implemented in src/lib/apiErrorMessage.ts.
Requires a State API backend running locally at http://localhost:8090 —
without it, npm run dev still serves the UI, but calls to /api/* fail
(the list shows the error message once the retry is exhausted).
npm install
npm run devThe /api route handler proxies to http://localhost:8080 by default; to
target the local backend on 8090, create .env.local with
BACKEND_API_URL=http://localhost:8090. Open http://localhost:3000/.
BACKEND_API_URL — server-side only: the backend base URL the /api route
handler forwards to. Required in production (the route answers 500 if it's
missing); defaults to http://localhost:8080 in development. Deliberately
not a NEXT_PUBLIC_ variable: that would be inlined into the client
bundle and make the browser call the backend directly, bypassing the proxy
and tripping the CSP's connect-src 'self'. httpClient always uses the
same-origin /api.
BACKEND_PROXY_SECRET — server-side only, optional: a shared secret that lets
the backend trust the client IP this app forwards (as X-Client-IP, taken
from Vercel's x-vercel-forwarded-for). Without it the backend only sees
Vercel's egress IPs, so its per-IP rate limits (including the 5-per-minute
login limit) are shared by every visitor. It must match the backend's
RATE_LIMIT_PROXY_SECRET; if either is unset the app still works, just
with the shared-IP behaviour.
npm run buildOutputs the production build to .next/ (run it with npm start).
Live at react-state-flax.vercel.app.
Deployed on Vercel as a Next.js app (zero-config, no vercel.json). The
/api route handler forwards to the live backend named by the
BACKEND_API_URL project env var — the browser only ever talks to the
deployment's own origin, so it avoids CORS entirely (the backend only
allows its own CloudFront origin). Run npx vercel --prod from a
Vercel-linked checkout.
There's no one-click "Deploy with Vercel" button: BACKEND_API_URL must
point at a backend you control, and this project's own backend only allows
its own origin.
npm run lint # eslint
npm run format:check # prettier --check
npm run format # prettier --writeHusky + lint-staged run eslint --fix and prettier --write on pre-commit.
npm test # vitest
npm run test:coverage # vitest run --coverageVitest + React Testing Library, covering the list/create/edit/delete and Ask AI flows,
shared-form validation, the support components (Icon, Spinner,
ErrorMessage/useErrorMessage, RouteError), and lib/services
utilities. Coverage is reported in CI via Codecov, gated at 90% (project) /
80% (patch).

