Stack · Next.js

We build production Next.js applications, not landing pages

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

01

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.

02

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.

03

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).

04

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.

05

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, not around it.

Frequently asked questions

Do you build only the marketing site?

Possible, but rarely a fit. We are fastest when we build the marketing site, the app and the admin surface together because they all live in one repository. If you want only the marketing site, look for a Framer or Webflow partner.

App Router or Pages Router?

App Router for new projects, every time. We migrate Pages Router projects when the upside justifies the work, usually when the team wants Server Actions, streaming or React 19.

Do you use a CSS framework like Tailwind?

No. We ship with a proprietary CSS-first design system of fifty-seven components used across ten production apps. The system is the differentiator; configuring Tailwind for the hundredth time is not.

Vercel or self-hosted?

Vercel by default for the deploy experience, the edge runtime and the preview URLs. Self-hosted on Node where the procurement or compliance environment requires it; the codebase stays portable.

How long does a typical Next.js SaaS take?

Four to eight weeks for a greenfield SaaS with auth, billing, dashboard, admin and onboarding. Faster for a contained module, longer when the business logic is unique.

What about React 19 server components stability?

We have shipped React 19 RC and 19 stable to production across multiple apps since release. Known caveats are documented in our internal playbook and we work around them in code, not by avoiding the runtime.

Can our team take over after handover?

Yes. Any developer with Next.js experience can read the code. Documentation comes from the codebase itself, the design system arrives as a versioned package, and the database types are generated from the live schema. No guided tour required.

Tell us what you are building on Next.js

A scoping call, a concrete number in the first reply, no agency theater. Greenfield SaaS in four to eight weeks.