Building a FiveM mod marketplace from scratch is not your typical Next.js project. When I started working on VertexMods, I quickly realized that the usual e-commerce patterns don't fully translate to the gaming modding space. Here's what I learned.
The Stack
Before we dive in, here's the full tech stack:
- Next.js 16 (App Router, React Server Components, PPR)
- PostgreSQL + Drizzle ORM (75 tables, full-text search via tsvector)
- Clerk for authentication
- Stripe + Coinbase Commerce for payments
- Cloudflare R2 for mod file storage
- TypeScript strict mode throughout
Why FiveM Mods Are Different
FiveM is a multiplayer modification framework for GTA V. Server owners run custom Lua scripts (called "resources") that add gameplay features — economy systems, vehicles, jobs, weapons, UI menus. These scripts range from free community projects to premium $50+ packages.
The marketplace has to handle:
- Instant digital delivery — buyers expect the download link immediately after payment
- Guest checkout — many server owners don't want to create accounts
- Two payment methods — traditional Stripe + crypto via Coinbase (the gaming community loves crypto)
- Free mods with monetized links — Linkvertise/LinkHub for free downloads
Stripe + Instant Downloads
The trickiest part was the download flow. We use Stripe Checkout sessions with webhook verification:
// src/app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
const sig = req.headers.get('stripe-signature')!;
const body = await req.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
return new Response('Webhook signature verification failed', { status: 400 });
}
if (event.type === 'checkout.session.completed') {
await handleSuccessfulPurchase(event.data.object as Stripe.Checkout.Session);
}
return new Response('OK');
}
For guest users, we generate encrypted download tokens with a 7-day expiry:
// Encrypted token for guest downloads — no account needed
const token = await encrypt({
orderId: order.id,
productIds: order.items.map(i => i.productId),
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
});
Next.js 16 + PPR
We're on Next.js 16 with Partial Pre-Rendering (PPR) enabled via cacheComponents: true. This replaced all the old unstable_cache patterns with "use cache" directives:
// Product listing page — static shell + dynamic personalization
async function ProductGrid({ locale }: { locale: string }) {
"use cache";
cacheLife("hours");
cacheTag("products");
const products = await getPublishedProducts();
return <Grid products={products} />;
}
The shop page now serves from cache in ~50ms while the cart/auth state hydrates client-side. That's a real win for Core Web Vitals.
Drizzle ORM + Full-Text Search
PostgreSQL full-text search powers the mod discovery. We use a generated tsvector column with a GIN index:
// schema/products.ts
export const products = pgTable('products', {
id: uuid('id').primaryKey().defaultRandom(),
title: text('title').notNull(),
description: text('description').notNull(),
searchVector: customType<{ data: string }>({
dataType: () => 'tsvector',
})('search_vector').generatedAlwaysAs(
sql`to_tsvector('english', title || ' ' || description)`
),
}, (t) => [
index('products_search_idx').using('gin', t.searchVector),
]);
Querying is clean:
const results = await db
.select()
.from(products)
.where(sql`search_vector @@ plainto_tsquery('english', ${query})`)
.orderBy(sql`ts_rank(search_vector, plainto_tsquery('english', ${query})) DESC`);
Cloudflare R2 for Mod Files
R2 stores the actual ZIP files. Presigned URLs expire after 15 minutes to prevent link sharing:
// src/lib/r2.ts
export async function getDownloadUrl(key: string): Promise<string> {
const command = new GetObjectCommand({ Bucket: process.env.R2_BUCKET_NAME!, Key: key });
return getSignedUrl(r2Client, command, { expiresIn: 900 }); // 15 min
}
The R2 bucket is private — no public access. Downloads always go through our API which validates the token first.
Multi-Language with next-intl
The marketplace supports 4 locales: English (no prefix), German (/de), French (/fr), and Brazilian Portuguese (/pt-br).
English URLs are canonical without prefix — /shop not /en/shop. This is a common source of SEO bugs; make sure your sitemap, hreflang tags, and middleware all agree on this.
Creator Marketplace
Beyond just selling our own mods, we built a creator platform where independent FiveM developers can list their scripts. Creators earn 70% of revenue, we take 30%. Stripe Connect handles payouts.
The flow:
- Creator applies → admin reviews
- Creator submits product for review
- Admin approves → product goes live
- Buyer purchases → creator gets 70% via Stripe Connect payout
Lessons Learned
Webhooks are hard in development. We built a zero-config webhook setup that automatically starts the Stripe CLI listener with pnpm dev and writes the secret to .env.local. No more manual setup.
PPR with auth is tricky. Static cache can't include user-specific data. The pattern that works: cache the product data, render auth state client-side with a Suspense boundary.
Guest checkout UX matters. A huge percentage of server owners will abandon checkout if forced to create an account. Guest checkout with email-based download tokens was worth the extra complexity.
The full marketplace is live at vertexmods.com if you want to see it in action.
Questions? Drop them in the comments. I'm happy to go deeper on any part of the stack.