Building an art gallery with React and Supabase
I help organize HOME/HOMELAND, an international student art initiative, and built its submission, review, and exhibition website.
I built it with React, TypeScript, and Supabase, using agents during implementation and review. Artists submit work privately. The team reviews it and decides what to publish. Visitors browse the exhibition.
The gallery has to keep working after the first load. People move between rooms while requests are still running. Media links expire. A failed request needs another chance without repeatedly retrying forever. The decisions below cover who can see the data and who owns it while those things happen.
Publication is a separate decision
Private submission
Public exhibition
get_published_artworks selects what visitors may see.Database and storage permissions enforce this boundary. Hiding a screen in React cannot enforce it.
Accepting a submission does not publish it. Artwork received by email also needs a record before the website can display it. When a piece is missing from the gallery, I need to know how it entered the project and whether it was published.
The public gallery calls get_published_artworks, a database function that selects published work and returns the fields needed for the exhibition. The private submission includes information visitors should not receive. Allowing public access to the whole row would expose that information even if React never displayed it.
Direct reads of submissions are limited to their owner and administrators. Storage policies separately check whether a file belongs to published work. Both checks belong on the backend, where requests made outside the interface must pass them too.
The function uses SECURITY DEFINER. Its privileges, selected fields, and configured search path need review because it executes with its owner’s privileges. Supabase’s function documentation and row-level security guide explain the checks needed here.
The submission form also saves a local draft with an expiry timestamp. It rejects invalid or expired drafts. Browser storage can fail, and saving a filename cannot restore the uploaded file. File type and size checks give early feedback; the backend must enforce its own upload rules.
Keep the collection alive while people change rooms
Moving from the lobby into a room changes which artworks are visible. It should not mean fetching the whole exhibition again. Leaving the gallery and returning later is different: then I do want a fresh load.
The data-loading effect belongs to the gallery’s lifetime, while the room is a filter over the loaded collection. A visitor can enter a room before the initial request finishes without creating another request. This also avoids replacing loaded images with placeholders on every room change.
Keeping the collection in the gallery’s state gave it the lifetime I needed without an application-wide cache.
Renew media links without losing completed updates
A signed media URL is temporary access to a stable file path. The gallery keeps that path so it can renew the URL later. In this implementation, links last an hour and renewal is scheduled after 55 minutes.
Each distinct path has a small registry entry:
| State | What it controls |
|---|---|
pending |
Prevents a second signing request while one is in flight. |
failures |
Selects a bounded retry delay. |
nextAttemptAt |
Schedules initial signing, a retry, or renewal. |
Keying the registry by file path means two artworks referencing the same file can share a signing request. Signing different paths independently means one slow request need not hold up every other image.
That independence introduces another problem: callbacks can complete on different ticks. Each successful callback needs to update the current collection, not replace it with the snapshot that existed when its request began. The relevant production update uses React’s functional state setter:
setArt((current) =>
current.map((piece) => {
if (piece.filePath !== path) return piece;
return piece.mediaType === 'image'
? { ...piece, image: url }
: { ...piece, mediaUrl: url };
})
);
Here path and url come from the completed signing request; setArt is the gallery’s state setter. This excerpt belongs inside that callback. Updating from current preserves another request’s earlier success. Keeping the rest of each artwork intact also preserves its source path for the next renewal.
Replacing the collection with A’s old snapshot could erase B’s success.
The functional state update in the code above reads current state when React applies it. Callbacks from a cleaned-up gallery instance are ignored.
Recovery needs a limit and a way back in
A signing failure gets retries after one and three seconds, then automatic retries stop. The implementation marks the next attempt as infinity after exhausting the delays. That prevents an unavailable service from creating an endless timer loop.
Stopping forever would be a different bug. Returning to a room, bringing the page back into view, focusing the window, or coming back online can reopen a failed entry for another attempt. An entry already marked pending is left alone, so those signals do not multiply an in-flight request.
Renewal keeps the previous image while the new URL is being obtained. Clearing it first would make an ordinary background operation visible as a flicker or an empty viewer. Keeping it cannot guarantee that an already expired URL will keep working; it avoids discarding usable state before a replacement exists.
Cleanup clears timers and listeners and marks that effect instance cancelled. Already-started network requests can still finish, but their callbacks are ignored. That is different from aborting the underlying request.
The registry is created inside the effect. React’s development Strict Mode can run setup, cleanup, and setup again. Reusing a registry whose old requests were cancelled logically could leave entries permanently marked pending. A fresh registry lets the new lifetime start its own work. React documents this additional development check.
Control the order in the test
The navigation suite controls when signing promises resolve and advances a fake clock for renewal and retries. It checks that requests finishing on different ticks keep their images, that Strict Mode does not strand signing, and that a late callback from an old gallery instance does not update the new one.
One retry test advances time until the three allowed attempts have happened, advances it again to prove the count stays at three, then changes rooms and confirms recovery. Another keeps the last image during a failed refresh and checks that returning to the page does not duplicate a pending request.
The test setup deliberately leaves an unexpected second exhibition request unresolved. A mock that immediately returned the same collection would hide an accidental reload. I want the test to expose that second request.
The agent-workflow article includes a standalone Bun example of the out-of-order update. You can run the working version and then introduce the stale snapshot to see the assertion fail.
Check the experience as well as the state
The artwork viewer has a named dialog, an Escape-to-close interaction, and a keyboard-accessible route to comments.
Component tests can check accessible names and dispatched keyboard events. A browser check is still needed for real focus movement, scrolling, and the rendered layout. The ARIA dialog pattern is a useful reference when deciding what a dialog should do.
The same principle applies to language. The gallery uses language-prefixed routes, so a shared URL preserves the language someone was reading. A stored preference should not quietly override the URL someone explicitly opened.
During this project, I had to check whether the reviewed German translations matched the files being shipped. That required comparing the supplied document, the repository, and the deployed page.
Artist-written titles and statements need particular care. The browser suite checks that changing the interface language does not machine-translate the artist’s own text. It also checks Arabic presentation and mobile overflow. The artist’s text stays as submitted when the surrounding interface changes language.
Make repeated verification affordable
The gallery’s GitHub Actions workflow runs linting, type checks, a production build, unit tests, and Chromium browser tests. Bun is pinned through the package configuration and used in Vercel’s commands too, so a local runtime update needs to reach the build service.
A push to the same branch cancels an older CI run. That keeps an obsolete commit from consuming the verification queue while its replacement waits. Separate branches can still run independently. GitHub’s concurrency controls define that grouping and cancellation behaviour. The browser download is cached using the runner OS and the exact Playwright version; a cache hit still installs the required system dependencies. A warm cache should save a download without substituting the wrong browser binary. This is a project choice, not a universal recommendation: Playwright’s CI guidance notes that restoring its browser cache can take as long as downloading it.
The browser report and failure artifacts are uploaded even when tests fail, unless the run is cancelled. That makes a failed check something I can investigate, rather than a red status with no useful evidence.
Vercel deploys from the repository separately from this CI workflow. A passing local suite does not establish that a deployment succeeded, and a CI workflow running on a push does not by itself block Vercel from deploying that push. Required checks before merge and inspecting the resulting deployment are separate parts of the release process.
The browser suite uses a disabled live backend. It can test navigation, focus, and the submission interface without pretending to verify the production database.
What these checks establish
The project uses Vitest for component tests and Playwright for browser tests. The submission, gallery-navigation, and viewer suites exercise the state changes described here. The repository is private, so this article is a case study; the code excerpt above needs its surrounding gallery callback.
The component tests mock backend calls, and the browser suite disables the live backend. They check client behaviour. To verify the deployed permissions, I would also test access as a visitor, the submitting artist, another artist, and an administrator. A successful component test cannot establish that storage rejects the wrong user or that an email was delivered.
The request lifecycle is the part I would reuse in another project: keep a stable file identity, update the current collection, stop callbacks from an old instance, and give failed requests a bounded way to recover. Each of those decisions has a failure case that can be reproduced before changing the implementation.