Upload Component Backend Integration Analysis
Analysis of how the image upload, display, and editing components connect to the REST backend, and a proposal for using TanStack Query as the integration layer.
1. Connection Points
Section titled “1. Connection Points”The image upload feature has six distinct backend connection points, each with different characteristics (mutation vs. query, frequency, error handling needs).
1.1 Presigned Upload Credentials (Mutation)
Section titled “1.1 Presigned Upload Credentials (Mutation)”| Aspect | Detail |
|---|---|
| Trigger | User confirms image in ImageUploadDialog (Confirm click) |
| Flow | SPA → BFF POST /api/image-upload → Backend POST /v1/item/image-upload |
| Request | { contentType: "image/jpeg", contentLength: number } |
| Response | { uploadUrl, formFields, objectKey, cdnUrl } |
| Consumer | ImageUploadDialog via onUpload callback prop |
| Nature | One-shot mutation — each upload requires fresh credentials |
| Errors | 401 (session expired), 502 (Backend unavailable) |
| api-proxy | BFF uses ItemProxy.createImageUploadUrl() to call Backend |
1.2 Direct-to-S3 Upload (Mutation)
Section titled “1.2 Direct-to-S3 Upload (Mutation)”| Aspect | Detail |
|---|---|
| Trigger | Immediately after receiving presigned credentials |
| Flow | SPA → S3 presigned POST URL (direct, no BFF) |
| Request | Multipart form with formFields + image Blob |
| Response | HTTP 204 (no body) |
| Consumer | Production ImageUploadHandler (internal to upload orchestration) |
| Nature | One-shot mutation with progress tracking (XHR upload.onprogress) |
| Errors | Network failure, S3 policy rejection (wrong content type or size) |
| api-proxy | Not used — SPA posts directly to S3 presigned URL |
1.3 Entity Persist with CDN URL (Mutation)
Section titled “1.3 Entity Persist with CDN URL (Mutation)”| Aspect | Detail |
|---|---|
| Trigger | After successful S3 upload |
| Flow | SPA → BFF PUT /api/items/<itemEId> → Backend |
| Request | Full entity payload with imageUrl set to cdnUrl from credentials response |
| Response | Updated entity record |
| Consumer | ImageUploadDialog.onConfirm → form state → entity save |
| Nature | Standard entity update mutation (already exists for other fields) |
| Errors | 400 (URL validation failure), 401, 502 |
| api-proxy | BFF uses ItemProxy.update() to call Backend (existing method) |
1.4 URL Reachability Check (Mutation-like Query)
Section titled “1.4 URL Reachability Check (Mutation-like Query)”| Aspect | Detail |
|---|---|
| Trigger | User enters/pastes an external URL in ImageDropZone |
| Flow | SPA direct HEAD → on CORS failure → BFF POST /api/storage/check-url |
| Request | { url: "https://..." } |
| Response | { reachable, contentType, contentLength } |
| Consumer | ImageUploadDialog via onCheckReachability callback prop |
| Nature | On-demand, user-initiated — not cacheable (external URL state can change) |
| Errors | 400 (invalid URL, SSRF), 422 (unreachable), 429 (rate limited) |
| api-proxy | Not used — BFF performs server-side HEAD/GET to external URLs directly |
1.5 External Image Fetch (Mutation-like Query)
Section titled “1.5 External Image Fetch (Mutation-like Query)”| Aspect | Detail |
|---|---|
| Trigger | After reachability check passes, if direct fetch fails due to CORS |
| Flow | SPA direct GET → on CORS failure → BFF POST /api/storage/fetch-url |
| Request | { url: "https://..." } |
| Response | Image bytes with Content-Type header |
| Consumer | ImageDropZone / ImageUploadDialog URL processing pipeline |
| Nature | One-shot fetch — result loaded into editor canvas, not cached |
| Errors | 400 (SSRF), 413 (too large), 422 (unreachable), 429 (rate limited) |
| api-proxy | Not used — BFF performs server-side fetch to external URLs directly |
1.6 CDN Cookie Lifecycle (Query + Background Refresh)
Section titled “1.6 CDN Cookie Lifecycle (Query + Background Refresh)”| Aspect | Detail |
|---|---|
| Trigger | Session start, proactive timer (~15 min), tenant switch, 403 recovery |
| Flow | SPA → BFF POST /api/storage/cdn-cookies → Set-Cookie headers |
| Request | None (tenant from session) |
| Response | 204 No Content; cookies in Set-Cookie headers |
| Consumer | CDN cookie lifecycle manager (global, not per-component) |
| Nature | Background refresh on interval — closest to a query with refetchInterval |
| Errors | 401 (session expired), 500 (signing key unavailable) |
| api-proxy | Not used — BFF signs cookies locally using CloudFront private key |
Connection Point Summary
Section titled “Connection Point Summary”TanStack Query runs entirely in the SPA (browser). It manages query/mutation state, caching, background refresh, and retry logic client-side.
@arda-cards/api-proxy runs entirely in the BFF (Next.js server-side
API routes). The BFF routes use ItemProxy from api-proxy to make type-safe
calls to the Backend with the server-side ARDA_API_KEY. The SPA never
imports or uses api-proxy — it has no access to the API key. The SPA’s API
functions layer (src/api/image-upload.ts) uses plain fetch() to call the
BFF routes, not the Backend directly.
The network boundary (split over the wire) sits between the SPA API
functions and the BFF routes. Inside each TanStack Query hook, a fetch()
call crosses the wire to the BFF. The BFF then uses api-proxy to call the
Backend, or performs server-side work (SSRF-protected fetches, cookie signing).
The S3 presigned POST is a special case: the SPA sends it directly to S3,
bypassing both the BFF and api-proxy.
2. TanStack Query: Standard Patterns for React + REST
Section titled “2. TanStack Query: Standard Patterns for React + REST”TanStack Query (formerly React Query) provides a declarative data-fetching and server-state management layer for React. It separates server state (data owned by the backend) from client state (UI state owned by the browser).
2.1 Core Concepts
Section titled “2.1 Core Concepts”Queries (useQuery) — declarative reads with automatic caching, background
refetch, stale-while-revalidate, and deduplication.
// Standard pattern: a query hook wrapping a fetch functionfunction useItem(itemEId: string) { return useQuery({ queryKey: ['item', itemEId], queryFn: () => fetch(`/api/items/${itemEId}`).then(r => r.json()), staleTime: 5 * 60 * 1000, // Data considered fresh for 5 minutes });}
// Usage in componentfunction ItemDisplay({ itemEId }: { itemEId: string }) { const { data, isLoading, error } = useItem(itemEId); if (isLoading) return <Skeleton />; if (error) return <ErrorBadge />; return <div>{data.name}</div>;}Mutations (useMutation) — imperative writes with optimistic updates,
rollback, and cache invalidation.
// Standard pattern: a mutation hook wrapping a POST/PUT/DELETEfunction useUpdateItem() { const queryClient = useQueryClient();
return useMutation({ mutationFn: (vars: { itemEId: string; input: ItemInput }) => fetch(`/api/items/${vars.itemEId}`, { method: 'PUT', body: JSON.stringify(vars.input), }).then(r => r.json()), onSuccess: (_data, vars) => { // Invalidate cached data so queries refetch queryClient.invalidateQueries({ queryKey: ['item', vars.itemEId] }); queryClient.invalidateQueries({ queryKey: ['items'] }); }, });}
// Usage in componentfunction ItemForm({ itemEId }: { itemEId: string }) { const mutation = useUpdateItem(); const handleSave = (input: ItemInput) => { mutation.mutate( { itemEId, input }, { onSuccess: () => toast('Saved'), onError: (e) => toast.error(e.message) } ); }; return <form onSubmit={handleSave}>...</form>;}2.2 Query Keys
Section titled “2.2 Query Keys”Query keys are the cache identity. TanStack Query uses structural equality for key matching and supports hierarchical invalidation:
// Hierarchical key convention['items'] // All items (list)['items', { page: 1 }] // Paginated list['item', 'abc-123'] // Single item['item', 'abc-123', 'supplies'] // Item's supplies
// Invalidation scopingqueryClient.invalidateQueries({ queryKey: ['items'] }); // All item listsqueryClient.invalidateQueries({ queryKey: ['item', 'abc-123'] }); // One item + children2.3 Recommended Project Structure
Section titled “2.3 Recommended Project Structure”The standard TanStack Query project structure separates API functions from hooks, keeping hooks thin:
src/├── api/ # Plain async functions (no React)│ ├── image-upload.ts # uploadImage(), checkReachability(), ...│ └── items.ts # getItem(), updateItem(), ...├── hooks/│ ├── queries/ # useQuery wrappers│ │ └── use-cdn-cookies.ts│ └── mutations/ # useMutation wrappers│ ├── use-image-upload.ts│ └── use-check-reachability.ts├── lib/│ └── query-client.ts # QueryClient singleton + defaults└── providers/ └── query-provider.tsx # QueryClientProvider wrapperKey conventions:
- API functions are plain
asyncfunctions — no React hooks, importable from anywhere (components, mutations, tests). - Hooks are thin wrappers — configure cache keys, stale times, and invalidation; delegate fetching to API functions.
- Query keys are co-located with hooks — export key factories from the same module for use in invalidation.
- Mutations invalidate queries — after a successful write, invalidate the relevant query keys so the UI reflects the new state.
2.4 Mutations with Multi-Step Orchestration
Section titled “2.4 Mutations with Multi-Step Orchestration”For workflows that chain multiple async steps (like upload), TanStack Query’s
mutationFn can orchestrate the full sequence:
// Multi-step mutation: get credentials → upload to S3 → persist entityfunction useImageUpload() { const queryClient = useQueryClient();
return useMutation({ mutationFn: async (vars: { itemEId: string; imageBlob: Blob; contentType: string; onProgress?: (pct: number) => void; }) => { // Step 1: Get presigned credentials const credentials = await getImageUploadUrl(vars.itemEId, { contentType: vars.contentType, contentLength: vars.imageBlob.size, });
// Step 2: Upload to S3 via presigned POST await uploadToS3(credentials.uploadUrl, credentials.formFields, vars.imageBlob, vars.onProgress);
// Step 3: Return CDN URL for entity persist return credentials.cdnUrl; }, onSuccess: (_cdnUrl, vars) => { queryClient.invalidateQueries({ queryKey: ['item', vars.itemEId] }); }, });}2.5 Background Refresh Pattern
Section titled “2.5 Background Refresh Pattern”For the CDN cookie lifecycle, TanStack Query’s refetchInterval provides
automatic background refresh:
function useCdnCookies() { return useQuery({ queryKey: ['cdn-cookies'], queryFn: () => fetch('/api/storage/cdn-cookies', { method: 'POST' }), refetchInterval: 15 * 60 * 1000, // Refresh every 15 minutes (~50% of 30min TTL) refetchIntervalInBackground: true, // Keep refreshing even when tab is hidden staleTime: 10 * 60 * 1000, retry: 2, });}2.6 Query and Mutation State in Components
Section titled “2.6 Query and Mutation State in Components”TanStack Query exposes loading, error, and data states that map directly to component visual states:
| TanStack State | Component State | Visual |
|---|---|---|
isLoading | Loading | Skeleton / shimmer |
isError | Error | Placeholder + error badge |
isSuccess | Loaded | Image rendered |
mutation.isPending | Uploading | Progress indicator |
mutation.isError | UploadError | Error message + retry |
2.7 Dependency Strategy: Where to Add @tanstack/react-query
Section titled “2.7 Dependency Strategy: Where to Add @tanstack/react-query”Neither ux-prototype nor arda-frontend-app currently depends on
@tanstack/react-query. The only TanStack package in the workspace is
@tanstack/react-table (in arda-frontend-app, used for grid rendering —
unrelated to React Query).
The dependency placement must respect the design system boundary: components in
@arda-cards/design-system are backend-agnostic and must not import TanStack
Query directly. The question is whether the design system should declare
TanStack Query as a peer dependency (if components expose TanStack-aware
interfaces) or whether the dependency is entirely an arda-frontend-app
concern.
Current dependency model in ux-prototype
Section titled “Current dependency model in ux-prototype”| Category | Examples | Pattern |
|---|---|---|
dependencies | radix-ui, browser-image-compression, react-easy-crop, heic2any | Bundled into the package — consumers get them transitively |
peerDependencies | react, react-dom, ag-grid-community, lucide-react | Consumer must install; design system uses but does not bundle |
devDependencies | react-dropzone, @storybook/*, vitest, @testing-library/* | Development/test only; not shipped in the published package |
Note: react-dropzone is a devDependency of ux-prototype (used in
Storybook development) but a direct dependency of arda-frontend-app
(used at runtime). This is because ImageDropZone imports react-dropzone
at the component level, so the consuming app must provide it.
Recommended placement for @tanstack/react-query
Section titled “Recommended placement for @tanstack/react-query”arda-frontend-app — direct dependency:
@tanstack/react-query and @tanstack/react-query-devtools are added as
direct dependencies. This is where the QueryClient, QueryClientProvider,
all useQuery/useMutation hooks, and API functions live. This is the only
repository that calls TanStack Query APIs.
arda-frontend-app/package.json dependencies: "@tanstack/react-query": "^5.x" "@tanstack/react-query-devtools": "^5.x" # dev convenience, tree-shaken in produx-prototype — no dependency (current recommendation):
If the design system components remain backend-agnostic with plain callback
props (onUpload: (file: Blob) => Promise<string>), they have no reason to
depend on @tanstack/react-query in any form. The wiring between TanStack
Query and component props happens entirely in arda-frontend-app.
This is the simplest model and preserves maximum flexibility — the design system can be consumed by any app regardless of its data-fetching library.
Alternative: ux-prototype — peerDependency (conditional):
If a design decision during this project determines that components should
expose TanStack-aware interfaces (e.g., accepting a UseMutationResult
directly, or providing built-in QueryClientProvider wiring), then
@tanstack/react-query would be added as a peerDependency of
ux-prototype:
ux-prototype/package.json peerDependencies: "@tanstack/react-query": "^5.x"This would follow the same pattern as react and ag-grid-community — the
design system uses it but does not bundle it; the consuming app must install
a compatible version. This approach couples the design system to TanStack
Query, which is a significant architectural decision and should be recorded in
the project decision log if chosen.
Dependency summary
Section titled “Dependency summary”| Package | ux-prototype | arda-frontend-app | Rationale |
|---|---|---|---|
@tanstack/react-query | none (recommended) or peerDependency (if components become TanStack-aware) | dependency | Hooks and QueryClient live in the app |
@tanstack/react-query-devtools | none | dependency | Dev panel for debugging queries/mutations |
@tanstack/react-table | none | dependency (existing) | Unrelated — AG Grid table adapter |
Decision (FD-01): The callback-only approach is confirmed — no TanStack dependency in the design system. Components use typed data provider hooks; the app provides TanStack-backed implementations. See tanstack-component-binding-analysis.md.
2.8 TanStack Query vs. Native React Patterns
Section titled “2.8 TanStack Query vs. Native React Patterns”The analysis so far assumes TanStack Query as the integration layer. This section evaluates that assumption against native React capabilities to determine whether the added dependency is justified.
Native React patterns available (React 19 + Next.js 16)
Section titled “Native React patterns available (React 19 + Next.js 16)”For client-side data fetching and mutations, React provides:
useState+useEffect+fetch()— the standard pattern for triggering async operations and tracking loading/error/data states.useReducer— for complex state machines (theImageUploadDialogalready uses this for its phase transitions).useTransition— marks state updates as non-urgent; keeps the UI responsive during async work.useCallback+AbortController— for stable fetch references with cancellation support.- Custom hooks — encapsulate fetch + state into reusable
useXxxhooks (e.g.,useImageUploadreturning{ mutate, isPending, error }). setInterval/setTimeout— for background polling (CDN cookie refresh).
A native React implementation of the upload mutation would look like:
// Native React — no TanStack dependencyfunction useImageUpload() { const [state, setState] = useState<{ status: 'idle' | 'pending' | 'error' | 'success'; error: Error | null; }>({ status: 'idle', error: null });
const mutate = useCallback(async (vars: { imageBlob: Blob; onProgress?: (pct: number) => void; }) => { setState({ status: 'pending', error: null }); try { const credentials = await getImageUploadUrl({ ... }); await uploadToS3(credentials.uploadUrl, credentials.formFields, vars.imageBlob, vars.onProgress); setState({ status: 'success', error: null }); return credentials.cdnUrl; } catch (err) { setState({ status: 'error', error: err as Error }); throw err; } }, []);
const reset = useCallback(() => { setState({ status: 'idle', error: null }); }, []);
return { mutate, isPending: state.status === 'pending', isError: state.status === 'error', error: state.error, reset, };}Comparison per connection point
Section titled “Comparison per connection point”| Connection Point | What TanStack adds | Native React equivalent | Net benefit |
|---|---|---|---|
| 1.1 Presigned credentials | useMutation with isPending/isError/reset | useState + try/catch in custom hook | Low — one-shot mutation, no caching needed |
| 1.2 S3 upload | Nothing — this is a raw XHR, not a fetch | Same — XHR with onprogress either way | None |
| 1.3 Entity persist | Cache invalidation after mutation | Manual refetch or Redux dispatch | Low — existing pattern uses Redux thunks |
| 1.4 Reachability check | useMutation | useState + try/catch | Low — one-shot, no caching |
| 1.5 External image fetch | useMutation | useState + try/catch | Low — one-shot, no caching |
| 1.6 CDN cookies | refetchInterval, refetchIntervalInBackground, auto-retry, stale tracking | setInterval + useState + manual retry logic | Medium — background lifecycle management is the strongest case |
Pros of TanStack Query
Section titled “Pros of TanStack Query”-
CDN cookie lifecycle —
refetchIntervalwithrefetchIntervalInBackgroundis significantly cleaner than a hand-rolledsetInterval+ cleanup + error-retry + visibility-change handler. This is the strongest argument. -
Declarative state —
isPending,isError,isSuccess,error,resetare provided out of the box. Native React requires managing these manually in each hook — not hard, but repetitive. -
DevTools — the React Query DevTools panel provides visibility into active queries, mutations, cache state, and timing. Useful for debugging the cookie refresh lifecycle and upload flows.
-
Establishes a pattern — if the app migrates more server-state management from Redux thunks to TanStack Query over time, starting with image upload sets the foundation. The existing
ardaClient.ts+ Redux thunks pattern could gradually shift to TanStack Query for data fetching. -
Community standard — TanStack Query is the de facto standard for server-state management in React. Engineers familiar with it will recognize the patterns immediately.
-
Retry and error recovery — configurable retry logic, exponential backoff, and error boundaries are built in. Native React requires implementing these manually.
Cons of TanStack Query
Section titled “Cons of TanStack Query”-
New dependency — adds
@tanstack/react-query(~44 KB gzip) and@tanstack/react-query-devtoolsto the bundle. The app currently has zero TanStack Query usage. -
Second state management paradigm — the app already uses Redux Toolkit with redux-persist for client state AND server state (items, kanban cards are fetched via Redux thunks today). Adding TanStack Query means two coexisting patterns for data fetching: Redux thunks for existing features, TanStack Query for image upload. Engineers must understand both.
-
Provider setup — requires
QueryClientProviderin the component tree alongside the existingReduxProvider,AuthProvider,JWTProvider,SidebarVisibilityProvider, andOrderQueueProvider. The provider stack is already 5 levels deep. -
Overkill for the mutations — five of the six connection points are one-shot mutations with no caching, no deduplication, no background refresh. For these, TanStack Query’s
useMutationprovides the same result asuseState+try/catchwrapped in a custom hook, but with more abstraction layers. -
Learning curve — engineers unfamiliar with TanStack Query need to understand query keys, cache invalidation, stale times, and the query/mutation distinction. The existing Redux pattern is already understood by the team.
-
CDN cookies are the only query — the single use case that genuinely benefits from TanStack Query (
refetchInterval) could be implemented with a focused custom hook (~30 lines) usingsetInterval+useEffect. Adding a full query library for one polling use case may not be proportional.
Alternative: Native React hooks with the same API surface
Section titled “Alternative: Native React hooks with the same API surface”A lightweight alternative is to implement the same hook API surface
(useImageUpload, useCheckReachability, useCdnCookies) using native
React primitives. The component wiring layer (section 3.9) would be
identical — components still receive callback props, and the wiring hooks
bridge them. The difference is internal to the hooks.
// Native React CDN cookie hook — no TanStack dependencyfunction useCdnCookies(enabled = true) { const [isReady, setIsReady] = useState(false);
useEffect(() => { if (!enabled) return; let cancelled = false;
const refresh = async () => { try { await refreshCdnCookies(); if (!cancelled) setIsReady(true); } catch { if (!cancelled) setIsReady(false); } };
void refresh(); // Initial fetch const id = setInterval(refresh, 15 * 60 * 1000); // 15-min refresh
return () => { cancelled = true; clearInterval(id); }; }, [enabled]);
return { isReady };}This is ~20 lines vs. ~10 lines with TanStack Query. The TanStack version adds automatic retry, background tab awareness, and stale tracking. The native version is simpler but requires manual additions for those features if needed later.
Assessment
Section titled “Assessment”| Criterion | TanStack Query | Native React |
|---|---|---|
| Bundle size impact | +44 KB gzip | None |
| Implementation effort | Lower (built-in state management) | Slightly higher (manual state in each hook) |
| CDN cookie lifecycle | Significantly cleaner | Workable but more manual code |
| Mutation hooks | Marginal benefit over custom hooks | Equivalent functionality |
| Coexistence with Redux | Adds second paradigm | No new paradigm — consistent with existing |
| Future migration path | Foundation for moving away from Redux thunks | No migration path established |
| Team familiarity | New pattern to learn | Uses patterns already in the codebase |
The decision depends on strategic intent:
-
If the goal is to keep this project scoped and minimize new patterns, native React hooks are sufficient. All six connection points can be implemented with custom hooks using
useState+fetch+useEffect. The component wiring layer is identical either way. -
If the goal is to establish TanStack Query as the go-forward pattern for server-state management in
arda-frontend-app(eventually replacing Redux thunks for data fetching), this project is a reasonable place to start. The CDN cookie lifecycle is a genuine use case, and the pattern pays off as more features adopt it.
Decision: TanStack Query was selected as a platform investment. The analysis in tanstack-adoption-exploration.md confirmed that while native React is sufficient for image upload alone, TanStack Query is justified for the broader product roadmap (complex nested data, AG Grid integration, background refresh lifecycle). Section 3 presents the TanStack Query integration design as the decided approach.
3. TanStack Query Integration for Image Upload Components
Section titled “3. TanStack Query Integration for Image Upload Components”Decision context: Section 2.8 analyzed TanStack Query vs. native React patterns. TanStack Query was selected as a platform investment for server-state management across the product roadmap (see tanstack-adoption-exploration.md). FD-01 establishes that design system components use typed data provider hooks — TanStack Query is used exclusively in the app’s wiring layer (see tanstack-component-binding-analysis.md).
Architecture note: Image components live in the
@arda-cards/design-systempackage (ux-prototyperepository) and are imported intoarda-frontend-appvia thecanaryentry point (@arda-cards/design-system/canary). Components are not copied intoarda-frontend-app. This means:
- Components must remain backend-agnostic — they expose callback props (
onUpload,onCheckReachability,onChange) and must not import TanStack Query,fetch, or anyarda-frontend-app-specific code.- All backend wiring happens in
arda-frontend-appvia hooks that bridge TanStack Query mutations/queries to the component callback props.- If the current callback interfaces are insufficient for production needs (e.g., real progress reporting, error/retry state exposure), the components must be updated in
ux-prototypeand a new package version published beforearda-frontend-appcan consume the changes. The scope of component changes is documented in the architectural review: 6 moderate and 13 minor changes, plus new lifecycle framework types (useDraft<T>,EditableComponentProps<T>,ValidationResult) per the abstract component lifecycle design.
Component update summary (branch
seb/inline-card-image-upload): The following changes have been made to the design system components since the original analysis. None affect the backend wiring interfaces:
ImageDropZone: redesigned layout withInputGroupfor URL entry, “Go” button, integrated HEIC conversion viamaybeConvertHeicutility with loading spinner, improved URL drag-and-drop (RFC 2483text/uri-listparsing). TheonDismissprop has been removed — the parent dialog handles cancel at its own level.ImageUploadDialog: the separateCopyrightAcknowledgmentcheckbox has been replaced with inline footer text (“By confirming, you acknowledge that you own or have a license to use this image.”). ThecopyrightAckedstate is gone — confirm is always enabled. Mobile-friendly padding and overflow handling added.- New utility:
maybeConvertHeic— extracted HEIC/HEIF-to-JPEG conversion.- New organism:
ItemCardEditor— WYSIWYG card editor with inlineImageDropZone, edit/replace overlay,ArdaConfirmDialogfor removal, andonImageConfirmedcallback. UsesImageUploadDialogfor edit mode.- New atom:
ArdaConfirmDialog— lightweight confirm dialog with focus trap.- Callback interfaces unchanged:
onUpload: (file: Blob) => Promise<string>andonCheckReachability: (url: string) => Promise<boolean>remain stable.
3.1 Code Structure (FD-02)
Section titled “3.1 Code Structure (FD-02)”New code in arda-frontend-app follows the Option C hybrid structure
(FD-02, see
code-organization-options.md).
Existing code is not reorganized — that is deferred to
#734.
New directories created by this project:
src/├── server/ # NEW — BFF-only (import 'server-only')│ ├── routes/ # Route handlers (image-upload, storage/*)│ ├── lib/ # Server utilities (cloudfront-signer, ssrf, rate-limiter)│ └── index.ts # import 'server-only'├── api/ # NEW — SPA API functions (plain fetch to BFF)│ └── image-upload.ts├── hooks/ # EXISTING dir, NEW feature subdirs│ ├── image-upload/ # NEW — TanStack mutations + FD-01 providers│ └── cdn/ # NEW — useCdnCookies query├── providers/ # NEW — QueryClientProvider, CdnCookieProvider├── lib/ # EXISTING — not moved (TODO #734)│ ├── env.ts # Server-only by convention (imported by src/server/)│ ├── jwt.ts # Shared (imported by src/server/ and client code)│ └── ardaClient.ts # SPA-only (existing item CRUD, not modified)└── components/items/ # EXISTING — modified in placeTemporary cross-layer imports: New BFF routes in src/server/ import
env and jwt from src/lib/ (legacy location). These are annotated with
// TODO #734: move to src/server/lib/ and cleaned up during the full
restructuring.
3.2 Setup: QueryClient and Provider
Section titled “3.2 Setup: QueryClient and Provider”Add @tanstack/react-query and @tanstack/react-query-devtools to
arda-frontend-app. Configure a QueryClient:
// src/providers/query-client.ts (FD-02: new src/providers/ directory)import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 5 * 60 * 1000, // 5 minutes retry: 1, refetchOnWindowFocus: false, // Explicit control, not ambient }, mutations: { retry: 0, // Mutations should not auto-retry }, },});Wrap the app in QueryClientProvider alongside the existing Redux Provider.
Both coexist — TanStack Query manages server state (backend data), Redux
continues to manage client state (UI state, auth tokens, form drafts).
3.3 API Functions Layer
Section titled “3.3 API Functions Layer”Note: This is a proposed new layer — it does not exist in the current
arda-frontend-appcodebase. Today, all client-side API calls are made throughsrc/lib/ardaClient.ts, a single file that mixes fetch functions with Redux store access (e.g., reading auth tokens viaselectAccessToken()). The TanStack Query convention recommends separating plain async functions from hooks and state management. This proposal introducessrc/api/as a new directory for that purpose, starting with image upload operations. This is a new architectural pattern for this codebase — not a formalization of an existing one.Current SPA/BFF/shared code separation in
arda-frontend-app:The codebase achieves good separation in practice but relies on convention rather than structural enforcement:
- BFF code lives in
src/app/api/(Next.js API routes). These routes import fromsrc/lib/jwt.ts(JWT verification),src/lib/env.ts(server secrets), andsrc/types/(shared interfaces). They never import fromsrc/store/,src/components/,src/hooks/, orsrc/contexts/.- SPA code lives in
src/components/,src/hooks/,src/contexts/, andsrc/store/. Client-side API calls go throughsrc/lib/ardaClient.tswhich calls/api/arda/...routes. SPA code never importssrc/lib/env.ts.- Shared code lives in
src/lib/(a flat bag of utilities) andsrc/types/(read-only interfaces).src/lib/jwt.tsis used by both sides but partitions cleanly by function: server usesprocessJWTForArda(NextRequest)for verification; client usesdecodeJWTPayload()for read-only decode.- No enforcement markers: zero
'use server'directives, zeroimport 'server-only'usage, no'use client'onardaClient.ts. Boundaries hold by convention and by the natural constraints of the APIs used (e.g.,process.envfails silently in browsers).- No structural separation: there is no
src/api/(SPA API layer), nosrc/server/(BFF utilities), andsrc/lib/mixes server-only, client-only, and shared modules in a single flat directory.The proposed
src/api/image-upload.tswould be the first file in a new SPA-specific API functions layer. A separate effort to restructure the codebase into explicit SPA/BFF/shared directories with enforcement markers is tracked in arda-frontend-app#734.
Plain async functions that call the BFF (not the Backend directly). These
run in the browser and are consumed by TanStack Query hooks. They use plain
fetch() to call /api/... routes — they do not use @arda-cards/api-proxy
(that package is used by the BFF server-side routes to call the Backend).
// src/api/image-upload.ts — runs in the BROWSER (SPA)// FD-02: new src/api/ directory (SPA API functions layer)
export interface ImageUploadUrlRequest { contentType: string; contentLength: number;}
export interface ImageUploadUrlResponse { uploadUrl: string; formFields: Record<string, string>; objectKey: string; cdnUrl: string;}
export interface ReachabilityResult { reachable: boolean; contentType: string; contentLength: number;}
/** Request presigned POST credentials from BFF. */export async function getImageUploadUrl( request: ImageUploadUrlRequest,): Promise<ImageUploadUrlResponse> { const response = await fetch('/api/image-upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request), }); if (!response.ok) throw new ApiError(response); return response.json();}
/** Upload image blob to S3 via presigned POST form. */export function uploadToS3( uploadUrl: string, formFields: Record<string, string>, imageBlob: Blob, onProgress?: (percent: number) => void,): Promise<void> { return new Promise((resolve, reject) => { const formData = new FormData(); for (const [key, value] of Object.entries(formFields)) { formData.append(key, value); } formData.append('file', imageBlob);
const xhr = new XMLHttpRequest(); xhr.open('POST', uploadUrl); if (onProgress) { xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100)); }); } xhr.addEventListener('load', () => xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(`S3 upload failed: ${xhr.status}`)), ); xhr.addEventListener('error', () => reject(new Error('Network error during upload'))); xhr.send(formData); });}
/** Check if an external URL is reachable (SPA-side HEAD, then BFF fallback). */export async function checkReachability(url: string): Promise<boolean> { try { const head = await fetch(url, { method: 'HEAD', mode: 'cors' }); return head.ok; } catch { // CORS failure — fall back to BFF const response = await fetch('/api/storage/check-url', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }), }); if (!response.ok) return false; const result: ReachabilityResult = await response.json(); return result.reachable; }}
/** Fetch external image via BFF proxy (for CORS-blocked URLs). */export async function fetchExternalImage(url: string): Promise<Blob> { const response = await fetch('/api/storage/fetch-url', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }), }); if (!response.ok) throw new ApiError(response); return response.blob();}
/** Request CDN signed cookies from BFF. */export async function refreshCdnCookies(): Promise<void> { const response = await fetch('/api/storage/cdn-cookies', { method: 'POST' }); if (!response.ok) throw new ApiError(response); // Cookies are set via Set-Cookie headers — no body to parse}3.4 Query Key Factory
Section titled “3.4 Query Key Factory”Co-locate query keys in a single factory for consistent invalidation:
export const imageKeys = { all: ['image'] as const, cdnCookies: ['image', 'cdn-cookies'] as const, upload: () => ['image', 'upload'] as const, reachability: (url: string) => ['image', 'reachability', url] as const,};3.5 Hook: useImageUpload
Section titled “3.5 Hook: useImageUpload”The core mutation hook that orchestrates the three-step upload flow (credentials
→ S3 upload → return CDN URL). This replaces the onUpload callback prop on
ImageUploadDialog.
import { useMutation, useQueryClient } from '@tanstack/react-query';import { getImageUploadUrl, uploadToS3 } from '@/api/image-upload';import { imageKeys } from '@/hooks/query-keys';
interface UseImageUploadVars { itemEId: string; imageBlob: Blob; onProgress?: (percent: number) => void;}
export function useImageUpload() { const queryClient = useQueryClient();
return useMutation({ mutationFn: async ({ itemEId, imageBlob, onProgress }: UseImageUploadVars) => { // Step 1: Get presigned POST credentials from BFF → Backend // Note: backend endpoint is module-scoped (/v1/item/image-upload), // not entity-scoped. itemEId is used for cache invalidation only. const credentials = await getImageUploadUrl({ contentType: 'image/jpeg', // Always JPEG output (SPA-FR-021) contentLength: imageBlob.size, });
// Step 2: Upload directly to S3 via presigned POST await uploadToS3( credentials.uploadUrl, credentials.formFields, imageBlob, onProgress, );
// Step 3: Return CDN URL for the caller to persist on the entity return credentials.cdnUrl; }, onSuccess: (_cdnUrl, vars) => { // Invalidate the item query so grid/detail views pick up the new image queryClient.invalidateQueries({ queryKey: ['item', vars.itemEId] }); queryClient.invalidateQueries({ queryKey: ['items'] }); }, });}3.6 Hook: useCheckReachability
Section titled “3.6 Hook: useCheckReachability”A mutation (not a query — it is user-triggered and not cacheable) that replaces
the onCheckReachability callback prop.
import { useMutation } from '@tanstack/react-query';import { checkReachability } from '@/api/image-upload';
export function useCheckReachability() { return useMutation({ mutationFn: (url: string) => checkReachability(url), });}3.7 Hook: useFetchExternalImage
Section titled “3.7 Hook: useFetchExternalImage”For fetching CORS-blocked images through the BFF proxy:
import { useMutation } from '@tanstack/react-query';import { fetchExternalImage } from '@/api/image-upload';
export function useFetchExternalImage() { return useMutation({ mutationFn: (url: string) => fetchExternalImage(url), });}3.8 Hook: useCdnCookies
Section titled “3.8 Hook: useCdnCookies”A query with automatic background refresh for the CDN cookie lifecycle. This is the one true query in the image feature — it manages a long-lived background concern rather than a user-initiated action.
import { useQuery, useQueryClient } from '@tanstack/react-query';import { refreshCdnCookies } from '@/api/image-upload';import { imageKeys } from '@/hooks/query-keys';
const COOKIE_TTL_MS = 30 * 60 * 1000; // 30 minutes (server-configured)const REFRESH_INTERVAL_MS = COOKIE_TTL_MS / 2; // 15 minutes (~50% of TTL)
export function useCdnCookies(enabled: boolean = true) { return useQuery({ queryKey: imageKeys.cdnCookies, queryFn: refreshCdnCookies, enabled, refetchInterval: REFRESH_INTERVAL_MS, refetchIntervalInBackground: true, staleTime: REFRESH_INTERVAL_MS, retry: 2, retryDelay: 5000, });}
/** Force immediate cookie refresh (tenant switch, 403 recovery). */export function useRefreshCdnCookies() { const queryClient = useQueryClient(); return () => queryClient.invalidateQueries({ queryKey: imageKeys.cdnCookies });}3.9 BFF Route Handlers (server-side, uses api-proxy)
Section titled “3.9 BFF Route Handlers (server-side, uses api-proxy)”The BFF API route handlers run on the server (Next.js API routes). The
image-upload route uses @arda-cards/api-proxy to make type-safe calls to the
Backend. The storage routes (check-url, fetch-url, cdn-cookies) do not
use api-proxy — they perform server-side work directly (external URL
fetching, cookie signing).
// src/server/routes/image-upload.ts — runs on the SERVER (BFF)// FD-02: new src/server/ directory. Re-exported from src/app/api/image-upload/route.ts// Uses @arda-cards/api-proxy to call the Backend
import { NextRequest, NextResponse } from 'next/server';import { ItemProxy } from '@arda-cards/api-proxy/reference/item';import type { ImageUploadRequest } from '@arda-cards/api-proxy/reference/item';import { processJWTForArda } from '@/lib/jwt';import { env } from '@/lib/env';
export async function POST(request: NextRequest) { const jwtResult = await processJWTForArda(request); if (!jwtResult.success) { return NextResponse.json( { ok: false, error: jwtResult.error }, { status: jwtResult.statusCode }, ); }
const body: ImageUploadRequest = await request.json();
// api-proxy provides type-safe Backend calls with the server-side API key const proxy = new ItemProxy({ host: env.BASE_URL, apiKey: env.ARDA_API_KEY, });
const result = await proxy.createImageUploadUrl(body); return NextResponse.json({ ok: true, data: result });}The entity update route similarly uses ItemProxy.update() — this is an
existing BFF route that already calls the Backend via api-proxy (or will
after migration from raw fetch()).
The storage routes (check-url, fetch-url) use server-side fetch() to
reach external URLs with SSRF protection. The cdn-cookies route uses
cloudfront-signer.ts to sign cookies locally. None of these touch the
Backend or use api-proxy.
3.10 Component Wiring
Section titled “3.10 Component Wiring”The design system components (imported from @arda-cards/design-system/canary)
use callback props (onUpload, onCheckReachability, onChange) for backend
interaction. In production, TanStack Query hooks in arda-frontend-app provide
the implementations for these callbacks. The components themselves do not import
TanStack Query — they remain pure UI components in the design system package. A
thin wiring layer in arda-frontend-app connects hooks to component props.
Open Design Question: Component Interface Sufficiency
Section titled “Open Design Question: Component Interface Sufficiency”The current ImageUploadDialog manages upload progress via an internal
simulated timer (50ms interval over 1.5s). In production, progress comes from
the real XHR upload.onprogress event, which is managed by the TanStack Query
mutation in arda-frontend-app.
Two approaches exist:
-
Callback-only (current interface): The
onUploadcallback returns aPromise<string>. The dialog’s internal timer provides visual progress while the real upload happens. The timer duration would need to be adjusted or made indeterminate. The dialog has no visibility into real progress. -
Extended interface: The
onUploadcallback accepts anonProgresscallback parameter:onUpload: (file: Blob, onProgress: (pct: number) => void) => Promise<string>. This lets the production wiring push real progress values into the dialog’s progress bar. Requires a component update inux-prototype.
Similarly, the UploadError state (retry after failure) and
single-upload-at-a-time enforcement may require interface extensions.
Note: the recent component update removed the CopyrightAcknowledgment
checkbox gate — confirm is always enabled with inline text. This simplifies
the upload trigger path (no conditional gating state to manage) but does not
change the progress/error question above.
These decisions should be resolved during project planning and recorded in the decision log.
ImageUploadDialog Wiring
Section titled “ImageUploadDialog Wiring”The ImageUploadDialog accepts onUpload: (file: Blob) => Promise<string>.
The production wiring provides this via useImageUpload:
import { useCallback, useState } from 'react';import { useImageUpload } from '@/hooks/mutations/use-image-upload';import { useCheckReachability } from '@/hooks/mutations/use-check-reachability';
export function useItemImageUploadDialog(itemEId: string) { const uploadMutation = useImageUpload(); const reachabilityMutation = useCheckReachability(); const [uploadProgress, setUploadProgress] = useState(0);
const handleUpload = useCallback( async (blob: Blob): Promise<string> => { const cdnUrl = await uploadMutation.mutateAsync({ itemEId, imageBlob: blob, onProgress: setUploadProgress, }); return cdnUrl; }, [itemEId, uploadMutation], );
const handleCheckReachability = useCallback( async (url: string): Promise<boolean> => { return reachabilityMutation.mutateAsync(url); }, [reachabilityMutation], );
return { handleUpload, handleCheckReachability, uploadProgress, isUploading: uploadMutation.isPending, uploadError: uploadMutation.error, resetUpload: uploadMutation.reset, };}Usage in the form — note components imported from the design system, hooks from
arda-frontend-app:
import { ImageFormField, ImageUploadDialog } from '@arda-cards/design-system/canary';import { ITEM_IMAGE_CONFIG } from '@/config/item-image-config';import { useItemImageUploadDialog } from '@/hooks/use-item-image-upload-dialog';
function ItemImageSection({ itemEId, imageUrl, onImageChange }: Props) { const [dialogOpen, setDialogOpen] = useState(false); const { handleUpload, handleCheckReachability, } = useItemImageUploadDialog(itemEId);
return ( <ImageFormField config={ITEM_IMAGE_CONFIG} imageUrl={imageUrl} onChange={onImageChange} > <ImageUploadDialog config={ITEM_IMAGE_CONFIG} existingImageUrl={imageUrl} open={dialogOpen} onCancel={() => setDialogOpen(false)} onConfirm={(result) => { onImageChange(result.imageUrl); setDialogOpen(false); }} onUpload={handleUpload} onCheckReachability={handleCheckReachability} /> </ImageFormField> );}CDN Cookie Lifecycle Wiring
Section titled “CDN Cookie Lifecycle Wiring”The CDN cookie manager runs at the app level, not per-component. It uses the query hook for background refresh and exposes an imperative refresh function for 403 recovery and tenant switch:
import { createContext, useContext } from 'react';import { useCdnCookies, useRefreshCdnCookies } from '@/hooks/queries/use-cdn-cookies';
interface CdnCookieContext { isReady: boolean; refreshNow: () => void;}
const CdnCookieCtx = createContext<CdnCookieContext>({ isReady: false, refreshNow: () => {},});
export function CdnCookieProvider({ children }: { children: React.ReactNode }) { const { isSuccess } = useCdnCookies(); const refreshNow = useRefreshCdnCookies();
return ( <CdnCookieCtx.Provider value={{ isReady: isSuccess, refreshNow }}> {children} </CdnCookieCtx.Provider> );}
export const useCdnCookieContext = () => useContext(CdnCookieCtx);ImageDisplay uses this context for 403 recovery:
// Inside ImageDisplay's onError handlerconst { refreshNow } = useCdnCookieContext();
function handleImageError() { if (!hasRetried) { refreshNow(); // Refresh cookies setHasRetried(true); // Mark to retry once setImgKey(k => k + 1); // Force <img> re-render } else { setLoadState('error'); // Show error placeholder }}Grid Cell Wiring
Section titled “Grid Cell Wiring”Grid components (ImageCellDisplay, ImageCellEditor) are thin AG Grid
wrappers. They do not need TanStack Query directly:
ImageCellDisplayrendersImageDisplay(CDN cookies handled globally).ImageCellEditorrendersImageUploadDialog— the wiring hook providesonUploadandonCheckReachabilitythe same way as the form field.
3.11 Coexistence with Redux
Section titled “3.11 Coexistence with Redux”TanStack Query and Redux serve different purposes and coexist cleanly:
| Concern | Owner | Rationale |
|---|---|---|
| Auth tokens | Redux | Cross-cutting session state, already implemented |
| UI state (tabs, panels) | Redux | Client-only, no server counterpart |
| Form draft persistence | Redux + persist | Client-only, survives page refresh |
| Item data from API | TanStack Query | Server state with caching, staleness, refetch |
| Upload mutations | TanStack Query | Server-side effect with progress and error tracking |
| CDN cookies | TanStack Query | Background refresh lifecycle |
The existing ardaClient.ts functions continue to work for non-image API calls.
Image-specific API functions are placed in src/api/image-upload.ts and
consumed through TanStack Query hooks. Over time, other API calls can be
migrated to TanStack Query, but that is outside the scope of this project.
The design system components (@arda-cards/design-system/canary) are agnostic
to the state management choice — they accept callback props and render
accordingly. The TanStack Query vs. Redux boundary is entirely within
arda-frontend-app.
3.12 Wiring Summary
Section titled “3.12 Wiring Summary”SPA Layer (browser — TanStack Query hooks, no api-proxy)
Section titled “SPA Layer (browser — TanStack Query hooks, no api-proxy)”| Component | Connection Point | TanStack Hook | Wiring Strategy |
|---|---|---|---|
ImageUploadDialog | onUpload prop | useImageUpload | Custom hook bridges mutateAsync → callback prop |
ImageUploadDialog | onCheckReachability prop | useCheckReachability | Custom hook bridges mutateAsync → callback prop |
ImageDropZone | URL fetch (CORS fallback) | useFetchExternalImage | Called inside reachability flow when direct fails |
ImageDisplay | 403 recovery | useCdnCookies (context) | Context provides refreshNow; component retries |
ImageFormField | onChange prop | Entity mutation (existing) | Passes CDN URL to form state; form saves entity |
ImageCellDisplay | CDN image rendering | useCdnCookies (global) | No direct hook; cookies are ambient via browser |
ImageCellEditor | onUpload/onCheckReachability | useImageUpload | Same wiring as ImageUploadDialog |
ItemCardEditor | onImageConfirmed + internal dialog | useImageUpload | Wraps ImageDropZone (inline) + ImageUploadDialog (edit/replace) |
| CDN Cookie Manager | Background refresh | useCdnCookies | Provider at app root; refetchInterval for refresh |
| CDN Cookie Manager | Tenant switch | useRefreshCdnCookies | Imperative invalidation on tenant change |
BFF Layer (server — api-proxy where applicable)
Section titled “BFF Layer (server — api-proxy where applicable)”| BFF Route | Backend Call | Uses api-proxy | Notes |
|---|---|---|---|
POST /api/image-upload | POST /v1/item/image-upload | Yes — ItemProxy.createImageUploadUrl() | Type-safe presigned credential request |
PUT /api/items/<itemEId> | PUT /v1/item/item/<itemEId> | Yes — ItemProxy.update() | Existing entity update route |
POST /api/storage/check-url | External URL HEAD/GET | No | Server-side fetch with SSRF protection |
POST /api/storage/fetch-url | External URL GET | No | Server-side fetch, streams response |
POST /api/storage/cdn-cookies | None (local signing) | No | CloudFront cookie signing with private key |
Direct SPA → S3 (no BFF, no api-proxy)
Section titled “Direct SPA → S3 (no BFF, no api-proxy)”| Operation | Target | Notes |
|---|---|---|
| Presigned POST upload | S3 bucket URL | SPA submits multipart form directly using credentials from BFF. Neither BFF nor api-proxy is involved. |
| CDN image rendering | CloudFront URL | Browser <img> requests. Signed cookies sent automatically. |
Copyright: (c) Arda Systems 2025-2026, All rights reserved
Copyright: © Arda Systems 2025-2026, All rights reserved