Skip to content

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.

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)”
AspectDetail
TriggerUser confirms image in ImageUploadDialog (Confirm click)
FlowSPA → BFF POST /api/image-upload → Backend POST /v1/item/image-upload
Request{ contentType: "image/jpeg", contentLength: number }
Response{ uploadUrl, formFields, objectKey, cdnUrl }
ConsumerImageUploadDialog via onUpload callback prop
NatureOne-shot mutation — each upload requires fresh credentials
Errors401 (session expired), 502 (Backend unavailable)
api-proxyBFF uses ItemProxy.createImageUploadUrl() to call Backend
AspectDetail
TriggerImmediately after receiving presigned credentials
FlowSPA → S3 presigned POST URL (direct, no BFF)
RequestMultipart form with formFields + image Blob
ResponseHTTP 204 (no body)
ConsumerProduction ImageUploadHandler (internal to upload orchestration)
NatureOne-shot mutation with progress tracking (XHR upload.onprogress)
ErrorsNetwork failure, S3 policy rejection (wrong content type or size)
api-proxyNot 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)”
AspectDetail
TriggerAfter successful S3 upload
FlowSPA → BFF PUT /api/items/<itemEId> → Backend
RequestFull entity payload with imageUrl set to cdnUrl from credentials response
ResponseUpdated entity record
ConsumerImageUploadDialog.onConfirm → form state → entity save
NatureStandard entity update mutation (already exists for other fields)
Errors400 (URL validation failure), 401, 502
api-proxyBFF 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)”
AspectDetail
TriggerUser enters/pastes an external URL in ImageDropZone
FlowSPA direct HEAD → on CORS failure → BFF POST /api/storage/check-url
Request{ url: "https://..." }
Response{ reachable, contentType, contentLength }
ConsumerImageUploadDialog via onCheckReachability callback prop
NatureOn-demand, user-initiated — not cacheable (external URL state can change)
Errors400 (invalid URL, SSRF), 422 (unreachable), 429 (rate limited)
api-proxyNot 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)”
AspectDetail
TriggerAfter reachability check passes, if direct fetch fails due to CORS
FlowSPA direct GET → on CORS failure → BFF POST /api/storage/fetch-url
Request{ url: "https://..." }
ResponseImage bytes with Content-Type header
ConsumerImageDropZone / ImageUploadDialog URL processing pipeline
NatureOne-shot fetch — result loaded into editor canvas, not cached
Errors400 (SSRF), 413 (too large), 422 (unreachable), 429 (rate limited)
api-proxyNot used — BFF performs server-side fetch to external URLs directly
Section titled “1.6 CDN Cookie Lifecycle (Query + Background Refresh)”
AspectDetail
TriggerSession start, proactive timer (~15 min), tenant switch, 403 recovery
FlowSPA → BFF POST /api/storage/cdn-cookies → Set-Cookie headers
RequestNone (tenant from session)
Response204 No Content; cookies in Set-Cookie headers
ConsumerCDN cookie lifecycle manager (global, not per-component)
NatureBackground refresh on interval — closest to a query with refetchInterval
Errors401 (session expired), 500 (signing key unavailable)
api-proxyNot used — BFF signs cookies locally using CloudFront private key

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.

PlantUML diagram


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

Queries (useQuery) — declarative reads with automatic caching, background refetch, stale-while-revalidate, and deduplication.

// Standard pattern: a query hook wrapping a fetch function
function 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 component
function 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/DELETE
function 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 component
function 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>;
}

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 scoping
queryClient.invalidateQueries({ queryKey: ['items'] }); // All item lists
queryClient.invalidateQueries({ queryKey: ['item', 'abc-123'] }); // One item + children

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 wrapper

Key conventions:

  1. API functions are plain async functions — no React hooks, importable from anywhere (components, mutations, tests).
  2. Hooks are thin wrappers — configure cache keys, stale times, and invalidation; delegate fetching to API functions.
  3. Query keys are co-located with hooks — export key factories from the same module for use in invalidation.
  4. 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 entity
function 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] });
},
});
}

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 StateComponent StateVisual
isLoadingLoadingSkeleton / shimmer
isErrorErrorPlaceholder + error badge
isSuccessLoadedImage rendered
mutation.isPendingUploadingProgress indicator
mutation.isErrorUploadErrorError 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.

CategoryExamplesPattern
dependenciesradix-ui, browser-image-compression, react-easy-crop, heic2anyBundled into the package — consumers get them transitively
peerDependenciesreact, react-dom, ag-grid-community, lucide-reactConsumer must install; design system uses but does not bundle
devDependenciesreact-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.

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 prod

