All projects
Live in productionProject 01
SaaSMulti-tenanteCommerce

BioLinkStore

One URL that turns your catalog into a store, for people who sell on Instagram and close on WhatsApp. Set up in minutes, no card required, $0/month infrastructure without giving up architecture.

Next.jsNestJSPrismaPostgreSQL (Supabase)VercelKoyeb

The Instagram bio link has one job. BioLinkStore turns it into a complete store.

A catalog with photos, variants and stock, three templates, and the order reaching the seller on WhatsApp already written. No commission per sale and infrastructure at $0/mo.

Instagram profile
noire.boutique
clothing · nationwide shipping
Orders through the link
biolinkstore.com/noire
→
The same URL, opened
Linen shirt
US$ 18
Midi dress
US$ 32
Wide-leg pants
US$ 24
Size S · M · LColorStock
Order on WhatsApp
3:02min:s to a published store, timed in the recorded demo
$0per month in infrastructure: four providers on free tiers
0%commission per sale, on every plan
01 · How it works

From a tap on the bio to an order in WhatsApp

Tap the steps or let it advance. Each step lights up the piece doing the work.

Instagram bio
a single link
Next.js
/[slug] · SSR on Vercel
NestJS API
/api/public/:slug
PostgreSQL
one schema, N stores
WhatsApp
the order, pre-written
Step 1 of 5 · plays on its own
The customer taps the bio link
The URL is biolinkstore.com/<store>. Nothing to install and no account to create on the buyer's side: it is a page.
Sign-up is four screens: username (checked live so the link is free), store name, categories and plan. On the buyer's side there is no account and no persistent cart: the order is a message, and the seller closes it the way they always have.
02 · Architecture

Four layers, four providers, a single schema

Each layer lives on the provider that serves it for free. The coupling between them goes over HTTPS and an API contract documented in Swagger.

Production topology
Edge
Cloudflare
DNS + domain proxy
free
HTTPS
Front
Vercel · Next.js
/[slug] SSR + Open Graph
24 pages
BFF in Next
httpOnly cookies + /api/proxy
tokens off the browser
Bearer JWT · allowlisted CORS
API
Koyeb · NestJS
14 modules · 47 endpoints
hexagonal
Guards
JWT · StoreOwner · PlanFeature · Demo
4 in a chain
Prisma · pgBouncer
Data
Supabase
PostgreSQL · 19 models, one schema
multi-tenant
Storage
local · S3 · Cloudinary
one interface
Resend
welcome · reset
React Email

Inside the API, every module follows the same line: Controller → Use case → Repository interface → Prisma repository. That is 44 use cases with one responsibility each. The domain does not import Prisma.

Multi-tenant · one schema, one row per store
One route
app/[slug]/page.tsx
Each store is a value of the parameter, not a deployment.
slug
template
whatsapp
noire
Noir
+58 4…
brooklyn
Vitrina
+58 4…
andrea
Luxora
+58 4…
stores table · slug UNIQUE · index on slug
Noir
clothing · own palette
Vitrina
food · own palette
Luxora
accessories · own palette
The route calls /api/public/:slug and the renderer maps the template column to a component. A new store is an INSERT, not a deploy. Store names are examples.
Auth · tokens never touch the browser
1 · NestJS
Validates credentials and issues two JWTs of its own: a 15-minute access token and a 7-day refresh token. The refresh token is persisted in the refresh_tokens table.
2 · BFF · Next
Next's own /api/auth/login route receives the tokens and stores them in httpOnly cookies. It also sets a cookie with no secret in it, igs_session, which the Edge middleware can read.
3 · Browser
Never sees a token. Client code calls /api/proxy/… and the proxy injects the Bearer from the cookie.
4 · Proxy
If the API returns 401, the proxy calls /api/auth/refresh, rotates the cookies and retries the original request. If the refresh fails, the client signs out.
5 · Guards
On the API, three guards in a chain: global JWT (with @Public() to opt out), StoreOwnerGuard (the store is yours) and PlanFeatureGuard (your plan includes this).
03 · An end-to-end example

María opens her store on a Tuesday afternoon

A made-up case with small numbers, to see what happens at each stage. Names and quantities are examples.

1
16:00 · sign-up
She picks her link and it is free
María sells clothing on Instagram. She types maria-closet; the panel queries /stores/check-username and confirms nobody has it. Name, categories, Free plan. The store exists.
2
16:10 · catalog
Three products, one with variants
She uploads a shirt with attributes Size S · M · L and Color Black · White. The variant service generates the 6 combinations; she only adjusts the price of the L (+US$ 2) and the stock of each one. The photos come out with a discreet watermark: that is what the Free plan includes.
3
16:20 · design
She picks Vitrina and her colors
She tries ?preview=noir, goes back to Vitrina, sets her palette. She pastes biolinkstore.com/maria-closet into her bio. The preview shows her logo because the page carries Open Graph.
4
19:42 · a customer
Two black shirts in M
Someone comes in from the bio, picks size and color, and orders. The backend creates the OrderIntent: 2 × (18 + 0) = US$ 36, generates the message and the WhatsApp URL. The buyer lands in María's chat with the order written.
5
19:43 · panel
The quote is already on her list
Under Quotes the order shows up with the customer's number and a button to open the chat. Under Analytics, the visit, the product view and the checkout were recorded as events. María ships via WhatsApp, as always.
04 · The challenges

