WeSwapCards.
Built and operated solo. Used by 980 people
to swap WeWard cards.

A real product, not a portfolio piece.
WeSwapCards is a platform where collectors of WeWard cards find swap partners, message about trades, and track progress toward complete sets. It launched in February 2025 after four months of development.
I built it because no good tool existed for the WeWard community. Collectors were coordinating swaps across scattered group chats and losing track of who had what. WeSwapCards gave them one place to find each other, talk, and trade. A year later, WeWard launched the same feature inside their own app.
980+
Members
16K+
Trades coordinated
200K+
Messages exchanged
Averaged 317 monthly active users from July 2025 to February 2026,
peaking at 334 in January 2026, the month before WeWard launched a
competing feature inside their own app.
I designed, built, and operate WeSwapCards solo:
architecture, frontend, backend, database, auth,
deployment, and ongoing user support.
A clean separation of concerns.
A React frontend talks to an Express REST API backed by PostgreSQL, with Clerk handling authentication. The shape is deliberately conventional.
API LOAD
35K+
Daily Calls
1M+
Peak Monthly Requests
Sustained across a year in production.
Frontend
React 18, React Router 6, Context + useReducer
API
Node 22, Express 4, Clerk authentication middleware
Data
PostgreSQL, pg driver
Three decisions worth a closer look.
Tap any card to read the deep-dive.
The first sign was a Passenger error page at api.weswapcards.com. No 5xx, no Express error. The runtime itself wasn't starting, which narrowed the problem space immediately. Whatever was wrong was happening before any of my code had a chance to run.
o2switch exposes raw log files per subdomain. I pulled the most recent one for the API and found the error pattern repeating:
/opt/alt/alt-nodejs16/root/usr/bin/node:
/opt/alt/alt-nodejs16/root/usr/lib64/libcrypto.so.1.1:
version `OPENSSL_1_1_1e' not found
(required by /opt/alt/alt-nodejs16/root/usr/lib64/libnode.so.93)Three things stood out. The path still referenced alt-nodejs16 (Node 16, well past EOL). The missing symbol was an OpenSSL version the system no longer provided. And the failure was in node itself, before any JavaScript got evaluated.
This was an ABI mismatch. The Node binary was linked against one version of libcrypto and the system had moved to another. Nothing in my Express app, my dependencies, or my route handlers could have caused it or fixed it.
o2switch lets you choose the Node version per application from its technical panel. I updated the API from Node 16 to Node 22 and restarted. Service was back up.
What I'd do differently:Set up structured logging with Pino or Winston, and external uptime monitoring, before the next runtime bump. Logs in the host's file manager, manually grep'd, is not a sustainable ops loop. I'd rather learn about the next outage from a Slack ping than from a user email.
A common mistake in apps that use third-party auth is to treat the auth provider's user object as the application user. It works at first. Then you need to swap providers, support multiple auth methods, store domain-specific user data, or write tests that don't depend on the auth provider being available, and the cost of the shortcut shows up.
The two identities:
- Clerk owns authentication: sessions, credentials, OAuth, password resets.
- The
explorertable owns domain identity: the username, the foreign-key target for swaps, messages, and card collections.
They're joined by explorer.userid, which holds the Clerk UID. The consequence: Clerk could be replaced with another provider by changing what populates that column. Domain logic doesn't move.
Authorization runs in four layers:
app.use(clerkMiddleware()); // populates req.auth
router.post('/login/user', requireAuth(), handler); // unauthenticated → reject
const explorer = await getExplorerIdByClerkId(req.auth.userId);
if (explorer.id !== Number(req.params.explorerId))
// ownership check
return res.status(403).end();
// + DB unique constraints as the final guardTwo pieces of this design I'm proud of:
A Clerk webhook with Svix signature verification handles user.deleted. When a user removes their Clerk account, the matching explorer is deleted, preventing orphan domain data.
A frontend route guard called RequireUsername catches the “Clerk-authenticated but no Explorer record yet” state on every protected route. If signup is interrupted partway through, the system recovers on the next navigation rather than silently breaking.
What I'd do differently: The duplicate-UID branch in the registration flow is a 409 fallback. A single INSERT ... ON CONFLICT upsert at the database level would be cleaner. It's one query instead of two and removes a class of race conditions entirely.
The deliberate call was not to introduce Redux. The global slice I actually needed was small: two namespaces (explorer and swap) and around eleven actions. Redux's biggest wins (devtools time-travel, middleware ecosystem, RTK Query) are real but priced for applications with much larger global state than mine.
The known tax with Context is re-renders. Every consumer re-renders when any part of the value changes. I solved it by splitting state and dispatch into two separate contexts:
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
<AuthWatcher />
<BootAuthLoader hasExplorer={hasExplorer} />
{children}
</DispatchContext.Provider>
</StateContext.Provider>Components that only dispatch, the majority, never re-render when state changes.
The discipline is in what's global and what isn't.
| State | Lives in |
|---|---|
explorer.id, explorer.name | Global store |
| Active swap: target user, card, conversation | Global store |
| Page-scoped async data | Feature reducers |
| Loading flags, alerts, form values | Local useState |
State that crosses routes goes global. State scoped to a single feature stays in that feature. Ephemeral UI stays in useState. Nothing in the global store is a data cache.
UI components don't consume context directly. Feature logic lives in useXLogic hooks (useSwapLogic, useDashboardLogic), and only those hooks touch the store. UI receives plain values and callbacks. The store could be swapped for Zustand or Redux by editing the logic hooks alone.
What I'd do differently: The migration left a few leftovers. An unused explorer/created reducer case. A state.userUID field that's written but never read. Worth a cleanup pass. None of them is causing a bug, which is itself evidence the architecture's seams are sound.
A year of operating WeSwapCards taught me where I'd build differently. I started with Sqitch migrations and drifted off them; the conversation and message tables exist only in the database now, not in version control. Testing was an afterthought I keep meaning to come back to. None of it is breaking anything in production, but each one is something I'd do from day one if I rebuilt it. Activity has dropped since WeWard launched their native version, and the mobile rebuild I'm working on now is partly a response: making the experience good enough on a different surface to compete on different terms.
WHAT'S NEXT
WeSwapCards Mobile.
Same backend, new client. A React Native rewrite in TypeScript,
and the architectural lessons from a year of running the web app.