Server Actions: Stop Double Charges (2026)

Yatish Goel
Co-Founder & CTO
If you have a Next.js app with Server Actions and payments, you probably have a scary bug hiding in plain sight: the same action can run twice. Not in theory. In production. Under normal user behavior.
It shows up as double-charged cards, duplicate rows, two emails, two tickets, or a user screaming on a Sunday. And the worst part is that your logs look clean because each request is valid.
In 2026, this is extra common because Server Actions make it so easy to ship a form, wire it to an action, and call Stripe or an LLM right inside it. The abstraction is nice. The failure modes are not.
The bug: Server Actions can be invoked twice
You see it when:
- A user double-clicks the submit button.
- Mobile Safari resubmits after a network hiccup.
- Your UI retries because it did not get a response fast enough.
- The browser replays a POST after a redirect or back/forward weirdness.
- You deploy on Vercel and a function cold start makes users click again.
Server Actions are just HTTP requests. They are not magic. If your action does something that costs money, you need the same safety rails you would put on a public API endpoint.
Rate limiting is not enough
Most blog posts stop at rate limiting. That helps with spam. It does not fix duplicate submissions from a normal user. Rate limiting tells you 'stop after N requests', but you still might allow 2 requests in a burst, and that is enough to charge twice.
You need two layers:
1) Rate limit to control abuse and cost.
2) Idempotency so the same intent is processed once, even if the request happens twice.
The pattern that actually works (2026)
Here is the pattern we use when rescuing early-stage apps that bolted payments onto Server Actions:
- Client generates an idempotency key per submit attempt (one user intent).
- Server Action enforces a per-user or per-IP rate limit.
- Server Action takes a lock in Redis for that idempotency key.
- If the lock exists, return the previous result (or a friendly 'already processing' state).
- Only after the lock is acquired do you call Stripe, your DB write, or your LLM.
This turns 'double submit' into 'one write, one charge'.
Step 1: Create a real idempotency key
This key should represent the user intent, not the request. It must be stable across retries. Two easy ways:
- Generate a UUID on the client when the user first hits submit, keep it in component state, and send it to the action.
- Or generate it server-side once, store it in a hidden input, and submit it with the form.
Do not use timestamps. Do not use random per request. That defeats the whole point.
Client example (App Router)
In your client component, generate once: const [idemKey] = useState(() => crypto.randomUUID())
Then pass it into the action as a normal argument or a hidden form field.
Step 2: Identify the caller inside the action
If the user is logged in, rate limit by user id. If not, rate limit by IP.
In Server Actions you do not get a Request object, but you can read headers. Next.js documents headers() as async now (Next.js 15+).
Grab x-forwarded-for and take the first IP. It is not perfect, but it is what you have in a Server Action.
Step 3: Rate limit with Redis (Upstash is fine)
Upstash's @upstash/ratelimit works well because it is serverless-friendly and has fixed window, sliding window, and token bucket limiters.
For most apps, fixed window is fine and cheap. Use sliding window for smoother limits if you are dealing with bursty endpoints like AI chat.
Real numbers that work for paid actions:
- Payment intent create: 3 per minute per user
- Passwordless login email: 5 per 10 minutes per IP
- AI 'Generate' button: 10 per hour per user (or you will burn cash)
Step 4: Add an idempotency lock
This is the missing piece in most tutorials. Rate limit blocks spam. The lock blocks duplicate intent.
You want a single atomic operation: set-if-not-exists with a TTL. In Redis this is SET key value NX EX <ttl>.
TTL matters. If your action crashes, you do not want the lock forever. Typical TTLs:
- 60 seconds for fast actions
- 5 minutes if you call Stripe and then do heavy work
What you store in the lock
Store a small JSON blob with status: processing, done with result, failed with a safe error.
That way, the second request can return the exact same response.
Step 5: Stripe-specific tip: use Stripe idempotency too
If you call Stripe from the action, also pass Stripe's Idempotency-Key header. That covers the Stripe side even if your own lock fails.
If you do both, you get a nice belt-and-suspenders setup. I like boring systems.
A complete Server Action sketch
This is not copy-paste production code, but it is close. The point is the flow.
// Pseudocode
export async function createCheckout(idemKey, planId) {
const user = await auth();
// 1) Rate limit
const identifier = user?.id ?? getIpFromHeaders();
const { success } = await ratelimit.limit(`checkout:${identifier}`);
if (!success) return { error: "Too many attempts. Try again in a bit." };
// 2) Idempotency lock
const lockKey = `idem:checkout:${identifier}:${idemKey}`;
const gotLock = await redis.set(
lockKey,
JSON.stringify({ status: "processing" }),
{ nx: true, ex: 300 }
);
if (!gotLock) {
const existing = await redis.get(lockKey);
if (existing) {
const parsed = JSON.parse(existing);
if (parsed.status === "done") return parsed.result;
}
return { error: "Already processing. Give it a second and refresh." };
}
try {
// 3) Do the expensive thing
const session = await stripe.checkout.sessions.create(
{ /* ... */ },
{ idempotencyKey: `${identifier}:${idemKey}` }
);
const result = { ok: true, url: session.url };
await redis.set(lockKey, JSON.stringify({ status: "done", result }), { ex: 300 });
return result;
} catch (e) {
await redis.set(lockKey, JSON.stringify({ status: "failed" }), { ex: 60 });
return { error: "Checkout failed. If you were charged, contact support." };
}
}
Common mistakes we see in rescues
1) Rate limiting on IP only
If you are a B2B app behind corporate NAT, IP-based rate limits can punish a whole office. Prefer user-id when you can.
2) Treating a 429 as the whole solution
429 is good hygiene. It does not make your action retry-safe.
3) Locking on just idemKey
Scope it. Include user id or IP in the key so one user cannot block another user's action by guessing an idempotency value.
4) No TTL
No TTL means one crash can brick the endpoint forever for that key. Always set a TTL.
5) Not returning the same result
If the second call gets 'already processing' and the first call succeeded, the user still might retry. If you can return the original result, do it.
Costs and timelines (realistic numbers)
If you already have a Next.js app with Server Actions and Stripe, adding these safety rails is not a multi-week project. Typical effort for a US or UK-based team:
- 2 to 4 hours: add idempotency key flow on one critical form
- 4 to 8 hours: wire Redis, rate limiter, and lock pattern
- 4 to 10 hours: add tests and log traces so you can prove it works
Expect $800 to $3,500 if you outsource it, depending on how messy the code is and whether payments are already tangled with UI state.
Infra cost is usually tiny. Upstash Redis starts low, and rate limiting calls are cheap compared to one duplicate Stripe charge plus the support headache.
A blunt opinion
If your Server Action can charge money or create paid credits, it is an API. Treat it like one.
Server Actions make it easy to ship. They also make it easy to ship foot-guns. The fix is not fancy. It is boring guardrails and a little paranoia.
Checklist: ship this before you scale
- Client-generated idempotency key per submit
- Rate limit by user id (fallback to IP)
- Redis lock SET NX with TTL
- Cache final result and return it on repeats
- Stripe idempotency header too
- Logs: include identifier + idemKey in every line
How we explain this to founders (without blaming users)
Founders hate hearing 'users double-click'. It sounds like you are blaming customers. I frame it differently: the internet is flaky, browsers retry, and humans click again when nothing happens for 2 seconds. That is normal. Your backend must be calm under that kind of pressure.
If you are in the US or UK and you are selling anything paid, your support cost is not just the refund. It is the time, the chargeback risk, and the trust hit. The first time someone sees two charges, they assume fraud, not 'oops'.
Testing it (quick, not academic)
Do two tests before you ship:
1) Fire the same action twice at the same time with the same idempotency key. You should get one Stripe session, one DB row, one everything.
2) Fire it twice with different keys. Both should work. If the second fails, your lock scope is wrong.
If you have Playwright already, add a test that clicks the submit button twice fast and asserts one success path. It catches regressions when someone refactors the form later.
If you can only fix one thing tonight
Add Stripe idempotency on create calls and stop there. It is not perfect, but it will prevent the worst case: charging twice. Then come back and add the Redis lock so you stop duplicating internal state too.
---
Related reading

Yatish Goel
Co-Founder & CTO
Full-stack architect with US startup experience and an IIT Kanpur degree. Yatish drives the technical vision at HeyDev, designing robust architectures and leading development across web, mobile, and AI projects.