ux-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-prototypepeerDependency (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.

Packageux-prototypearda-frontend-appRationale
@tanstack/react-querynone (recommended) or peerDependency (if components become TanStack-aware)dependencyHooks and QueryClient live in the app
@tanstack/react-query-devtoolsnonedependencyDev panel for debugging queries/mutations
@tanstack/react-tablenonedependency (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 (the ImageUploadDialog already 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 useXxx hooks (e.g., useImageUpload returning { 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 dependency
function 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,
};
}
Connection PointWhat TanStack addsNative React equivalentNet benefit
1.1 Presigned credentialsuseMutation with isPending/isError/resetuseState + try/catch in custom hookLow — one-shot mutation, no caching needed
1.2 S3 uploadNothing — this is a raw XHR, not a fetchSame — XHR with onprogress either wayNone
1.3 Entity persistCache invalidation after mutationManual refetch or Redux dispatchLow — existing pattern uses Redux thunks
1.4 Reachability checkuseMutationuseState + try/catchLow — one-shot, no caching
1.5 External image fetchuseMutationuseState + try/catchLow — one-shot, no caching
1.6 CDN cookiesrefetchInterval, refetchIntervalInBackground, auto-retry, stale trackingsetInterval + useState + manual retry logicMedium — background lifecycle management is the strongest case
  1. CDN cookie lifecyclerefetchInterval with refetchIntervalInBackground is significantly cleaner than a hand-rolled setInterval + cleanup + error-retry + visibility-change handler. This is the strongest argument.

  2. Declarative stateisPending, isError, isSuccess, error, reset are provided out of the box. Native React requires managing these manually in each hook — not hard, but repetitive.

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

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

  5. Community standard — TanStack Query is the de facto standard for server-state management in React. Engineers familiar with it will recognize the patterns immediately.

  6. Retry and error recovery — configurable retry logic, exponential backoff, and error boundaries are built in. Native React requires implementing these manually.

  1. New dependency — adds @tanstack/react-query (~44 KB gzip) and @tanstack/react-query-devtools to the bundle. The app currently has zero TanStack Query usage.

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

  3. Provider setup — requires QueryClientProvider in the component tree alongside the existing ReduxProvider, AuthProvider, JWTProvider, SidebarVisibilityProvider, and OrderQueueProvider. The provider stack is already 5 levels deep.

  4. 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 useMutation provides the same result as useState + try/catch wrapped in a custom hook, but with more abstraction layers.

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

  6. 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) using setInterval + 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 dependency
function 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.

CriterionTanStack QueryNative React
Bundle size impact+44 KB gzipNone
Implementation effortLower (built-in state management)Slightly higher (manual state in each hook)
CDN cookie lifecycleSignificantly cleanerWorkable but more manual code
Mutation hooksMarginal benefit over custom hooksEquivalent functionality
Coexistence with ReduxAdds second paradigmNo new paradigm — consistent with existing
Future migration pathFoundation for moving away from Redux thunksNo migration path established
Team familiarityNew pattern to learnUses 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-system package (ux-prototype repository) and are imported into arda-frontend-app via the canary entry point (@arda-cards/design-system/canary). Components are not copied into arda-frontend-app. This means:

  • Components must remain backend-agnostic — they expose callback props (onUpload, onCheckReachability, onChange) and must not import TanStack Query, fetch, or any arda-frontend-app-specific code.
  • All backend wiring happens in arda-frontend-app via 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-prototype and a new package version published before arda-frontend-app can 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 with InputGroup for URL entry, “Go” button, integrated HEIC conversion via maybeConvertHeic utility with loading spinner, improved URL drag-and-drop (RFC 2483 text/uri-list parsing). The onDismiss prop has been removed — the parent dialog handles cancel at its own level.
  • ImageUploadDialog: the separate CopyrightAcknowledgment checkbox has been replaced with inline footer text (“By confirming, you acknowledge that you own or have a license to use this image.”). The copyrightAcked state 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 inline ImageDropZone, edit/replace overlay, ArdaConfirmDialog for removal, and onImageConfirmed callback. Uses ImageUploadDialog for edit mode.
  • New atom: ArdaConfirmDialog — lightweight confirm dialog with focus trap.
  • Callback interfaces unchanged: onUpload: (file: Blob) => Promise<string> and onCheckReachability: (url: string) => Promise<boolean> remain stable.

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 place

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

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

Note: This is a proposed new layer — it does not exist in the current arda-frontend-app codebase. Today, all client-side API calls are made through src/lib/ardaClient.ts, a single file that mixes fetch functions with Redux store access (e.g., reading auth tokens via selectAccessToken()). The TanStack Query convention recommends separating plain async functions from hooks and state management. This proposal introduces src/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 from src/lib/jwt.ts (JWT verification), src/lib/env.ts (server secrets), and src/types/ (shared interfaces). They never import from src/store/, src/components/, src/hooks/, or src/contexts/.
  • SPA code lives in src/components/, src/hooks/, src/contexts/, and src/store/. Client-side API calls go through src/lib/ardaClient.ts which calls /api/arda/... routes. SPA code never imports src/lib/env.ts.
  • Shared code lives in src/lib/ (a flat bag of utilities) and src/types/ (read-only interfaces). src/lib/jwt.ts is used by both sides but partitions cleanly by function: server uses processJWTForArda(NextRequest) for verification; client uses decodeJWTPayload() for read-only decode.
  • No enforcement markers: zero 'use server' directives, zero import 'server-only' usage, no 'use client' on ardaClient.ts. Boundaries hold by convention and by the natural constraints of the APIs used (e.g., process.env fails silently in browsers).
  • No structural separation: there is no src/api/ (SPA API layer), no src/server/ (BFF utilities), and src/lib/ mixes server-only, client-only, and shared modules in a single flat directory.

The proposed src/api/image-upload.ts would 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
}

Co-locate query keys in a single factory for consistent invalidation:

src/hooks/query-keys.ts
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,
};

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.

