How to Build a Workflow Marketplace with Next.js and Supabase
Last week, I launched a small workflow marketplace for n8n automations. In 48 hours, 3 sellers had listed their workflows, and the platform processed its first $47 sale. This isn't a theoretical mega-platform; it's a functional, monetizable product you can build over a weekend using tools you probably already know.
Architecture overview: what we're building
We're building a full-stack marketplace where creators can sell packaged automations (like n8n workflows or Make.com scenarios). Buyers can browse, purchase, and instantly receive a download link. The core stack is Next.js 15 (App Router) for the frontend and API routes, Supabase for the database and auth, and Paddle for payments. The frontend is deployed on Vercel.
Here’s the data flow: A seller submits a workflow through a form (title, description, price, .json workflow file). Our Next.js API route validates it, uploads the file to Supabase Storage, and creates a listing record in the database with a pending status. Upon successful Paddle checkout, a purchases record is created, which triggers a Supabase Edge Function. This function generates a signed, time-limited URL for the workflow file and emails it to the buyer via Resend. For a deeper look at crafting the automations themselves, check out my guide on building AI employees.
The key is keeping logic server-side. Never expose storage URLs directly. All interactions—listing creation, purchase events, file access—are gated by Supabase Row Level Security (RLS) and our API. Next, let's define the data model that makes this secure structure possible.
Database schema with Supabase (tables + RLS policies)
We need four core tables in Supabase. Here’s the SQL for the workflow_listings table, which is the heart of our workflow marketplace Nextjs build:
CREATE TABLE workflow_listings (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
seller_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,
title TEXT NOT NULL,
description TEXT,
price DECIMAL(10,2) NOT NULL,
file_url TEXT, -- Path in Supabase Storage
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')),
created_at TIMESTAMPTZ DEFAULT NOW()
);
The purchases table links buyers to listings:
``sql
CREATE TABLE purchases (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
buyer_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,
listing_id UUID REFERENCES workflow_listings(id) NOT NULL,
paddle_order_id TEXT UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
``
We also have profiles (extending auth.users) and a seller_applications table for role management. Security is enforced with RLS policies. For example, this policy ensures users only see approved listings:
CREATE POLICY "Select approved listings" ON workflow_listings
FOR SELECT USING (status = 'approved');
Sellers get a policy to manage their own listings:
``sql
CREATE POLICY "Sellers manage own listings" ON workflow_listings
FOR ALL USING (auth.uid() = seller_id);
``
Without RLS, your data is wide open. These policies, combined with Supabase Auth, create a secure foundation. Speaking of auth, let's set up the user system.
Authentication: signup, login, seller roles
We'll use Supabase Auth for everything. The auth.users table is managed automatically; we extend it with a profiles public table.
CREATE TABLE profiles (
id UUID REFERENCES auth.users(id) PRIMARY KEY,
username TEXT UNIQUE,
avatar_url TEXT,
is_seller BOOLEAN DEFAULT FALSE
);
Implement a signup flow with the @supabase/ssr package in a Next.js Server Action. After basic email/password sign-up, insert a row into profiles.
// app/auth/signup/route.js
export async function POST(request) { const supabase = createClient(); const { email, password, username } = await request.json();
const { data: authData, error: authError } = await supabase.auth.signUp({ email, password, });
if (authError) throw authError;
// Create profile const { error: profileError } = await supabase .from('profiles') .insert([{ id: authData.user.id, username, is_seller: false }]);
if (profileError) throw profileError;
return Response.json({ user: authData.user }); } ```
For seller status, don't let users self-promote. Create a seller_applications table. When a user applies, an admin gets notified (via a Supabase Function triggering Resend). Once approved, you update profiles set is_seller = true. This is_seller flag gates access to the "Create Listing" page and powers the RLS policies we wrote earlier. With users authenticated and roles defined, we can start displaying the core of our marketplace.
Building the marketplace listing page
This is the public-facing page (/marketplace) where buyers browse. We'll fetch approved listings from Supabase, server-side in a Next.js page or route handler, for SEO and speed. When choosing an automation platform to build for, our n8n vs Zapier vs Make comparison is essential reading.
// app/marketplace/page.js
export default async function MarketplacePage() {
const supabase = createClient();
const { data: listings } = await supabase
.from('workflow_listings')
.select(
id, title, description, price, created_at,
profiles (username)
)
.eq('status', 'approved')
.order('created_at', { ascending: false });
return (
{listing.title}
{listing.description}
${listing.price}
By {listing.profiles?.username}
{/ "Buy Now" button would link to a /checkout/[id] page /}For a production workflow marketplace Nextjs site, add pagination with range() and filters (e.g., by price range or integration type like "Slack" or "Google Sheets"). The listing card should link to a detailed /workflow/[id] page for better conversion. The "Buy Now" button will kick off the Paddle checkout flow, which we'll handle in an API route—that's where the real transaction magic happens, connecting the purchase to our database and triggering the file delivery.
File uploads to Supabase Storage
For sellers to upload workflow files (like n8n JSON exports or custom scripts), I use Supabase Storage. First, I create a bucket called workflow-templates via the Supabase dashboard with public access disabled. Then, I set up a storage policy to allow authenticated users to upload, but only the file owner (and admins) to read or delete:
CREATE POLICY "Users can upload their own workflow files"
ON storage.objects FOR INSERT TO authenticated
CREATE POLICY "Users can read their own purchased or uploaded files" ON storage.objects FOR SELECT TO authenticated USING ( bucket_id = 'workflow-templates' AND ( owner = auth.uid() OR EXISTS ( SELECT 1 FROM purchases WHERE purchases.workflow_id = (storage.objects.metadata->>'workflow_id')::uuid AND purchases.user_id = auth.uid() AND purchases.status = 'completed' ) ) ); ```
On the frontend, I use the @supabase/supabase-js client. The upload component generates a unique file path like user_${userId}/workflow_${workflowId}/template.json. Here's the actual upload function I call:
const uploadFile = async (file, workflowId) => {
const fileExt = file.name.split('.').pop();
const fileName = `template.${fileExt}`;
const { error } = await supabase.storage .from('workflow-templates') .upload(filePath, file, { cacheControl: '3600', upsert: true, metadata: { workflow_id: workflowId } });
if (error) throw error; // Store the public URL in the workflows table const { data: { publicUrl } } = supabase.storage .from('workflow-templates') .getPublicUrl(filePath);
await supabase .from('workflows') .update({ file_url: publicUrl }) .eq('id', workflowId); }; ```
For downloads, I generate signed URLs for purchased files that expire in 1 hour, preventing unauthorized sharing.
Payment integration with Paddle
I chose Paddle because it handles taxes, compliance, and payment methods globally. First, I create a seller account at paddle.com and get my vendor_id and vendor_auth_code. For the marketplace, I use Paddle's Catalog API to create products dynamically whenever a seller publishes a workflow.
When a seller submits a workflow, my API route creates a Paddle product:
// pages/api/create-paddle-product.js
const Paddle = require('@paddle/paddle-node-sdk');
export default async function handler(req, res) {
const { workflowId, name, price } = req.body;
const product = await paddle.Products.create({
name: ${name} (Workflow),
taxCategory: 'digital-goods',
prices: [{
description: 'One-time purchase',
unitPrice: {
amount: (price * 100).toString(), // Convert to cents
currencyCode: 'USD'
}
}],
customData: { workflowId }
});
// Store Paddle product ID in Supabase await supabase .from('workflows') .update({ paddle_product_id: product.id }) .eq('id', workflowId);
res.json({ productId: product.id }); } ```
For checkout, I use Paddle's inline checkout script. When a buyer clicks "Purchase", I generate a Paddle checkout URL with the product ID and passthrough data containing the buyer's user ID:
const checkoutUrl = `https://buy.paddle.com/checkout?product=${productId}&passthrough=${encodeURIComponent(JSON.stringify({ userId, workflowId }))}`;
I set up a webhook at https://myapp.com/api/paddle-webhook to receive payment events. When payment.completed arrives, I verify the webhook signature, then update my purchases table and grant the buyer access to the workflow file.
Deployment to Vercel
I deploy the Next.js app to Vercel because it's seamless with automatic preview deployments. First, I push my code to a GitHub repository. Then in the Vercel dashboard, I import the project and configure these environment variables:
NEXT_PUBLIC_SUPABASE_URL=https://xyz.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
SUPABASE_SERVICE_ROLE_KEY=eyJ...
PADDLE_API_KEY=pdl_nt_...
PADDLE_WEBHOOK_SECRET=pdl_whsec_...
NEXT_PUBLIC_APP_URL=https://my-marketplace.vercel.app
For the Paddle webhook to work in production, I need a publicly accessible URL. Vercel provides this automatically. I enter my production URL in Paddle's webhook settings at Dashboard → Settings → Webhooks.
I create a vercel.json file to ensure proper routing for Next.js API routes:
{
"headers": [
{
"source": "/api/(.*)",
"headers": [
{ "key": "Access-Control-Allow-Credentials", "value": "true" },
{ "key": "Access-Control-Allow-Origin", "value": "*" },
{ "key": "Access-Control-Allow-Methods", "value": "GET,OPTIONS,PATCH,DELETE,POST,PUT" }
]
}
]
}
Finally, I set up a PostgreSQL database connection in Vercel's Storage tab to enable direct database access for serverless functions, though I primarily use Supabase's client. The deployment takes about 2 minutes, and I get a live URL like https://my-marketplace.vercel.app.
Wrapping Up
Building a workflow marketplace requires stitching together several specialized services: Supabase for auth and storage, Paddle for payments, and Vercel for hosting. The key is connecting these systems through webhooks and maintaining consistent data relationships between your database and external APIs. Start with a solid schema, implement RLS properly, and test webhooks locally using tools like ngrok before deploying.
Don't want to build from scratch? Get our SaaS Starter Kit for $79.
You've got all the pieces—now go connect them.
Walid Abed
Building AI-operated businesses from Beirut. Creator of Opsonaut.
Recommended
Ready to automate?
Browse workflow templates, prompt packs, and AI kits.
Get weekly automation tips
Join 1,000+ developers and solopreneurs. No spam.
Related Articles
50 AI Prompts Every Business Needs in 2026
50 copy-paste AI prompts that handle marketing, sales, support, and operations — tested across ChatGPT, Claude, and Gemini.
The Developer's Guide to n8n Workflow Templates
Everything about n8n workflow templates — from importing your first template to building and selling your own.
How to Automate Your Entire Business for Under $120/Month
A real cost breakdown: automate sales, support, content, and analytics using free and low-cost tools for under $120/month.