Skip to content

Stack · Next.js

We build Next.js applications: SaaS, dashboards, the whole product

App Router, React 19, Server Actions, Server Components, edge runtime where it earns its place. The stack that holds up after the contract ends, on the framework that everyone in your team already knows.

Why this stack

App Router as a real architecture

We build on the App Router, not because it is new but because it makes the page boundary the data boundary. Server Components hold the data fetch, client components hold the interaction, and the seam between them stops being a Redux store you have to maintain.

Server Actions instead of an API layer

Every internal mutation is a typed function the client calls directly. No DTOs, no client-side fetch wrappers, no parallel API surface for the same operation. The API only exists where a third party needs to read it.

Edge runtime where latency matters

Auth checks, rate limiting and personalisation run at the edge so the first byte arrives in under a hundred milliseconds. The Node runtime stays where it belongs (Stripe webhooks, file uploads, anything that needs the full Node API).

Type safety end of file

TypeScript strict, the database types generated from Supabase, the dictionary keys checked at compile time. A renamed column breaks the build, not a paying customer.

An ecosystem your next team already knows

Hiring on Next.js is a different conversation from hiring on a niche framework. The component library, the auth patterns, the deploy story, the debugging tools: your next developer was already using them before they opened your codebase.

What we build with it

Multi-tenant authentication

Supabase Auth with row-level security, social providers, magic links, role-based permissions enforced server-side.

Stripe-integrated billing

Checkout, Subscriptions, Customer Portal, prorating, webhook event router, idempotent handlers, retry queue.

Admin dashboard

Internal cockpit for support, refunds, plan migrations, audit log. Built with the same design system as the customer-facing surface.

Server Actions and forms

Every form a typed Server Action with progressive enhancement; client-side validation that shares the same schema as the server.

Background jobs and queues

Webhook ingestion, idempotent processing, dead-letter logging, scheduled tasks for billing and reporting.

Multi-locale routing

hreflang and locale-aware URLs from the first commit. Three languages by default, more without a rebuild.

Marketing site and product in one codebase

One repo, one deploy, one team. The pricing page ranks like a static site and the dashboard loads fast on the first click.

Type-safe content layer

MDX with frontmatter or Supabase-backed CMS, fully typed, queried via Server Components.

Search Console and Plausible wired in

Sitemap, robots, OpenGraph, JSON-LD schema, hreflang validation, performance budget tracked per release.

Migration from legacy stacks

WordPress, Webflow, Bubble or React 17 / Pages Router. Existing content moved, URL redirects honoured, users carried through.

Performance budget in the contract

Core Web Vitals targets stated in the SOW, tested under load, reported with every release.

Handover documentation

Runbooks, architecture overview, design system as a versioned package. The next team picks up without a guided tour.

A real Server Action with row-level security

A signup form, a Stripe checkout call, and a tenant-isolated row insert in five files. No DTO layer, no fetch wrapper, no ORM in the middle of the request.

Most Next.js write-ups stop at the page boundary. The interesting part is what happens between a Server Component, a Server Action and the database it touches. Here is the shape we use in production for a tenant-isolated signup that calls Stripe Checkout and writes back to Supabase under row-level security.

1. The route file

A single Server Component reads the current tenant from the session and renders a form that posts to a typed Server Action. No client component yet; the form works with JavaScript disabled.

// app/[lang]/(public)/signup/page.tsx
import { redirect } from 'next/navigation'
import { createCheckoutSession } from './actions'
import { getServerSession } from '@/lib/auth/server'

export default async function SignupPage() {
  const session = await getServerSession()
  if (session) redirect('/dashboard')

  return (
    <form action={createCheckoutSession}>
      <label>
        Email
        <input name="email" type="email" required />
      </label>
      <label>
        Plan
        <select name="plan" required>
          <option value="starter">Starter</option>
          <option value="growth">Growth</option>
        </select>
      </label>
      <button type="submit">Continue to checkout</button>
    </form>
  )
}

2. The Server Action

The action runs only on the server. The Stripe call happens in the Node runtime; the redirect to the Stripe-hosted checkout URL is a regular HTTP 303. No client state, no fetch wrapper.

// app/[lang]/(public)/signup/actions.ts
'use server'

import { redirect } from 'next/navigation'
import { z } from 'zod'
import { stripe } from '@/lib/stripe/server'
import { createDraftTenant } from '@/lib/tenants/server'

const Input = z.object({
  email: z.string().email(),
  plan: z.enum(['starter', 'growth']),
})

export async function createCheckoutSession(formData: FormData) {
  const parsed = Input.parse({
    email: formData.get('email'),
    plan: formData.get('plan'),
  })

  const tenant = await createDraftTenant(parsed.email)

  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    customer_email: parsed.email,
    client_reference_id: tenant.id,
    line_items: [{ price: PRICE_LOOKUP[parsed.plan], quantity: 1 }],
    success_url: `${process.env.SITE_URL}/welcome?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.SITE_URL}/signup`,
    metadata: { tenant_id: tenant.id, plan: parsed.plan },
  })

  redirect(session.url!)
}

3. The row-level-security policy

The draft tenant insert runs as the service-role key so it can write before there is a user. Once the Stripe webhook attaches a user, every subsequent read is scoped to the authenticated tenant via this policy. The policy is in source control, not in the database UI.

-- supabase/migrations/0001_tenants_rls.sql
create policy "tenants: read own row"
on tenants for select
using (
  id = (select tenant_id from members where user_id = auth.uid())
);

create policy "tenants: update own row"
on tenants for update
using (
  id = (select tenant_id from members where user_id = auth.uid())
  and exists (
    select 1 from members
    where user_id = auth.uid()
      and tenant_id = tenants.id
      and role in ('owner', 'admin')
  )
);

4. The Stripe webhook

The webhook lives in the Node runtime because it needs the raw request body for signature verification. It is idempotent (Stripe may retry) and writes back through a service-role client that the policies allow because the operation runs server-side without an authenticated user yet.

// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers'
import { stripe } from '@/lib/stripe/server'
import { promoteDraftTenant } from '@/lib/tenants/server'

export const runtime = 'nodejs'

export async function POST(request: Request) {
  const signature = (await headers()).get('stripe-signature')
  if (!signature) return new Response('missing signature', { status: 400 })

  const body = await request.text()
  let event
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    )
  } catch {
    return new Response('invalid signature', { status: 400 })
  }

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object
    await promoteDraftTenant({
      tenantId: session.client_reference_id!,
      stripeCustomerId: session.customer as string,
      stripeSubscriptionId: session.subscription as string,
    })
  }

  return new Response(null, { status: 200 })
}

5. What the client never sees

There is no API endpoint exposing createCheckoutSession. There is no fetch('/api/signup') in any client component. There is no DTO layer translating between three shapes of the same data. The page boundary is the data boundary, the Server Action is the mutation, and row-level security is the only authorisation layer that can be both audited and enforced.

This is what we mean by "production Next.js": the application uses the framework.

Further reading

Frequently asked questions

Tell us what you are building on Next.js

Greenfield SaaS in four to eight weeks.