Docs/Tutorial
// tutorial
Fare una chiamata API
Una chiamata API in ZeroToShip ha sempre due lati: un route handler sotto app/api/ che fa il lavoro sul server, e un client component che lo chiama tramite libs/api.ts. Nel repo c'è già un esempio completo che gira: components/ButtonLead.tsx parla con app/api/lead/route.ts. Lo smontiamo pezzo per pezzo.
1. Il route handler
Un file route.ts dentro app/api/qualcosa/ espone l'endpoint /api/qualcosa. Esporti una funzione con il nome del metodo HTTP — POST, GET, DELETE — e restituisci un NextResponse. Questo è app/api/lead/route.ts, quasi per intero:
import { NextResponse, NextRequest } from "next/server";
// This route is used to store the leads that are generated from the landing page.
// The API call is initiated by <ButtonLead /> component
export async function POST(req: NextRequest) {
const body = await req.json();
if (!body.email) {
return NextResponse.json({ error: "Email is required" }, { status: 400 });
}
try {
// Qui dentro metti la tua logica:
// - salvare il lead su Supabase
// - mandare una email di benvenuto con sendEmail() di libs/resend.ts
return NextResponse.json({});
} catch (e) {
console.error(e);
return NextResponse.json({ error: e.message }, { status: 500 });
}
}Tre cose da notare, perché sono il pattern che ripeterai ovunque: await req.json() per leggere il body, un check di validazione che ritorna 400 con una chiave error, e un try/catch che ritorna 500 con la stessa chiave. Il perché di quella chiave lo vedi al passo 4.
2. Il client axios
libs/api.ts esporta di default un'istanza axios già configurata. Due dettagli cambiano il modo in cui la usi:
baseURL: "/api"— quindi chiami"/lead", non"/api/lead".- l'interceptor di risposta fa
return response.data— quindi ricevi direttamente il JSON, non l'oggetto axios. Nienteres.data.
import axios from "axios";
import { toast } from "react-hot-toast";
import { redirect } from "next/navigation";
import config from "@/config";
const apiClient = axios.create({
baseURL: "/api",
});
apiClient.interceptors.response.use(
function (response) {
return response.data;
},
function (error) {
let message = "";
if (error.response?.status === 401) {
// User not auth, ask to re login
toast.error("Please login");
redirect(config.auth.loginUrl);
} else if (error.response?.status === 403) {
// User not authorized, must subscribe/purchase/pick a plan
message = "Pick a plan to use this feature";
} else {
message =
error?.response?.data?.error || error.message || error.toString();
}
error.message =
typeof message === "string" ? message : JSON.stringify(message);
console.error(error.message);
// Automatically display errors to the user
if (error.message) {
toast.error(error.message);
} else {
toast.error("something went wrong...");
}
return Promise.reject(error);
}
);
export default apiClient;Gli errori sono già gestiti per te. Un 401 mostra un toast e sbatte l'utente su config.auth.loginUrl. Un 403 diventa il messaggio “Pick a plan to use this feature”. Tutto il resto prende il testo da error.response.data.error e lo mostra in un toast. Poi la promise viene comunque rigettata, quindi il tuo try/catch scatta lo stesso.
3. Chiamarla da un componente
components/ButtonLead.tsx è un client component (ha "use client" in cima, perché usa useState e gestisce un submit). La chiamata è una riga:
"use client";
import React, { useState } from "react";
import { toast } from "react-hot-toast";
import apiClient from "@/libs/api";
const ButtonLead = () => {
const [email, setEmail] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(false);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e?.preventDefault();
setIsLoading(true);
try {
await apiClient.post("/lead", { email });
toast.success("Grazie! Sei nella waitlist.");
setEmail("");
} catch (error) {
// l'errore l'ha già mostrato l'interceptor: qui lo logghi e basta
console.log(error);
} finally {
setIsLoading(false);
}
};
// ...form + bottone con disabled={isLoading}
};
export default ButtonLead;Il catch vuoto non è pigrizia: il messaggio l'ha già mostrato l'interceptor. Quello che serve davvero è il finally, che spegne lo spinner sia in caso di successo che di errore.
4. Il formato dell'errore conta
L'interceptor legge error.response.data.error. Se il tuo route handler risponde con una chiave diversa, l'utente si becca un generico “Request failed with status code 400” invece del messaggio che avevi scritto. Sii coerente:
// Bene: il toast mostra "Email non valida"
return NextResponse.json({ error: "Email non valida" }, { status: 400 });
// Male: il toast mostra "Request failed with status code 400"
return NextResponse.json({ message: "Email non valida" }, { status: 400 });5. Passare parametri in GET
Per una GET non c'è un body: leggi la query string da req.url, e lato client passi params ad axios.
import { NextResponse, NextRequest } from "next/server";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const q = searchParams.get("q");
if (!q) {
return NextResponse.json({ error: "Manca il parametro q" }, { status: 400 });
}
return NextResponse.json({ risultati: [] });
}const { risultati } = await apiClient.get("/ricerca", {
params: { q: "next.js" },
});6. Proteggere l'endpoint
Un route handler è pubblico finché non lo chiudi tu. Se l'endpoint tocca dati di un utente, recupera la sessione Supabase e rispondi 401 se non c'è nessuno. Su Next 15 createClient() va sempre atteso con await:
import { NextResponse, NextRequest } from "next/server";
import { createClient } from "@/libs/supabase/server";
export async function POST(req: NextRequest) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json();
// qui hai user.id, puoi scrivere sul database in sicurezza
return NextResponse.json({ ok: true });
}Quel 401 si incastra con l'interceptor del passo 2: se la sessione è scaduta mentre l'utente era sulla pagina, viene rimandato al login da solo. Per il resto dell'auth vedi Login utenti.
7. Riempire /api/lead
L'endpoint dei lead oggi valida l'email e basta — la parte interessante è commentata. Due cose sensate da metterci: salvare il lead su una tabella Supabase (vedi Database) e mandare una email di benvenuto (vedi Email).
import { createClient } from "@/libs/supabase/server";
import { sendEmail } from "@/libs/resend";
// dentro il try:
const supabase = await createClient();
await supabase.from("leads").insert({ email: body.email });
await sendEmail({
to: body.email,
subject: "Sei dentro",
text: "Ti scrivo appena apriamo. — Alberto",
html: "<p>Ti scrivo appena apriamo.</p><p>— Alberto</p>",
});La tabella leads non esiste finché non la crei tu su Supabase, con le sue policy RLS. Se la salti, l'insert fallisce e finisce nel catch — cioè un 500 e un toast rosso, non un crash.