The Problem: Your API Keys Are Probably Leaking
Last month, I traced a production incident back to a Next.js Server Action that accidentally bundled our Stripe secret key into the client JavaScript. The root cause? A seemingly innocent use client directive in the wrong file.
Next.js 15 Server Actions are powerful, but the boundary between server and client code is subtle. One misplaced export and your secrets ride straight into the browser bundle.
Here's what a vulnerable pattern looks like:
// ❌ DANGEROUS: Defined in a Client Component file
'use client'
import { stripe } from '@/lib/stripe' // Secret key imported!
export async function processPayment(amount: number) {
'use server'
return await stripe.charges.create({ amount, currency: 'usd' })
}
Even though the function has 'use server', it's defined in a client module. Next.js will serialize this function reference, but the import graph taints the entire file. Your secret key ends up in the client bundle.
Deliverable 1: Secure Server Action with Zod + Session Guard
Here's the correct architecture:
// app/actions/payment.ts (NO 'use client' anywhere)
'use server'
import { z } from 'zod'
import { auth } from '@/lib/auth'
import { stripe } from '@/lib/stripe'
const PaymentSchema = z.object({
amount: z.number().positive().max(999999),
currency: z.enum(['usd', 'eur']),
idempotencyKey: z.string().uuid(),
})
export async function processPayment(rawInput: unknown) {
// 1. Session verification FIRST
const session = await auth()
if (!session?.user) {
return { error: 'Unauthorized' }
}
// 2. Input validation with Zod
const parsed = PaymentSchema.safeParse(rawInput)
if (!parsed.success) {
return { error: 'Invalid input', details: parsed.error.flatten() }
}
// 3. Safe execution
try {
const charge = await stripe.charges.create({
amount: parsed.data.amount,
currency: parsed.data.currency,
customer: session.user.stripeId,
}, {
idempotencyKey: parsed.data.idempotencyKey,
})
return { success: true, chargeId: charge.id }
} catch (err) {
console.error('Payment failed:', err)
return { error: 'Payment processing failed' }
}
}
Why this works:
- No client directive: The entire file stays server-only.
- Zod validation: Catches malformed inputs before they hit Stripe.
- Session guard: Prevents anonymous abuse.
- Idempotency: Protects against duplicate charges on network retries.
- Typed errors: The client gets structured error objects, not raw exceptions.
Client usage:
// app/components/CheckoutButton.tsx
'use client'
import { processPayment } from '@/app/actions/payment'
import { useTransition } from 'react'
export function CheckoutButton({ amount }: { amount: number }) {
const [isPending, startTransition] = useTransition()
const handleClick = () => {
startTransition(async () => {
const result = await processPayment({
amount,
currency: 'usd',
idempotencyKey: crypto.randomUUID(),
})
if (result.error) {
alert(result.error)
}
})
}
return (
<button onClick={handleClick} disabled={isPending}>
{isPending ? 'Processing...' : 'Pay Now'}
</button>
)
}
Deliverable 2: React Server Component with Granular Suspense
The second performance trap I see constantly: waterfalling data fetches that tank your Largest Contentful Paint (LCP).
Anti-pattern:
// ❌ Serial waterfall
async function Page() {
const user = await fetchUser()
const posts = await fetchPosts(user.id) // Waits for user
const comments = await fetchComments(posts[0].id) // Waits for posts
return <div>...</div>
}
Every await blocks rendering. Your LCP suffers.
Correct pattern with Suspense streaming:
// app/dashboard/page.tsx
import { Suspense } from 'react'
async function UserProfile({ userId }: { userId: string }) {
const user = await fetchUser(userId)
return <div>{user.name}</div>
}
async function PostsList({ userId }: { userId: string }) {
const posts = await fetchPosts(userId)
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}
export default function DashboardPage({ params }: { params: { userId: string } }) {
return (
<div>
<Suspense fallback={<div className="skeleton h-12 w-48" />}>
<UserProfile userId={params.userId} />
</Suspense>
<Suspense fallback={<div className="skeleton h-64" />}>
<PostsList userId={params.userId} />
</Suspense>
</div>
)
}
Next.js 15 will:
- Stream the shell HTML immediately.
- Start both
fetchUserandfetchPostsin parallel. - Replace each Suspense boundary as data arrives.
Result: LCP improves by 40-60% because the skeleton UI paints instantly.
War Story: The Bundle Audit That Saved Us
After the Stripe key leak, I added a CI check:
# .github/workflows/bundle-audit.yml
- run: npm run build
- run: |
if grep -r "sk_live_" .next/static/chunks; then
echo "Secret key found in client bundle!"
exit 1
fi
Caught three more leaks before they hit production.
Takeaways
- Never define Server Actions in
'use client'files. - Validate inputs with Zod before touching external APIs.
- Guard every action with session checks.
- Use granular Suspense to parallelize data fetching and improve LCP.
- Audit your bundles in CI to catch secret leaks.
Next.js 15 gives you the primitives. It's on you to wire them correctly.
Top comments (3)
the CI bundle audit is the bit worth copying. a grep for sk_live_ in .next/static/chunks after every build costs nothing, so there is no excuse. the use-server-in-a-client-file footgun is documented everywhere by now, but almost nobody checks what actually lands in the bundle.
100% on the zero-cost point. Linters and AST plugins catch obvious syntax patterns, but the moment a build tool inlines an environment variable or flattens a closure into a client chunk, static analysis is blind.
One thing we learned the hard way in our release pipeline: don't just grep for known prefixes like
sk_live_. We ended up adding a generalized Shannon entropy check alongside regex patterns for base64/hex tokens over 32 characters in the chunk audit step. It caught an inlined custom LLM gateway token that would have completely bypassed standard vendor prefix checks.the entropy check is the real upgrade here. prefix lists are a known-enemy game, they lose the moment someone names a token anything creative. catching your own gateway token that way is the kind of finding that makes the whole audit worth it.