איך לבנות מערכת ניוזלטר פנימית עם Vercel + Supabase + Resend (מדריך מלא 2026)
במקום לשלם מאות שקלים ל-Mailchimp — בונים מערכת ניוזלטר משלכם ששייכת לכם לגמרי: Resend לשליחה, Supabase לאחסון, Vercel לאירוח ולתזמון. שלב אחר שלב, עם הקוד.
הידעת?
מערכת הניוזלטר הזו רצה בלי שרת ייעודי ובלי אפילו טבלת SQL אחת: Resend מאחסן את רשימת הנמענים, Supabase Storage שומר את הקמפיינים והאירועים, ו-Vercel Cron שולח אוטומטית כל שבוע. עלות התחלתית: 0 ₪.
בניית מערכת ניוזלטר פנימית עם Vercel + Supabase + Resend מאפשרת לכם לשלוח מיילים, לעקוב אחרי פתיחות וקליקים, ולנהל מנויים — בלי לשלם מאות שקלים בחודש ובלי לוותר על הבעלות על הנתונים. המדריך הזה מראה איך בונים את זה שלב אחר שלב, עם קוד אמיתי, בדיוק כמו המערכת שמריצה את הניוזלטר של tzedek.me.
בקצרה (TL;DR)
- הסטאק: Resend (שליחה + רשימת נמענים) · Supabase (אחסון קמפיינים ואירועים) · Vercel (אירוח Next.js + תזמון).
- בלי מסד נתונים מורכב: הנמענים נשמרים ב-Resend Audience, והקמפיינים/אירועים כקבצי JSON ב-Supabase Storage.
- מה תקבלו: טופס הרשמה, מייל ברוכים-הבאים, שליחת מהדורות, מעקב פתיחות/קליקים, דשבורד אדמין ותזמון אוטומטי.
- עלות התחלתית: 0 ₪ — הכל על שכבות החינם.
מה בדיוק נבנה? (הארכיטקטורה)
המערכת מורכבת משלושה שירותים, שכל אחד עושה דבר אחד היטב:
| רכיב | תפקיד |
|---|---|
| Resend | שולח את המיילים, מאחסן את רשימת הנמענים (Audience), ושולח webhooks על כל אירוע |
| Supabase | מאחסן קמפיינים ואירועי פתיחה/קליק (כ-JSON ב-Storage) — ובהמשך גם לידים ומשתמשים |
| Vercel | מארח את אפליקציית ה-Next.js, מריץ את ה-API, ומפעיל את התזמון (Cron) |
התובנה המרכזית: לא צריך טבלת SQL אחת כדי להתחיל. Resend הוא כבר ה"מסד נתונים" של הנמענים, ו-Supabase Storage מספיק כדי לשמור קמפיינים ואירועים כקבצים. זה מקצר את ההקמה מימים לשעות.
שלב 0 — דרישות מקדימות
לפני שמתחילים צריך שלושה חשבונות (כולם עם שכבת חינם) ופרויקט Next.js:
- חשבון Vercel + פרויקט Next.js (App Router).
- חשבון Supabase (פרויקט חדש).
- חשבון Resend + דומיין משלכם לשליחה (קריטי למסירוּת — נסביר בשלב 2).
מתקינים את ה-SDK של Resend:
npm install resend zod
שלב 1 — משתני סביבה (Environment Variables)
כל המפתחות נשמרים כמשתני סביבה ב-Vercel (Settings → Environment Variables) וב-.env.local לפיתוח מקומי:
# .env.local
RESEND_API_KEY=re_xxxxxxxxxxxx
RESEND_AUDIENCE_ID=xxxxxxxx-xxxx-xxxx # ה-ID של רשימת הנמענים ב-Resend
RESEND_WEBHOOK_SECRET=whsec_xxxxxxxx # לאימות ה-webhooks (שלב 6)
LEAD_FROM_EMAIL="השם שלך <news@yourdomain.com>"
NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJxxxx # מפתח שרת בלבד — לעולם לא בצד הלקוח
ב-Resend יוצרים Audience אחד (Audiences → Create) ומעתיקים את ה-ID שלו. זו רשימת הנמענים שלכם.
שלב 2 — אימות הדומיין ב-Resend (אל תדלגו על זה)
אם תשלחו מדומיין לא מאומת, המיילים יגיעו לספאם. זה השלב הכי חשוב למסירוּת. ב-Resend → Domains → Add Domain, מוסיפים את הדומיין שלכם, ו-Resend נותן לכם רשומות DNS להוסיף אצל ספק הדומיין:
- SPF — מאשרת ש-Resend רשאי לשלוח בשמכם.
- DKIM — חותמת דיגיטלית שמוכיחה שהמייל לא זויף.
- DMARC (מומלץ) — מדיניות שאומרת לשרתים מה לעשות עם מייל חשוד.
אחרי שה-DNS מתעדכן (עד 48 שעות), הדומיין מסומן "Verified" ואפשר לשלוח ממנו. השתמשו בכתובת תת-דומיין כמו news@yourdomain.com.
שלב 3 — טופס הרשמה + הוספת מנוי
עכשיו בונים את הלב של המערכת: כשמישהו נרשם, מוסיפים אותו ל-Resend Audience. תחילה פונקציית עזר שמוסיפה מנוי (אידמפוטנטית — הוספה כפולה לא שוברת):
// lib/newsletter.ts
import { Resend } from "resend";
const AUDIENCE_ID = process.env.RESEND_AUDIENCE_ID!;
export async function addSubscriber(email: string) {
const resend = new Resend(process.env.RESEND_API_KEY);
const { error } = await resend.contacts.create({
audienceId: AUDIENCE_ID,
email,
unsubscribed: false,
});
// מנוי שכבר קיים → מתייחסים כהצלחה
if (error && !/exist/i.test(error.message)) {
return { ok: false, reason: error.message };
}
return { ok: true };
}
עכשיו Server Action שמקבל את הטופס, מאמת אימייל, ומוסיף את המנוי. שימו לב ל-honeypot (שדה נסתר שבוטים ממלאים ובני אדם לא) — הגנת ספאם פשוטה וחינמית:
// app/actions/newsletter.ts
"use server";
import { z } from "zod";
import { addSubscriber } from "@/lib/newsletter";
const schema = z.object({ email: z.string().trim().email() });
export async function subscribeToNewsletter(_prev: unknown, formData: FormData) {
// Honeypot — בוטים ממלאים שדות נסתרים
if ((formData.get("company_website") as string)?.length) {
return { ok: true, message: "תודה!" };
}
const parsed = schema.safeParse({ email: formData.get("email") });
if (!parsed.success) return { ok: false, message: "נא להזין אימייל תקין" };
const res = await addSubscriber(parsed.data.email);
if (!res.ok) return { ok: false, message: "ההרשמה נכשלה, נסו שוב." };
return { ok: true, message: "נרשמת בהצלחה! 🎉" };
}
ורכיב הטופס (Client Component) שמשתמש ב-useActionState:
// components/NewsletterForm.tsx
"use client";
import { useActionState } from "react";
import { subscribeToNewsletter } from "@/app/actions/newsletter";
export function NewsletterForm() {
const [state, action, pending] = useActionState(subscribeToNewsletter, { ok: false, message: "" });
return (
<form action={action}>
{/* honeypot — נסתר מבני אדם */}
<input type="text" name="company_website" tabIndex={-1} className="hidden" aria-hidden />
<input type="email" name="email" required placeholder="האימייל שלך" dir="ltr" />
<button disabled={pending}>{pending ? "נרשם…" : "הרשמה"}</button>
{state.message && <p>{state.message}</p>}
</form>
);
}
שלב 4 — מייל ברוכים הבאים
מיד אחרי ההרשמה, שולחים מייל אישור — זה מעלה אמון ומסירוּת. מוסיפים את זה בסוף ה-Server Action (כ-best-effort, שלא יחסום את ההרשמה אם נכשל):
import { Resend } from "resend";
try {
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.emails.send({
from: process.env.LEAD_FROM_EMAIL!,
to: email,
subject: "ברוך הבא לניוזלטר! 🎉",
html: `<div dir="rtl" style="font-family:Arial;max-width:560px;margin:auto">
<h2>נרשמת בהצלחה!</h2>
<p>כל שבוע תקבל/י עדכונים, טיפים ותוכן חדש. נתראה במייל הראשון.</p>
</div>`,
});
} catch { /* לא חוסם את ההרשמה */ }
שלב 5 — שליחת ניוזלטר לכל הרשימה
כדי לשלוח מהדורה, שולפים את כל הנמענים הפעילים מ-Resend ושולחים. Resend מאפשר שליחה ב-batch (עד 100 מיילים בקריאה), אז מחלקים לקבוצות:
// lib/send-newsletter.ts
import { Resend } from "resend";
export async function sendNewsletter(subject: string, html: string) {
const resend = new Resend(process.env.RESEND_API_KEY);
// 1. שולפים את כל הנמענים הפעילים
const { data } = await resend.contacts.list({ audienceId: process.env.RESEND_AUDIENCE_ID! });
const emails = (data?.data ?? []).filter((c) => !c.unsubscribed).map((c) => c.email);
// 2. שולחים בקבוצות של 100 (מגבלת ה-batch של Resend)
for (let i = 0; i < emails.length; i += 100) {
const chunk = emails.slice(i, i + 100);
await resend.batch.send(
chunk.map((to) => ({
from: process.env.LEAD_FROM_EMAIL!,
to,
subject,
html,
headers: { "List-Unsubscribe": "<mailto:unsub@yourdomain.com>" }, // חובה למסירוּת
})),
);
}
return emails.length;
}
טיפ מסירוּת: הוסיפו תמיד כותרת
List-Unsubscribe— Gmail ו-Outlook דורשים אותה לשולחים בכמות, והיא מקטינה דרמטית את הסיכוי להגיע לספאם.
שלב 6 — מעקב פתיחות וקליקים (Resend Webhooks)
כאן נכנס הקסם. Resend שולח webhook לכל אירוע (delivered, opened, clicked, bounced). אנחנו קולטים אותו, מאמתים את החתימה (Resend חותם עם Svix), ושומרים את האירוע ב-Supabase Storage.
ב-Resend → Webhooks, מוסיפים endpoint שמצביע ל-https://yourdomain.com/api/webhooks/resend ומעתיקים את ה-Signing Secret ל-RESEND_WEBHOOK_SECRET.
ה-route handler שמאמת ושומר:
// app/api/webhooks/resend/route.ts
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
import { appendEmailEvent } from "@/lib/events";
export const dynamic = "force-dynamic";
// Resend חותם עם Svix — מאמתים HMAC-SHA256
function verify(secret: string, req: NextRequest, body: string): boolean {
const id = req.headers.get("svix-id");
const ts = req.headers.get("svix-timestamp");
const sig = req.headers.get("svix-signature");
if (!id || !ts || !sig) return false;
const key = Buffer.from(secret.split("_")[1] || "", "base64");
const expected = crypto.createHmac("sha256", key).update(`${id}.${ts}.${body}`).digest("base64");
return sig.split(" ").map((s) => s.split(",")[1]).some((p) => p === expected);
}
const TYPES: Record<string, string> = {
"email.delivered": "delivered",
"email.opened": "opened",
"email.clicked": "clicked",
"email.bounced": "bounced",
};
export async function POST(req: NextRequest) {
const body = await req.text();
const secret = process.env.RESEND_WEBHOOK_SECRET;
if (secret && !verify(secret, req, body)) {
return NextResponse.json({ ok: false }, { status: 401 });
}
const payload = JSON.parse(body);
const type = TYPES[payload.type];
const email = payload.data?.to?.[0] || "";
if (type && email) {
await appendEmailEvent({ type, email, at: payload.created_at });
}
return NextResponse.json({ ok: true });
}
ושמירת האירוע ל-Supabase Storage (קובץ JSON — בלי SQL):
// lib/events.ts
import { createClient } from "@supabase/supabase-js";
const sb = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
export async function appendEmailEvent(ev: { type: string; email: string; at?: string }) {
const path = "events/email-events.json";
// קוראים את האירועים הקיימים, מוסיפים, ושומרים
const { data } = await sb.storage.from("newsletter").download(path);
const events = data ? JSON.parse(await data.text()) : [];
events.push({ ...ev, at: ev.at || new Date().toISOString() });
await sb.storage.from("newsletter").upload(path, JSON.stringify(events), {
contentType: "application/json",
upsert: true,
});
}
למה Supabase Storage ולא טבלה? כי לאירועים אין צורך בשאילתות מורכבות — רק לצבור ולהציג. קובץ JSON אחד פשוט יותר להקים, ואפשר לעבור ל-SQL מאוחר יותר אם הנפח גדל. (ב-Supabase יוצרים bucket בשם
newsletterב-Storage.)
שלב 7 — דשבורד אדמין (סטטיסטיקות)
עכשיו בונים עמוד אדמין שמציג את המספרים. שולפים את הנמענים מ-Resend ואת האירועים מ-Supabase, ומחשבים:
// חישוב שיעורי פתיחה/קליק מתוך האירועים
const delivered = events.filter((e) => e.type === "delivered").length;
const opened = events.filter((e) => e.type === "opened").length;
const clicked = events.filter((e) => e.type === "clicked").length;
const openRate = delivered ? Math.round((opened / delivered) * 100) : 0;
const clickRate = delivered ? Math.round((clicked / delivered) * 100) : 0;
מציגים בכרטיסים: סך מנויים, נרשמים השבוע, שיעור פתיחה, שיעור קליק. את העמוד מגנים מאחורי אימות אדמין (cookie חתום) כדי שלא יהיה ציבורי.
שלב 8 — תזמון אוטומטי (Vercel Cron)
הצעד האחרון: לשלוח את המהדורה אוטומטית כל שבוע. Vercel Cron קורא ל-route בלוח זמנים שמגדירים ב-vercel.json:
{
"crons": [
{ "path": "/api/cron/newsletter", "schedule": "0 7 * * 4" }
]
}
(0 7 * * 4 = כל יום חמישי בשעה 7:00 UTC.) וה-route שמופעל:
// app/api/cron/newsletter/route.ts
import { NextResponse } from "next/server";
import { sendNewsletter } from "@/lib/send-newsletter";
export async function GET(req: Request) {
// Vercel שולח כותרת אימות — מוודאים שזה באמת ה-Cron
if (req.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ ok: false }, { status: 401 });
}
const count = await sendNewsletter("המהדורה השבועית 🤖", "<h1>שלום!</h1>...");
return NextResponse.json({ ok: true, sent: count });
}
מגדירים CRON_SECRET ב-Vercel, ו-Vercel שולח אותו אוטומטית בכל הרצה — כך אף אחד לא יכול להפעיל את השליחה ידנית.
שלב 9 — מסירוּת ושיטות עבודה מומלצות
המערכת עובדת — עכשיו מוודאים שהמיילים מגיעים ל-Inbox ולא לספאם:
- דומיין מאומת (SPF + DKIM + DMARC) — כיסינו בשלב 2, זה הבסיס.
- List-Unsubscribe header בכל מייל — חובה לשולחים בכמות.
- חימום הדרגתי (warm-up) — אל תשלחו 10,000 מיילים ביום הראשון; הגדילו בהדרגה.
- ניקוי רשימה — הסירו כתובות שעושות bounce, כדי לשמור על מוניטין שולח טוב.
- תוכן אמיתי — נמנעים מ"מילות ספאם", מאזנים טקסט ותמונות, ובודקים ב-spam-checker לפני שליחה.
שורה תחתונה
עם Vercel, Supabase ו-Resend בונים מערכת ניוזלטר מלאה — הרשמה, שליחה, מעקב ותזמון — שהיא לגמרי שלכם: הנתונים, הקוד והבעלות. מתחילים בחינם, ומשדרגים רק כשהרשימה גדלה. את כל הקוד אפשר לכתוב מהר עם Claude Code, בדיוק כמו שאנחנו בנינו את המערכת שמריצה את הניוזלטר של האתר הזה.
רוצים שנבנה לכם מערכת שיווק אוטומטית מותאמת לעסק? זה בדיוק מה שאנחנו עושים.
להמשך: מה זה Claude Code · אוטומציות AI לעסקים · סוכן Claude שאוסף חשבוניות · קורס Claude Code בחינם · כלי AI מומלצים