Ship a SaaS in a weekend with Cursor + Supabase: the stack, the code, and the 'don't skip this' checklist
Disclosure: this post is from the MailValid team. MailValid appears in one checklist item, and that section shows real API results, including what it can't tell you. You can get a working SaaS skeleton (sign up, pay, tr
Disclosure: this post is from the MailValid team. MailValid appears in one checklist item, and that section shows real API results, including what it can't tell you.
You can get a working SaaS skeleton (sign up, pay, track usage) out of Cursor in an afternoon. The hard part is the code that doesn't show up in a demo: the security and email-deliverability pieces that only matter once strangers start using your product.
This post gives you the whole stack as code you can run. Every file and command here is the one I built and tested. Package versions are pinned, and the "What I tested" section at the end says exactly what ran live and what only compiled.
The stack
| Job | Tool | Version tested |
|---|---|---|
| App + API | Next.js (App Router) | 15.5.28 |
| Auth + database | Supabase (@supabase/ssr, @supabase/supabase-js) |
0.12.7 / 2.118.0 |
| Payments | Stripe Checkout + webhooks (stripe) |
23.0.0 |
| Analytics | PostHog (posthog-js) |
1.438.15 |
| Runtime | Node.js | 22.23.4 |
Cursor works well for writing this kind of boilerplate. The tip that saved me the most time is at the end: paste the checklist into your prompt up front.
1. Create the project
npx create-next-app@15 my-saas --ts --app --tailwind --eslint --no-src-dir --import-alias "@/*" --use-npm --no-turbopack --yes
cd my-saas
npm i @supabase/supabase-js @supabase/ssr stripe posthog-js server-only
Create .env.local:
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRICE_ID=
APP_URL=http://localhost:3000
MAILVALID_API_KEY=
NEXT_PUBLIC_POSTHOG_KEY=
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
The Supabase values are under Project Settings → API. The Stripe values come from your test-mode dashboard. For STRIPE_PRICE_ID, create a recurring price under Product catalog.
2. Database schema
Run this in the Supabase SQL editor. It creates a profiles table, locks it down with row-level security (RLS), and adds a trigger that creates a profile row for every new user. Without that trigger, the Stripe webhook below has no row to update.
create table public.profiles (
id uuid primary key references auth.users on delete cascade,
email text not null,
plan text not null default 'free',
stripe_customer_id text,
created_at timestamptz default now()
);
alter table public.profiles enable row level security;
create policy "users read own profile"
on public.profiles for select
using (auth.uid() = id);
-- Create a profile row for every new auth user
create function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
insert into public.profiles (id, email) values (new.id, new.email);
return new;
end;
$$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
3. Supabase clients
A server client that reads the user's session from cookies:
// lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (toSet) => {
try {
toSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options));
} catch {
// called from a Server Component; safe to ignore if middleware refreshes sessions
}
},
},
}
);
}
An admin client for the webhook. It uses the service role key, which bypasses RLS, so import "server-only" makes the build fail if client code ever imports it:
// lib/supabase/admin.ts
import "server-only";
import { createClient } from "@supabase/supabase-js";
// Service role key bypasses RLS. Server only.
export function createAdminClient() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } }
);
}
4. Signup, with the email check built in
The signup is a server action, so you control what happens before an account is created. The verifyEmail call is checklist item #1; its code and results are below.
// app/signup/actions.ts
"use server";
import { createClient } from "@/lib/supabase/server";
import { verifyEmail } from "@/lib/verify-email";
export type SignUpState = { error?: string; ok?: boolean };
export async function signUp(_prev: SignUpState, formData: FormData): Promise<SignUpState> {
const email = String(formData.get("email")).trim().toLowerCase();
const password = String(formData.get("password"));
const check = await verifyEmail(email);
if (!check.allow) return { error: check.reason };
const supabase = await createClient();
const { error } = await supabase.auth.signUp({ email, password });
if (error) return { error: error.message };
return { ok: true };
}
// app/signup/page.tsx
"use client";
import { useActionState } from "react";
import { signUp, type SignUpState } from "./actions";
export default function SignUpPage() {
const [state, action, pending] = useActionState<SignUpState, FormData>(signUp, {});
if (state.ok) return <p className="p-8">Check your inbox to confirm your email.</p>;
return (
<form action={action} className="mx-auto mt-16 flex max-w-sm flex-col gap-3 p-4">
<input name="email" type="email" required placeholder="[email protected]" className="border p-2" />
<input name="password" type="password" required minLength={8} placeholder="Password" className="border p-2" />
<button disabled={pending} className="bg-black p-2 text-white">
{pending ? "Creating account..." : "Sign up"}
</button>
{state.error && <p className="text-red-600">{state.error}</p>}
</form>
);
}
5. Stripe Checkout
This route takes the user from their Supabase session, not from the request body. A version that accepts userId in the body (which AI tools often generate) lets anyone start a checkout tied to someone else's account.
// app/api/checkout/route.ts
import Stripe from "stripe";
import { createClient } from "@/lib/supabase/server";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST() {
// Take the user from the session, never from the request body.
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return new Response("Unauthorized", { status: 401 });
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: process.env.STRIPE_PRICE_ID!, quantity: 1 }],
customer_email: user.email,
client_reference_id: user.id,
success_url: `${process.env.APP_URL}/dashboard?paid=1`,
cancel_url: `${process.env.APP_URL}/pricing`,
});
return Response.json({ url: session.url });
}
6. Stripe webhook
This upgrades the user's plan once payment succeeds. It verifies the signature against the raw body, so a fake "payment succeeded" POST gets rejected.
// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
import { createAdminClient } from "@/lib/supabase/admin";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text(); // raw body, not req.json()
const sig = req.headers.get("stripe-signature");
if (!sig) return new Response("Missing signature", { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response("Bad signature", { status: 400 });
}
if (event.type === "checkout.session.completed") {
const s = event.data.object;
// Setting a value (not incrementing) makes this safe if Stripe redelivers the event.
await createAdminClient()
.from("profiles")
.update({ plan: "pro", stripe_customer_id: s.customer as string })
.eq("id", s.client_reference_id!);
}
return new Response("ok");
}
To test it locally, the Stripe CLI forwards events and prints the whsec_... secret to put in .env.local:
stripe listen --forward-to localhost:3000/api/stripe/webhook
7. PostHog
// app/providers.tsx
"use client";
import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react";
import { useEffect } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
useEffect(() => {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
});
}, []);
return <PostHogProvider client={posthog}>{children}</PostHogProvider>;
}
In app/layout.tsx, import it and wrap the page: <body ...><Providers>{children}</Providers></body>. Then track the events that make up your funnel, for example posthog.capture("checkout_started") in your pricing page's button handler.
The "don't skip this" checklist
1. Verify signup emails before creating the account
Your signup form will receive typos, disposable addresses and addresses on domains that don't exist. Each one makes Supabase send a confirmation email that bounces, and bounces count against your sending domain's reputation. Google's sender guidelines ask senders to keep their lists clean for this reason. A regex only catches broken syntax. To check whether a domain accepts mail and the mailbox exists, you need DNS and SMTP checks.
Here's the check I used with the MailValid email verification API:
// lib/verify-email.ts
type Check = { allow: boolean; flag?: boolean; reason?: string };
export async function verifyEmail(email: string): Promise<Check> {
try {
const res = await fetch("https://mailvalid.io/api/v1/verify/single", {
method: "POST",
headers: {
"X-API-Key": process.env.MAILVALID_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(3000),
});
if (!res.ok) return { allow: true, flag: true }; // fail open
const { result } = await res.json();
if (result.status === "invalid" || result.is_disposable) {
return { allow: false, reason: "We can't send email to that address. Check for typos, or use a different one." };
}
return { allow: true, flag: result.status !== "valid" }; // catch_all, unknown: allow, but watch
} catch {
return { allow: true, flag: true }; // timeout or network error: never block signup on a dependency
}
}
These are the real API results from my test run, and what the function did with each:
| Address |
status / status_reason
|
Result |
|---|---|---|
[email protected] |
invalid / domain_does_not_exist
|
Blocked |
[email protected] |
do_not_mail / disposable
|
Blocked |
[email protected] (typo of gmail.com) |
do_not_mail / disposable
|
Blocked |
[email protected] (made-up mailbox) |
unknown / smtp_unverifiable_provider
|
Allowed, flagged |
| Any address, with a wrong API key | (request fails) | Allowed, flagged |
Two things worth knowing from those results:
-
Some providers won't confirm whether a mailbox exists. A made-up Gmail address came back
unknown, notinvalid, so this check won't block it. That's why the function allowsunknownand flags it instead of blocking it, and why you still need Supabase's confirmation email to prove that a real person owns the address. - It fails open. With a wrong key, or if the API is down, signups still go through. Your signup flow should never depend on a third-party service being up.
The typo domain was blocked because MailValid classifies gmial.com as disposable. That's why the error message asks users to check for typos rather than accusing them of using a throwaway address.
2. Turn on RLS for every table
Your anon key is in the frontend bundle, and Supabase exposes tables through an API that accepts it. Without RLS, anyone with the key can read every row. The Security Advisor flags any public table without RLS (rls_disabled_in_public).
3. Keep the service role key server-side
Never put it in a NEXT_PUBLIC_ variable. The import "server-only" line in lib/supabase/admin.ts makes the build fail if client code imports the admin client.
4. Verify webhook signatures
See step 6. Without the check, anyone who knows your endpoint URL can give themselves a free upgrade.
5. Make webhook handlers safe to repeat
Stripe can deliver the same event more than once. Setting plan = 'pro' is safe to repeat; incrementing a counter isn't.
6. Set up custom SMTP before launch
This one surprises people. Supabase's built-in email server isn't meant for production: without custom SMTP, it only delivers to addresses on your project's team and has a low hourly rate limit. Real users won't get their confirmation emails. Configure an SMTP provider under Authentication → SMTP Settings, on a domain with SPF, DKIM and DMARC set up.
7. Protect signup from bots
Supabase supports CAPTCHA with hCaptcha or Cloudflare Turnstile. Turn it on before you share your signup link publicly.
What I tested
So you know which parts I actually ran:
-
Built from scratch: the project above, following only these commands and files, on Node 22.23.4.
next buildpassed, including type checking and lint. -
Run live:
verifyEmailagainst the real MailValid API, with the results in the table above, plus the wrong-key case. -
Run locally with
next start:- The webhook with no
stripe-signatureheader returned400 Missing signature. - The webhook with a fake signature returned
400 Bad signature. - The checkout route without a logged-in user returned
401 Unauthorized. -
/signupreturned200.
- The webhook with no
-
Not run live: creating a real Supabase user, a real Stripe payment and the PostHog events. Those pieces are written against each library's current API and compile, but you should test them with your own test-mode keys. For Stripe,
stripe listenplusstripe trigger checkout.session.completedis the quickest way.
The prompt tip
Paste the checklist into Cursor before asking it to build. "Use RLS on every table, take the user from the session in API routes, verify Stripe signatures on the raw body, fail open on third-party calls" gets you most of this on the first pass. Asking up front is much easier than auditing for missing pieces afterwards.
If you want to try the signup check, a free MailValid account includes 100 credits, and the API docs have examples in Python, JavaScript and PHP.
What's on your "don't skip this" list? Tell me in the comments.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.