Five times the obvious solution was wrong

Every challenge follows the same pattern: what anyone would do first, why it does not work, what was done and the number that proves it. The technical detail stays folded.

01

Many stores, one schema

The obvious approach
One PostgreSQL schema per store, or one database per customer. Perfect isolation.
Why it fails
With zero budget and a single Supabase instance, every new schema is one more migration to run and one more connection pool to pay for. And the real problem is not the schema: in a shared schema, a query without a store filter does not fail. It silently returns someone else's data.
Compiles · passes tests · leaks
prisma.product.findMany({ where: { id: productId } })
Returns the product even when it belongs to another store. No error gives it away.
What every repository requires
prisma.product.findMany({ where: { id: productId, storeId } })
The storeId arrives as a required parameter, already validated by the guard.
What was done
Two layers that do not depend on anyone's memory: StoreOwnerGuard checks that the store in the URL belongs to the user in the token before entering the use case, and repositories receive the storeId as a required parameter, never inferring it. Public routes resolve by slug, which is unique and indexed.
1 schemafor every store. A new customer is a row, not a migration.
Technical detail

Prisma does not expose Row Level Security. The discipline lives in the code: every new repository must accept storeId and use it in the where. It is sustainable while the team is small and dangerous once it is not; that is why it is on the list of what would be done differently today.

Nested routes are explicit, /api/stores/:storeId/products, so the guard always has the identifier in the URL and not in the request body.

02

Size, color and 64 rows

The obvious approach
Store each variant as JSON: { "Size": "M", "Color": "Black" }. Flexible, no new tables, any attribute fits.
Why it fails
When the buyer picks Size and Color you have to find that row by comparing JSON objects, with no usable index. With 3 attributes of 4 values that is 64 combinations per product, resolved at request time. And renaming an attribute means migrating data inside JSON.
2 attributes × 2 values
S · Black
S · White
M · Black
M · White
4 rows · readable at a glance
3 attributes × 4 values
64 rows · finding one means comparing JSON
What was done
The seller does not write the combinations: VariantGeneratorService takes the cartesian product of the attributes and creates the rows. The final price is basePrice + priceAdjustment of the variant, and stock lives per variant. The name shown in the WhatsApp message is composed on the fly: M / Black.
64possible combinations for 3 attributes × 4 values. The generator creates them; matching is still JSON.
Technical detail

The combination field is Json in the schema. It works for the current volume (the Free plan caps at 20 products, Pro at 100) and it is the clearest debt in the data model: a ProductVariantAttribute table with one row per attribute-value pair would be indexable and queryable with plain SQL.

03

The error that raises no error

The obvious approach
Point DATABASE_URL at Supabase, run prisma migrate deploy on boot and done. It works locally.
Why it fails
Supabase hands out the connection through pgBouncer in transaction pooling mode, which keeps no session state between queries. Prisma uses prepared statements and the migration asks for an advisory lock, two things that require a session. Result: migrate deploy hangs. No timeout, no stack trace, no log. Every attempt cost a build cycle.
DATABASE_URL
…:6543/postgres?pgbouncer=true
Runtime: Prisma stops assuming a session and disables prepared statements.
DIRECT_URL
…:5432/postgres
Migrations: the advisory lock needs a direct connection, no pooler.
migrate deploy
from the local machine
Port 5432 is not reachable from the Koyeb build. The migration runs from the laptop; the deploy only boots.
What was done
Three changes, each for a different reason: ?pgbouncer=true on the runtime URL, directUrl on port 5432 for migrations, and migrations run from the local machine because that port is not reachable from Koyeb's build environment.
~5 minper build cycle with no useful message at all. The most expensive challenge of the project in time.
Technical detail

The Prisma docs and the Supabase docs mention the case separately; neither describes the combined effect on a real deployment. It is the kind of problem that only shows up in production and is worth writing down for the next project on the same stack.

04

Tokens never touch the browser

The obvious approach
Store the JWT in localStorage and send it from the client with an interceptor. It is what most tutorials do.
Why it fails
Any script running on the page can read localStorage. And in this deployment the front (Vercel) and the API (Koyeb) are different origins: every call from the browser is one more CORS negotiation, with credentials, which already cost production time once.
What was done
A BFF inside Next itself: /api/auth/login stores access and refresh in httpOnly cookies, and /api/proxy/[...path] forwards every panel call to the API, injecting the Bearer. On a 401, the proxy refreshes, rotates the cookies and retries the original request. The browser only sees a cookie with no secret in it, igs_session, which the Edge middleware uses to redirect without calling the API.
15 minaccess token lifetime. 7 days for the refresh token, revocable because it lives in a table.
Technical detail

The Google OAuth callback is the awkward exception: the 302 redirect comes back from the API to the front with the tokens in the query string, and a /login/oauth page turns them into cookies. A ref keeps the useEffect from consuming them twice in Strict Mode. It is one of the items on the would-do-differently list.

