Maila z formularza wysyłasz przez e-mail API tak, że przeglądarka wysyła dane na Twój serwer, a dopiero serwer wywołuje API (np. Resend) z tajnym kluczem. Nie robi się tego bezpośrednio z JavaScriptu na stronie, bo klucz API byłby publiczny, a samo API Resend nie zwraca nagłówków CORS wymaganych przez przeglądarkę. Samo wywołanie to kilka linii kodu. Najwięcej problemów sprawiają rzeczy dookoła: weryfikacja domeny nadawcy, obsługa błędów i ochrona formularza przed spamem.
Samo wywołanie e-mail API to kilka linii kodu. Najwięcej problemów sprawiają rzeczy dookoła: weryfikacja domeny nadawcy, obsługa błędów i ochrona formularza przed spamem. Poniżej przejdziemy przez cały proces, z przykładami w Next.js i FastAPI.
Czym jest e-mail API i po co go używać zamiast własnego serwera pocztowego?
E-mail API to usługa, której mówisz przez HTTP: „wyślij tę wiadomość z tego adresu na tamten adres”. Resztą zajmuje się dostawca: kolejką, ponawianiem, kontaktem z serwerami odbiorców i reputacją nadawcy. Ty wysyłasz jedno żądanie POST z JSON-em.
Własny serwer SMTP wydaje się prostszy, dopóki nie okaże się, że Twoje wiadomości lądują w spamie. Gmail czy Outlook sprawdzają, czy domena nadawcy ma poprawne rekordy SPF i DKIM, i czy serwer, z którego wychodzi poczta, ma dobrą reputację. Dostawcy e-mail API (Resend, SendGrid, Postmark i inni) robią to za Ciebie.
| Rozwiązanie | Zalety | Wady |
|---|---|---|
| Własny serwer SMTP | pełna kontrola | reputacja IP, SPF/DKIM na Twojej głowie, ryzyko spamu |
| Konto pocztowe przez SMTP (np. Gmail) | działa w 5 minut | limity dzienne, hasła aplikacji, nie na produkcję |
| E-mail API (Resend i podobne) | prosty POST, weryfikacja domeny, statystyki dostarczenia | zewnętrzna usługa, limity planu |
W tym artykule używamy Resend, bo ma prosty interfejs i darmowy plan na start. Zasady z kolejnych sekcji (klucz po stronie serwera, weryfikacja domeny, obsługa błędów) są takie same u każdego dostawcy.
Czy mail z formularza można wysłać bezpośrednio z przeglądarki?
Nie, i to z dwóch niezależnych powodów.
Po pierwsze: klucz API. Wywołanie API Resend wymaga nagłówka Authorization: Bearer re_.... Jeśli wpiszesz klucz do kodu JavaScript na stronie, zobaczy go każdy, kto otworzy zakładkę Sieć w narzędziach deweloperskich. Dokumentacja Resend mówi wprost, że klucze API są tajnymi tokenami i muszą pozostać poufne. Ktoś, kto wejdzie w posiadanie klucza, może wysyłać maile z Twojej domeny i zużyć Twój limit albo, co gorsza, zrujnować reputację domeny.
Po drugie: CORS. Przeglądarka przed wysłaniem żądania na obcy adres z nagłówkiem Authorization wysyła zapytanie wstępne (preflight, metoda OPTIONS) i sprawdza, czy serwer odpowiada nagłówkami Access-Control-Allow-*. Sprawdziliśmy to na żywo: zapytanie preflight na https://api.resend.com/emails z obcym Origin zwróciło 401 i żadnego z tych nagłówków. W takiej sytuacji przeglądarka zablokuje żądanie, zanim zdąży wyjść. Jeśli chcesz zrozumieć mechanizm, przeczytaj artykuł o CORS, SOP i nagłówkach HTTP.
Ten test pokazuje brak nagłówków CORS w odpowiedzi dla żądania bez ważnego klucza. Nawet gdyby z kluczem działało, pierwszy powód zostaje: nie wolno wystawiać klucza w przeglądarce.
Dlatego schemat zawsze wygląda tak:
- Użytkownik wypełnia formularz, a przeglądarka wysyła dane na Twój endpoint (np.
/api/contact). - Twój serwer waliduje dane i wywołuje API Resend, używając klucza ze zmiennej środowiskowej.
- Serwer odpowiada przeglądarce: „wysłano” albo błąd.
Pomysł „wyślę mail z frontendu, bo nie mam backendu” prowadzi do jednego z dwóch rozwiązań: funkcja serverless (np. na Vercel czy Netlify) albo gotowy serwis formularzy, który wysyła maile w Twoim imieniu. Własny, mały endpoint daje najwięcej kontroli i uczy najwięcej.
Jak skonfigurować Resend: klucz API i domena nadawcy?
Zanim napiszesz kod, w panelu Resend:
- Utwórz klucz API. Zaczyna się od
re_. Panel pokazuje go tylko raz, więc zapisz go od razu w pliku.env, nigdy w repozytorium. Dobrze jest mieć osobny klucz dla każdej części aplikacji i ograniczyć klucz do jednej domeny, bo wtedy wyciek ma mniejsze skutki. - Dodaj i zweryfikuj domenę. Resend poda rekordy DNS (SPF, DKIM), które dodajesz u swojego dostawcy domeny. Dopiero po weryfikacji możesz wysyłać z adresu typu
kontakt@twojadomena.pl. - Do testów możesz użyć nadawcy
onboarding@resend.devi adresów testowychdelivered@resend.dev,bounced@resend.dev,complained@resend.devisuppressed@resend.dev. Nadawcaonboarding@resend.devsłuży tylko do prób, na produkcji potrzebujesz własnej domeny. Maile testowe też liczą się do limitu planu.
# plik .env (dodany do .gitignore)
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxx
CONTACT_FROM="Moja strona <kontakt@twojadomena.pl>"
CONTACT_TO=ty@twojadomena.pl
Plan darmowy obejmuje 3 000 maili miesięcznie, do 100 dziennie i 3 domeny (stan na 19 października 2026). Do formularza kontaktowego na start to aż nadto, ale sprawdź cennik przed wdrożeniem, bo limity się zmieniają.
Jak wysłać maila z formularza w Next.js?
W Next.js (App Router) endpoint to plik route.ts w katalogu app. Funkcja POST dostaje standardowy obiekt Request i zwraca Response. Klucz API czytasz ze zmiennej środowiskowej, która nigdy nie trafia do przeglądarki. Zależności:
npm install resend
Plik app/api/contact/route.ts:
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
export async function POST(request: Request) {
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return Response.json({ ok: false, error: "Nieprawidłowe dane." }, { status: 400 });
}
// pułapka na boty: człowiek nie widzi tego pola, więc zostawia je puste
if (String(body.website ?? "").trim()) {
return Response.json({ ok: true });
}
const name = String(body.name ?? "").trim();
const email = String(body.email ?? "").trim();
const message = String(body.message ?? "").trim();
if (!name || !email.includes("@") || !message || message.length > 5000) {
return Response.json({ ok: false, error: "Uzupełnij poprawnie wszystkie pola." }, { status: 400 });
}
const { data, error } = await resend.emails.send({
from: process.env.CONTACT_FROM!,
to: process.env.CONTACT_TO!,
replyTo: email,
subject: `Wiadomość z formularza od ${name}`,
text: `Od: ${name} <${email}>\n\n${message}`,
html: `<p><strong>Od:</strong> ${escapeHtml(name)} (${escapeHtml(email)})</p>
<p>${escapeHtml(message).replaceAll("\n", "<br>")}</p>`,
});
if (error) {
console.error("Resend error:", error);
return Response.json({ ok: false, error: "Nie udało się wysłać wiadomości." }, { status: 502 });
}
return Response.json({ ok: true, id: data?.id });
}
Formularz to komponent kliencki, który woła Twój endpoint, a nie Resend:
"use client";
import { FormEvent, useState } from "react";
type Status = "idle" | "sending" | "sent" | "error";
export function ContactForm() {
const [status, setStatus] = useState<Status>("idle");
const [info, setInfo] = useState("");
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
setStatus("sending");
try {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(Object.fromEntries(new FormData(form))),
});
const result = await res.json();
if (!res.ok || !result.ok) {
setStatus("error");
setInfo(result.error ?? "Coś poszło nie tak.");
return;
}
form.reset();
setStatus("sent");
setInfo("Dziękuję, wiadomość wysłana!");
} catch {
setStatus("error");
setInfo("Brak połączenia. Spróbuj ponownie.");
}
}
return (
<form onSubmit={handleSubmit}>
<input name="name" placeholder="Imię" required />
<input name="email" type="email" placeholder="E-mail" required />
<textarea name="message" placeholder="Wiadomość" required />
{/* honeypot */}
<input name="website" tabIndex={-1} autoComplete="off" style={{ display: "none" }} />
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Wysyłam…" : "Wyślij"}
</button>
<p role="status">{info}</p>
</form>
);
}
Zamiast route handlera możesz użyć Server Action: wtedy formularz woła funkcję z "use server", a kod z resend.emails.send jest ten sam. Route handler ma tę zaletę, że ten sam endpoint może wołać też aplikacja mobilna lub inny frontend. Jeśli dopiero uczysz się tego stosu, zobacz nasz kurs JavaScript, w którym budujesz takie połączenia frontendu z backendem.
Jak wysłać maila z formularza w FastAPI?
W Pythonie robisz to samo: frontend (dowolny, nawet zwykły HTML) wysyła JSON na Twoje API, a API wołą Resend. Instalacja:
pip install fastapi uvicorn resend "pydantic[email]"
Dodatek pydantic[email] jest potrzebny do typu EmailStr, który sprawdza, czy pole wygląda jak adres e-mail. Plik main.py:
import logging
import os
from html import escape
import resend
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, EmailStr, Field
from resend.exceptions import ResendError
logger = logging.getLogger("contact")
resend.api_key = os.environ["RESEND_API_KEY"]
app = FastAPI()
# potrzebne tylko wtedy, gdy formularz stoi na INNEJ domenie niż API
app.add_middleware(
CORSMiddleware,
allow_origins=["https://twojadomena.pl"],
allow_methods=["POST"],
allow_headers=["Content-Type"],
)
class ContactForm(BaseModel):
name: str = Field(min_length=1, max_length=100)
email: EmailStr
message: str = Field(min_length=1, max_length=5000)
website: str = "" # honeypot: człowiek zostawia puste
@app.post("/api/contact")
def send_contact(form: ContactForm) -> dict:
if form.website.strip():
return {"ok": True}
safe_message = escape(form.message).replace("\n", "<br>")
params: resend.Emails.SendParams = {
"from": os.environ["CONTACT_FROM"],
"to": [os.environ["CONTACT_TO"]],
"reply_to": form.email,
"subject": f"Wiadomość z formularza od {form.name}",
"text": f"Od: {form.name} <{form.email}>\n\n{form.message}",
"html": f"<p><strong>Od:</strong> {escape(form.name)} ({escape(form.email)})</p><p>{safe_message}</p>",
}
try:
email = resend.Emails.send(params)
except ResendError as error:
logger.error("Resend error: %s", error)
raise HTTPException(status_code=502, detail="Nie udało się wysłać wiadomości.")
return {"ok": True, "id": email["id"]}
Uruchom serwer poleceniem uvicorn main:app --reload i wyślij żądanie z przeglądarki:
const res = await fetch("https://api.twojadomena.pl/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Anna", email: "anna@example.com", message: "Cześć!" }),
});
console.log(res.status, await res.json());
Kilka rzeczy wartych uwagi:
- Walidację robi Pydantic. Jeśli pole
emailnie wygląda jak adres, FastAPI samo odpowie błędem422, zanim Twój kod w ogóle ruszy. Jak to działa, opisujemy w artykule o FastAPI i Pydantic. - Endpoint jest zwykłym
def, nieasync def. Funkcjaresend.Emails.sendjest blokująca, więc FastAPI uruchomi ją w puli wątków i nie zablokuje reszty serwera. Wasync defużyjawait resend.Emails.send_async(params)(wymaga pakietuhttpx). Różnicę wyjaśniamy w tekście o pierwszym API we FastAPI. - CORS dodajesz tylko wtedy, gdy formularz i API są na różnych domenach. Jeśli frontend jest w Next.js, a API we FastAPI, wymień w
allow_originsdokładną domenę frontendu, a nie"*". - Klucz API zostaje w zmiennej środowiskowej na serwerze. Przeglądarka widzi tylko Twój endpoint.
Chcesz nauczyć się budować takie API od podstaw? Zajrzyj do naszego kursu Python.
Dlaczego wysyłka maila nie działa mimo „sukcesu” i jak obsłużyć błąd?
To jedna z najczęstszych pułapek, a SDK różnych języków zachowują się tu odwrotnie:
| SDK | Co robi przy błędzie API | Co musisz zrobić |
|---|---|---|
Node.js / Next.js (resend) | nie rzuca wyjątku, zwraca { data, error } | zawsze sprawdź error |
Python / FastAPI (resend) | rzuca wyjątek ResendError (np. ValidationError, RateLimitError, InvalidApiKeyError) | zawsze łap wyjątek w try/except |
W Node.js, jeśli nie sprawdzisz error, formularz pokaże „Wysłano!”, a mail nigdy nie wyjdzie. W Pythonie niełapany wyjątek zamieni się w niejasny błąd 500. W obu przypadkach zasada jest ta sama: zaloguj błąd po stronie serwera i odpowiedz przeglądarce kodem 502, nie 200. Użytkownik dostaje uczciwy komunikat, a Ty ślad w logach.
Najczęstsze przyczyny błędu na początku:
| Objaw | Prawdopodobna przyczyna | Co zrobić |
|---|---|---|
| Błąd o domenie lub nadawcy | from używa domeny, której nie zweryfikowałeś | Zweryfikuj domenę w panelu albo na czas testu użyj onboarding@resend.dev |
401 / „Missing API Key” | brak klucza lub zła zmienna środowiskowa | Sprawdź RESEND_API_KEY w środowisku, w którym działa serwer |
429 | ponad 10 żądań na sekundę na zespół | Zwolnij lub ustaw kolejkę |
403 przy bezpośrednim fetch | brak nagłówka User-Agent | Dodaj nagłówek (SDK robi to samo za Ciebie) |
Jeśli wolisz bez SDK, wystarczy zwykłe żądanie POST na https://api.resend.com/emails z nagłówkami Authorization i User-Agent oraz tym samym JSON-em (pola from, to, subject, html, text, reply_to). Pamiętaj tylko o sprawdzeniu statusu odpowiedzi, bo fetch nie rzuca wyjątku przy statusach 4xx i 5xx.
Jak zabezpieczyć formularz przed spamem i nadużyciem?
Publiczny endpoint, który wysyła maile, to cel dla botów. Każde wywołanie to mail na Twój koszt i z Twojej domeny, więc zabezpiecz go zanim wystawisz na świat:
- Honeypot. Ukryte pole (
websitepowyżej), które człowiek zostawia puste, a bot wypełnia. Jeśli przyjdzie wypełnione, endpoint odpowiada „ok” i niczego nie wysyła. - Limit żądań. Na przykład 5 prób na 10 minut z jednego adresu IP. Bez tego jeden skrypt zużyje Twój miesięczny limit w kilka minut.
- Minimalny czas wypełnienia. Człowiek nie wyśle formularza w mniej niż kilka sekund od załadowania strony. Przy wczytaniu formularza zapisz znacznik czasu i odrzucaj zgłoszenia przysłane od razu.
- Sztywny odbiorca. Adres
tojest w konfiguracji serwera, nie w danych z formularza. Inaczej Twój endpoint zamienia się w darmowy serwer do rozsyłania spamu. - Idempotentność. Jeśli użytkownik kliknie „Wyślij” dwa razy, możesz przekazać nagłówek
Idempotency-Key, żeby Resend nie wysłał dwóch takich samych wiadomości. Klucz jest ważny 24 godziny.
Co dalej, gdy wysyłka zadziała?
Gdy podstawowy formularz działa, kolejne kroki to:
- Potwierdzenie dla nadawcy. Drugi mail na adres z formularza („dostaliśmy Twoją wiadomość”). Uważaj na limit i na to, żeby nie dało się go użyć do wysyłania spamu na cudze adresy.
- Szablony. Zamiast sklejać HTML w ciągu znaków, możesz użyć szablonu (SDK przyjmuje np. komponent React).
- Webhooki. Resend może powiadomić Twoją aplikację, że mail został dostarczony, odrzucony albo oznaczony jako spam. To przydaje się, gdy wiadomości są ważne (np. potwierdzenie rezerwacji).
- Załączniki. Parametr
attachmentsprzyjmuje zawartość zakodowaną w Base64, a limit to 40 MB na mail. - Mail testowy w testach automatycznych. W testach nie wysyłaj prawdziwej poczty. Zamień wywołanie API na atrapę, tak jak w artykule o testowaniu kodu z API LLM (przykład jest w Pythonie, ale zasada ta sama).
Jeśli budujesz projekt do portfolio, formularz kontaktowy z działającą wysyłką, walidacją po stronie serwera i ochroną przed spamem to dobry dowód, że rozumiesz, jak frontend rozmawia z backendem. A jeśli chcesz, żeby ktoś doświadczony obejrzał Twój kod i podpowiedział, co poprawić, zajrzyj do mentoringu programistycznego.
Uwaga mentora: Jednym z momentów, w których formularz przestaje działać, jest przeniesienie go z
localhostna serwer. Zmienne środowiskowe są ustawione tylko na Twoim komputerze, domena nadawcy nie jest jeszcze zweryfikowana, a w ustawieniach CORS brakuje adresu produkcyjnego. Przed każdym wdrożeniem sprawdź te trzy rzeczy: klucz, domenę i dozwolony origin. Jak działa mechanizm CORS i skąd biorą się błędy z nim związane, wyjaśniamy w artykule o CORS, SOP i nagłówkach HTTP.
Najczęstsze pytania
Czy Resend jest darmowy?
Resend ma plan darmowy: 3 000 maili miesięcznie, do 100 dziennie i 3 domeny (stan na 19 października 2026). Płatny plan Pro kosztuje 20 USD miesięcznie i obejmuje 50 000 maili. Aktualne warunki sprawdź na stronie cennika.
Czy mogę wysyłać maile z formularza bez backendu?
Z samego frontendu nie wyślesz maila przez Resend bezpiecznie, bo klucz API musiałby trafić do przeglądarki. Potrzebujesz małego serwera lub funkcji serverless, która ukryje klucz. Alternatywą są gotowe serwisy formularzy, które wysyłają maila w Twoim imieniu.
Z jakiego adresu mogę wysyłać?
Z adresu na domenie, którą zweryfikujesz w panelu Resend (dodając rekordy SPF i DKIM). Do prób służy onboarding@resend.dev, ale tylko do testów.
Jaki jest limit wysyłki w Resend?
Domyślnie 10 żądań na sekundę na zespół, po przekroczeniu API zwraca błąd 429. Dodatkowo obowiązują limity planu, np. 100 maili dziennie w planie darmowym.
Czym Resend różni się od Nodemailera?
Nodemailer to biblioteka wysyłająca pocztę przez Twój własny serwer SMTP, więc to Ty odpowiadasz za SPF, DKIM i reputację. Resend to usługa, do której wysyłasz żądanie HTTP, a ona dostarcza wiadomość.

