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 (0)