05

Charging for plans without the code knowing

The obvious approach
An if (plan === 'PRO') in every endpoint that depends on the plan.
Why it fails
Three plans times a dozen capabilities is thirty-six decisions scattered across the code. Changing a commercial limit, say raising the product cap on the Free plan, means hunting down every if.
What was done
A single source of truth, PLAN_FEATURES: per plan, how many products, how many photos, custom domain, watermark, analytics, multi-store. A guard reads the endpoint's decorator, looks up the store's subscription and decides. The product limit, the watermark on uploaded photos and the powered by mark all come from the same table.
3 plansFree $0 · Pro $15/mo · Business $150/mo. 0% commission on all three.
Technical detail

Free: 20 products, 5 photos per product, 5 categories, WhatsApp checkout, basic analytics. Pro: 100 products, 10 photos, no watermark, Pro templates, quote export. Business: unlimited and up to 3 stores. Landing prices are also shown in bolívares at the official daily rate, read from a public API and cached on the client.

There is also a DemoGuard: accounts flagged as demo can read everything and write nothing, except for an allowlist of exempt emails. It is used to show the panel without anyone breaking the sample store.

"The order is a message."
There is no payment gateway and no persistent cart on the buyer's side. The seller already gets paid through Pago Móvil, Zelle or cash and already closes on WhatsApp; the product does not change that, it removes the work of typing out the catalog twelve times a day.
05 · Decisions

A single filter: does this fit in the free tier without giving up architecture?

If the answer was no, another piece was chosen. If it was yes, it was abstracted just enough to be swapped later.

Cloudflare
DNS and proxy
Vercel
Next.js SSR
Koyeb
NestJS API
Supabase
PostgreSQL
Resend
transactional email
Storage
local · S3 · Cloudinary
There was a single design constraint: every infrastructure decision had to get the most technical capability out of its provider's free tier. That turns the stack into a test of ecosystem knowledge, not of preference.
WHY
Why a separate NestJS API instead of Next API Routes?
Because the backend assumes a long-lived process with real dependency injection: global guards, interceptors, exception filters, Passport strategies, a storage abstraction. Putting that into serverless functions means losing the NestJS container or emulating it by hand.
Go deeper

The second reason is scale: the front is served from Vercel's edge and the API scales on its own. In a multi-tenant SaaS the traffic spikes of two stores are not correlated.

The accepted cost was CORS and latency between services. It showed up in production and cost time; the BFF in challenge 04 is the answer.
WHY
Why Prisma and not Drizzle or TypeORM?
End-to-end typing from the schema to the query, a predictable generated client and deterministic migrations. TypeORM has known problems with strict and its decorators; Drizzle was excellent but its migration tooling was less mature when the project started.
Go deeper
The trade-off showed up in production: Prisma assumes direct connections and is not built for poolers like pgBouncer without explicit configuration. That is challenge 03.
WHY
Why Resend and not Supabase email or SendGrid?
Supabase email is built for its own Auth, and here auth is a custom JWT with Passport. SendGrid is solid but its configuration is not worth it at the current volume. Resend has a first-class TypeScript SDK and templates in React Email: 90% of the result for 20% of the configuration.
WHY
A storage interface from day one
StorageProvider with three implementations: local for development, S3-compatible and Cloudinary. In a SaaS where every store uploads its photos, the storage provider is a cost decision that is going to change. Changing it is an environment variable, not a refactor.
Go deeper

The watermark goes through the same path: sharp composes an SVG over the image before uploading it, only if the store's plan does not include removing it. GIFs and PDFs skip the step.

06 · What I would do differently

Five decisions I would make the other way today

I did
Isolation by storeId, by hand, in every repository
Today I would
PostgreSQL Row Level Security. One policy per table makes the isolation impossible to bypass no matter the ORM. Supabase supports it first class.
I did
ProductVariant.combination as JSON
Today I would
A ProductVariantAttribute table with one row per attribute-value pair. Indexable, queryable with plain SQL, no JSON migration when an attribute is renamed.
I did
Refresh tokens in a PostgreSQL table
Today I would
Redis with TTL. The store handles expiry on its own, no frequent writes to Postgres and no cleanup jobs, O(1) reads.
I did
OAuth tokens in the redirect query string
Today I would
Close the callback inside the BFF itself: have the API redirect to a Next route that sets the cookies on the server, so the tokens never pass through the URL or the history.
I did
Front-end folders by type (components/, lib/)
Today I would
Structure by domain: features/auth, features/store, features/products. The same cohesion the backend modules already have.
07 · If you take away three things

Recap

1
A bio link that is a store: a catalog with variants and stock, three templates, and the order arrives on WhatsApp already written. No commission per sale.
2
Multi-tenant in one schema: a new store is a row, not a deploy. Isolation is guaranteed by a guard and a required parameter, and today it would be done with RLS.
3
Infrastructure at $0/mo across four providers, with tokens off the browser and a single plan table. The most expensive challenge was a pooler that hangs without saying a word.
Links
View live