Stack · Next.js

Construimos aplicaciones Next.js en producción, no landing pages

App Router, React 19, Server Actions, Server Components, edge runtime donde aporta. El stack que aguanta cuando termina el contrato, sobre el framework que cualquiera de tu equipo ya conoce.

Por qué este stack

01

App Router como arquitectura, no como novedad

Trabajamos sobre App Router no porque sea nuevo, sino porque convierte el borde de la página en el borde de los datos. El Server Component se ocupa del fetch, el client component de la interacción, y la zona intermedia deja de ser un store Redux que mantener a mano.

02

Server Actions en lugar de una capa API

Cada mutación interna es una función tipada que el client llama directamente. Sin DTO, sin fetch wrapper en el client, sin superficie API paralela para la misma operación. La API existe solo donde un tercero tiene que leerla.

03

Edge runtime donde la latencia importa

Auth check, rate limiting y personalización corren en edge: el primer byte llega por debajo de los cien milisegundos. El runtime Node se queda donde tiene sentido (webhooks de Stripe, subida de archivos, cualquier cosa que necesite la API Node completa).

04

Type safety de principio a fin

TypeScript strict, tipos de la base de datos generados desde Supabase, claves del diccionario verificadas en compilación. Una columna renombrada rompe la build, no un cliente que paga.

05

Un ecosistema que el siguiente equipo ya conoce

Contratar sobre Next.js es una conversación distinta a contratar sobre un framework de nicho. La librería de componentes, los patrones de auth, la historia de despliegue, las herramientas de debug: la persona que abre tu código ya las usaba antes.

Qué construimos con esta tecnología

Autenticación multi-tenant

Supabase Auth con row-level security, social providers, magic links, permisos role-based aplicados en servidor.

Billing con Stripe integrado

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

Dashboard de administración

Cockpit interno para soporte, reembolsos, migraciones de plan, audit log. Mismo design system que la superficie de cliente.

Server Actions y formularios

Cada formulario una Server Action tipada con progressive enhancement; validación en client que comparte el mismo schema que el servidor.

Jobs en background y colas

Ingesta de webhooks, procesamiento idempotente, dead-letter logging, tareas programadas para billing y reporting.

Routing multilenguaje

hreflang y URLs localizadas desde el primer commit. Tres idiomas por defecto, más sin reescribir.

Sitio marketing y producto en un solo código

Un solo repositorio, un solo despliegue, un solo equipo. La página de precios se posiciona como una página estática y el dashboard abre rápido al primer click.

Content layer tipada

MDX con frontmatter o CMS sobre Supabase, completamente tipada, consultada vía Server Components.

Search Console y Plausible conectados

Sitemap, robots, OpenGraph, JSON-LD schema, validación hreflang, performance budget seguido en cada release.

Migración desde stacks legacy

WordPress, Webflow, Bubble o React 17 / Pages Router. Contenido movido, redirects respetados, usuarios llevados al producto nuevo.

Performance budget en el contrato

Objetivos Core Web Vitals dentro del pliego, verificados bajo carga, reportados en cada release.

Documentación de traspaso

Runbooks, vista general de arquitectura, design system como paquete versionado. El siguiente equipo recoge el trabajo sin visita guiada.

Una Server Action real con row-level security

Un formulario de signup, una llamada a Stripe Checkout y un insert aislado por tenant en cinco archivos. Sin capa DTO, sin fetch wrapper, sin ORM en mitad del request.

La mayoría de los artículos sobre Next.js se quedan en el borde de la página. La parte interesante es qué pasa entre un Server Component, una Server Action y la base de datos que tocan. Esta es la forma que usamos en producción para un signup aislado por tenant que llama a Stripe Checkout y escribe sobre Supabase bajo row-level security.

1. El route file

Un Server Component lee el tenant actual desde la sesión y muestra un formulario que hace POST a una Server Action tipada. Sin client component, por ahora: el formulario funciona incluso con JavaScript deshabilitado.

// 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">Ir al checkout</button>
    </form>
  )
}

2. La Server Action

La action corre solo en el servidor. La llamada a Stripe ocurre en el runtime Node; el redirect al URL hosted de Stripe es un HTTP 303 normal. Sin estado en client, sin 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. La policy de row-level security

El insert del tenant en estado draft corre con la service-role key, porque tiene que escribir antes incluso de que exista un usuario. Cuando el webhook de Stripe vincula un usuario, cada lectura siguiente queda acotada al tenant autenticado vía esta policy. La policy vive en source control, no en la UI de la base de datos.

-- 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. El webhook de Stripe

El webhook vive en el runtime Node porque necesita el body raw para verificar la firma. Es idempotente (Stripe puede reintentar) y escribe a través de un client service-role que las policies permiten porque la operación corre en servidor sin un usuario autenticado.

// 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. Lo que el client nunca ve

No existe un endpoint API que exponga createCheckoutSession. No hay un fetch('/api/signup') en ningún client component. No hay una capa DTO que traduce entre tres formas del mismo dato. El borde de la página es el borde de los datos, la Server Action es la mutación, y row-level security es la única capa de autorización que se puede auditar y aplicar al mismo tiempo.

Esto es lo que entendemos por "Next.js en producción": la aplicación usa el framework, no gira alrededor de él.

Preguntas frecuentes

¿Construyen solo el sitio de marketing?

Posible, pero raro como encaje. Somos más rápidos cuando construimos marketing, aplicación y admin dentro del mismo repositorio. Si solo necesitas el sitio de marketing, busca un partner Framer o Webflow.

¿App Router o Pages Router?

App Router para los proyectos nuevos, siempre. Migramos proyectos Pages Router cuando la ganancia justifica el trabajo, normalmente cuando el equipo quiere Server Actions, streaming o React 19.

¿Usan algún framework CSS tipo Tailwind?

No. Trabajamos con un design system propio CSS-first, cincuenta y siete componentes usados en diez apps en producción. El sistema es el diferenciador; configurar Tailwind por enésima vez no lo es.

¿Vercel o self-hosted?

Vercel por defecto por la experiencia de despliegue, el edge runtime y los preview URL. Self-hosted sobre Node cuando el entorno de procurement o compliance lo exige; el código se mantiene portable.

¿Cuánto dura un SaaS Next.js típico?

Cuatro u ocho semanas para un SaaS greenfield con auth, billing, dashboard, admin y onboarding. Menos para un módulo acotado, más cuando la lógica de negocio es particular.

¿React 19 Server Components es suficientemente estable?

Hemos lanzado en producción React 19 RC y 19 stable en varias apps desde su release. Los caveats conocidos están documentados en nuestro playbook interno y los manejamos en el código, no evitando el runtime.

¿Nuestro equipo puede recoger el trabajo tras la entrega?

Sí. Cualquier persona desarrolladora con experiencia en Next.js lee el código. La documentación nace del codebase, el design system llega como paquete versionado y los tipos de base de datos están generados a partir del schema vivo. Sin visita guiada.

Cuéntanos qué estás construyendo sobre Next.js

Una llamada de scoping, un número concreto en la primera respuesta, sin teatro de agencia. SaaS greenfield en cuatro u ocho semanas.