src/hooks/mutations/use-image-upload.ts
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'] });
},
});
}

A mutation (not a query — it is user-triggered and not cacheable) that replaces the onCheckReachability callback prop.

src/hooks/mutations/use-check-reachability.ts
import { useMutation } from '@tanstack/react-query';
import { checkReachability } from '@/api/image-upload';
export function useCheckReachability() {
return useMutation({
mutationFn: (url: string) => checkReachability(url),
});
}

For fetching CORS-blocked images through the BFF proxy:

src/hooks/mutations/use-fetch-external-image.ts
import { useMutation } from '@tanstack/react-query';
import { fetchExternalImage } from '@/api/image-upload';
export function useFetchExternalImage() {
return useMutation({
mutationFn: (url: string) => fetchExternalImage(url),
});
}

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.

src/hooks/queries/use-cdn-cookies.ts
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.

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:

  1. Callback-only (current interface): The onUpload callback returns a Promise<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.

  2. Extended interface: The onUpload callback accepts an onProgress callback 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 in ux-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.

The ImageUploadDialog accepts onUpload: (file: Blob) => Promise<string>. The production wiring provides this via useImageUpload:

src/components/items/image/use-item-image-upload-dialog.ts
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>
);
}

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:

src/providers/cdn-cookie-provider.tsx
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 handler
const { 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 components (ImageCellDisplay, ImageCellEditor) are thin AG Grid wrappers. They do not need TanStack Query directly:

  • ImageCellDisplay renders ImageDisplay (CDN cookies handled globally).
  • ImageCellEditor renders ImageUploadDialog — the wiring hook provides onUpload and onCheckReachability the same way as the form field.

TanStack Query and Redux serve different purposes and coexist cleanly:

ConcernOwnerRationale
Auth tokensReduxCross-cutting session state, already implemented
UI state (tabs, panels)ReduxClient-only, no server counterpart
Form draft persistenceRedux + persistClient-only, survives page refresh
Item data from APITanStack QueryServer state with caching, staleness, refetch
Upload mutationsTanStack QueryServer-side effect with progress and error tracking
CDN cookiesTanStack QueryBackground 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.

SPA Layer (browser — TanStack Query hooks, no api-proxy)

Section titled “SPA Layer (browser — TanStack Query hooks, no api-proxy)”
ComponentConnection PointTanStack HookWiring Strategy
ImageUploadDialogonUpload propuseImageUploadCustom hook bridges mutateAsync → callback prop
ImageUploadDialogonCheckReachability propuseCheckReachabilityCustom hook bridges mutateAsync → callback prop
ImageDropZoneURL fetch (CORS fallback)useFetchExternalImageCalled inside reachability flow when direct fails
ImageDisplay403 recoveryuseCdnCookies (context)Context provides refreshNow; component retries
ImageFormFieldonChange propEntity mutation (existing)Passes CDN URL to form state; form saves entity
ImageCellDisplayCDN image renderinguseCdnCookies (global)No direct hook; cookies are ambient via browser
ImageCellEditoronUpload/onCheckReachabilityuseImageUploadSame wiring as ImageUploadDialog
ItemCardEditoronImageConfirmed + internal dialoguseImageUploadWraps ImageDropZone (inline) + ImageUploadDialog (edit/replace)
CDN Cookie ManagerBackground refreshuseCdnCookiesProvider at app root; refetchInterval for refresh
CDN Cookie ManagerTenant switchuseRefreshCdnCookiesImperative invalidation on tenant change

BFF Layer (server — api-proxy where applicable)

Section titled “BFF Layer (server — api-proxy where applicable)”
BFF RouteBackend CallUses api-proxyNotes
POST /api/image-uploadPOST /v1/item/image-uploadYesItemProxy.createImageUploadUrl()Type-safe presigned credential request
PUT /api/items/<itemEId>PUT /v1/item/item/<itemEId>YesItemProxy.update()Existing entity update route
POST /api/storage/check-urlExternal URL HEAD/GETNoServer-side fetch with SSRF protection
POST /api/storage/fetch-urlExternal URL GETNoServer-side fetch, streams response
POST /api/storage/cdn-cookiesNone (local signing)NoCloudFront cookie signing with private key
OperationTargetNotes
Presigned POST uploadS3 bucket URLSPA submits multipart form directly using credentials from BFF. Neither BFF nor api-proxy is involved.
CDN image renderingCloudFront URLBrowser <img> requests. Signed cookies sent automatically.

Copyright: (c) Arda Systems 2025-2026, All rights reserved