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