Stack · Next.js

Sviluppiamo applicazioni Next.js in produzione, non landing page

App Router, React 19, Server Actions, Server Components, edge runtime dove serve davvero. Lo stack che regge dopo la fine del contratto, sul framework che chiunque nel tuo team conosce già.

Perché questo stack

01

App Router come architettura, non come novità

Lavoriamo sull'App Router non perché è nuovo, ma perché trasforma il confine della pagina nel confine dei dati. Il Server Component si occupa del fetch, il client component dell'interazione e la zona di confine smette di essere uno store Redux da mantenere a mano.

02

Server Actions al posto di un livello API

Ogni mutazione interna è una funzione tipizzata che il client chiama direttamente. Niente DTO, niente fetch wrapper lato client, niente API parallela per la stessa operazione. L'API esiste solo dove un terzo deve leggerla.

03

Edge runtime dove la latenza conta

Auth check, rate limiting e personalizzazione girano in edge: il primo byte arriva sotto i cento millisecondi. Il runtime Node resta dove ha senso (webhook Stripe, upload di file, qualunque cosa abbia bisogno della API Node completa).

04

Type safety dall'inizio alla fine

TypeScript strict, tipi del database generati da Supabase, chiavi del dizionario verificate in compilazione. Una colonna rinominata rompe la build, non un cliente che paga.

05

Un ecosistema che il prossimo team conosce già

Assumere su Next.js è una conversazione diversa da assumere su un framework di nicchia. La libreria componenti, i pattern di auth, il flusso di deploy, gli strumenti di debug: lo sviluppatore che apre il tuo codice li conosce già.

Cosa sviluppiamo con questa tecnologia

Autenticazione multi-tenant

Supabase Auth con row-level security, social provider, magic link, permessi role-based applicati lato server.

Billing con Stripe integrato

Checkout, Subscriptions, Customer Portal, prorating, webhook event router, handler idempotenti, retry queue.

Dashboard di amministrazione

Cockpit interno per supporto, rimborsi, migrazioni di piano, audit log. Stesso design system del lato cliente.

Server Actions e form

Ogni form è una Server Action tipizzata con progressive enhancement; validazione client che condivide lo stesso schema del server.

Job in background e code

Ingestione di webhook, elaborazione idempotente, dead-letter logging, task schedulati per billing e reportistica.

Routing multilingua

hreflang e URL localizzate dal primo commit. Tre lingue di default, altre senza riscrittura.

Sito marketing e prodotto in un unico codebase

Un solo repository, un solo deploy, un solo team. La pagina prezzi si indicizza come una pagina statica e la dashboard si apre veloce al primo click.

Content layer tipizzato

MDX con frontmatter o CMS su Supabase, completamente tipizzato, interrogato via Server Components.

Search Console e Plausible già collegati

Sitemap, robots, OpenGraph, JSON-LD schema, validazione hreflang, performance budget tracciato a ogni release.

Migrazione da stack legacy

WordPress, Webflow, Bubble o React 17 / Pages Router. Contenuti spostati, redirect URL rispettati, utenti mantenuti.

Performance budget nel contratto

Obiettivi Core Web Vitals nel capitolato, verificati sotto carico, riportati a ogni rilascio.

Documentazione di passaggio di consegne

Runbook, panoramica di architettura, design system come pacchetto versionato. Il prossimo team riprende il lavoro senza visita guidata.

Una Server Action reale con row-level security

Un form di signup, una chiamata a Stripe Checkout e un insert isolato per tenant in cinque file. Niente livello DTO, niente fetch wrapper, niente ORM in mezzo alla richiesta.

La maggior parte degli articoli su Next.js si ferma al confine della pagina. La parte interessante è cosa succede tra un Server Component, una Server Action e il database che tocca. Ecco la forma che usiamo in produzione per un signup isolato per tenant che chiama Stripe Checkout e scrive su Supabase sotto row-level security.

1. Il route file

Un Server Component legge il tenant corrente dalla sessione e mostra un form che fa POST verso una Server Action tipizzata. Niente client component, per ora: il form funziona anche con JavaScript disabilitato.

// 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>
        Piano
        <select name="plan" required>
          <option value="starter">Starter</option>
          <option value="growth">Growth</option>
        </select>
      </label>
      <button type="submit">Vai al checkout</button>
    </form>
  )
}

2. La Server Action

L'action gira solo sul server. La chiamata Stripe avviene nel runtime Node; il redirect verso l'URL hosted di Stripe è un normale HTTP 303. Niente stato client, niente 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 di row-level security

L'insert del tenant in stato draft gira con la service-role key, perché deve scrivere prima ancora che ci sia un utente. Quando il webhook Stripe collega un utente, ogni lettura successiva è ristretta al tenant autenticato via questa policy. La policy vive nel source control, non nella UI del database.

-- 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. Il webhook Stripe

Il webhook vive nel runtime Node perché gli serve il body raw per verificare la firma. È idempotente (Stripe può ritrasmettere) e scrive tramite un client service-role che le policy permettono perché l'operazione gira lato server senza un utente autenticato.

// 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. Cosa il client non vede

Non esiste un endpoint API che espone createCheckoutSession. Non esiste un fetch('/api/signup') in nessun client component. Non c'è un livello DTO che traduce tra tre forme dello stesso dato. Il confine della pagina è il confine dei dati, la Server Action è la mutazione e row-level security è l'unico livello di autorizzazione che si può sia verificare in audit sia far applicare.

Questo è quello che intendiamo per "Next.js in produzione": l'applicazione usa il framework, non gli gira attorno.

Domande frequenti

Fate solo il sito marketing?

Possibile, ma raramente è il caso giusto. Siamo più veloci quando facciamo sito marketing, applicazione e admin nello stesso repository. Se ti serve solo il sito marketing, cerca un partner Framer o Webflow.

App Router o Pages Router?

App Router per i nuovi progetti, sempre. Migriamo progetti Pages Router quando il guadagno giustifica il lavoro, di solito quando il team vuole Server Actions, streaming o React 19.

Usate un framework CSS tipo Tailwind?

No. Lavoriamo con un design system proprietario CSS-first, cinquantasette componenti su dieci app in produzione. Il sistema è il differenziatore; configurare Tailwind per l'ennesima volta no.

Vercel o self-hosted?

Vercel di default per esperienza di deploy, edge runtime e preview URL. Self-hosted su Node quando l'ambiente di procurement o di compliance lo richiede; il codice resta portabile.

Quanto dura tipicamente una SaaS Next.js?

Quattro-otto settimane per una SaaS greenfield con auth, billing, dashboard, admin e onboarding. Meno per un modulo contenuto, di più quando la logica di business è particolare.

React 19 Server Components è abbastanza stabile?

Abbiamo rilasciato in produzione React 19 RC e 19 stable su più app dal rilascio. I caveat noti sono documentati nel nostro playbook interno e li gestiamo nel codice, non evitando il runtime.

Il nostro team riesce a prendere in mano il lavoro dopo la consegna?

Sì. Qualunque sviluppatore con esperienza Next.js legge il codice. La documentazione nasce dal codebase, il design system arriva come pacchetto versionato e i tipi del database sono generati dallo schema vivo. Niente visita guidata.

Raccontaci cosa stai mettendo in piedi su Next.js

Una call di scoping, un numero concreto nella prima risposta, niente recite da agenzia. SaaS greenfield in quattro-otto settimane.