Skip to content

About

React frontend for the Estado project — CRUD for Brazilian states, JWT auth, TanStack Query

Topics

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

react_state

CI CodeQL codecov

🔗 Live application

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).

Architecture

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
Loading

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.

Screenshots

States list

Create state form

Stack

Structure

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

Conventions

  • 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 updating i18n/routing.ts and supplying a matching messages/<locale>.json catalog. 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; the X-Request-Id header 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.ts is a small anti-corruption layer that maps that wire format to/from the English State domain type, so the rest of the app never sees the Portuguese field names.
  • Accessibility: aria-invalid/aria-describedby on form fields, dynamic aria-label on 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's app/error.tsx (RouteError), avoiding a blank screen.
  • stateService.list fetches 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 the estado repo) — the frontend still consumes that contract, it just doesn't surface paging in the UI for a dataset that always fits on one page.

When the API is turned off

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.

Prerequisites

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).

Development server

npm install
npm run dev

The /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/.

Environment variables

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.

Build

npm run build

Outputs the production build to .next/ (run it with npm start).

Deployment

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.

Lint and formatting

npm run lint          # eslint
npm run format:check  # prettier --check
npm run format        # prettier --write

Husky + lint-staged run eslint --fix and prettier --write on pre-commit.

Tests

npm test               # vitest
npm run test:coverage  # vitest run --coverage

Vitest + 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).

About

React frontend for the Estado project — CRUD for Brazilian states, JWT auth, TanStack Query

Topics

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Used by

Contributors

Languages