How I Built a FiveM Mod Marketplace with Next.js 16 and Stripe

typescript dev.to

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:

  1. Instant digital delivery — buyers expect the download link immediately after payment
  2. Guest checkout — many server owners don't want to create accounts
  3. Two payment methods — traditional Stripe + crypto via Coinbase (the gaming community loves crypto)
  4. 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');
}
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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} />;
}
Enter fullscreen mode Exit fullscreen mode

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),
]);
Enter fullscreen mode Exit fullscreen mode

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`);
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Creator applies → admin reviews
  2. Creator submits product for review
  3. Admin approves → product goes live
  4. 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.

Source: dev.to

arrow_back Back to Tutorials