How to Deploy v0 Apps Securely (2026)

v0-generated Next.js apps have a consistent pre-deploy security problem: the code works perfectly in development but leaks secrets the moment you push to production. In our scans of Next.js apps built with v0, the most common issue is NEXT_PUBLIC_-prefixed API keys baked into the client bundle. Every visitor to the deployed site can read those keys from the page source. The fix is straightforward, but you have to do it before you deploy, not after.

This guide covers the six steps to go from a v0 project to a production-ready Next.js app without the common leaks.

TL;DR

v0 apps deploy to Vercel and often include NEXT_PUBLIC_-prefixed secrets that end up in your JavaScript bundle. Before launching: (1) audit for the NEXT_PUBLIC_ prefix on any non-public value, (2) move secret API calls to Route Handlers or Server Actions, (3) store secrets in Vercel env vars without a public prefix, (4) add security headers, (5) enable Deployment Protection on previews, and (6) scan the live URL. Takes about 30 minutes on a fresh v0 project.

Step 1: Audit NEXT_PUBLIC_ Prefixes

This is the highest-ROI step. Run one command before you do anything else.

1

Find every NEXT_PUBLIC_ variable in your codebase:

# In your v0 project root
grep -r "NEXT_PUBLIC_" . \
  --include="*.ts" --include="*.tsx" --include="*.js" --include="*.env*" \
  --exclude-dir=node_modules --exclude-dir=.next

Any variable named NEXT_PUBLIC_OPENAI_API_KEY, NEXT_PUBLIC_STRIPE_SECRET_KEY, NEXT_PUBLIC_ANTHROPIC_API_KEY, or NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY is a leak. The NEXT_PUBLIC_ prefix is Next.js's way of embedding values in the client bundle at build time. It is the right approach for truly public values like your Supabase anon key or a public analytics ID. It is the wrong approach for secrets.

Categories of what you will find:

  • Definitely a leak: NEXT_PUBLIC_OPENAI_API_KEY, NEXT_PUBLIC_STRIPE_SECRET_KEY, NEXT_PUBLIC_ANTHROPIC_API_KEY, database connection strings
  • Probably fine: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY (anon key is designed to be public, controlled by RLS)
  • Depends: Feature flag values, public config. Ask yourself: should any visitor be able to read this value?

For each secret you find, you will fix it in Steps 2 and 3.

2

Verify what is actually in your deployed bundle. Even without running Step 1, you can confirm a leak on your deployed URL:

1. Open your deployed v0 app in Chrome
2. Press F12 → Sources tab
3. Press Ctrl+Shift+F (search across files)
4. Search for "sk_proj_" or "OPENAI" or "STRIPE"
5. If you see your key in a .js file, it is in the bundle

If you find a key already deployed, rotate it immediately before continuing. Do not fix the code first.

Step 2: Move Secret API Calls Server-Side

v0 puts API calls in React components because that is the path of least resistance. These components run in the browser. You need to move the secret-touching code to the server.

Route Handlers live in app/api/ and run only on the server. Your React component calls your Route Handler via fetch, and the Route Handler calls the third-party API. The secret never reaches the browser.

Before (v0-generated, secret in client bundle):

// app/components/ChatWidget.tsx
"use client";

export function ChatWidget() {
  const handleSubmit = async (message: string) => {
    const res = await fetch("https://api.openai.com/v1/chat/completions", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.NEXT_PUBLIC_OPENAI_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ model: "gpt-4o", messages: [{ role: "user", content: message }] }),
    });
  };
}

After (secret stays on server):

// app/api/chat/route.ts
import OpenAI from "openai";

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); // no NEXT_PUBLIC_ prefix

export async function POST(req: Request) {
  const { message } = await req.json();
  const response = await client.chat.completions.create({
    model: "gpt-4o",
    messages: [{ role: "user", content: message }],
  });
  return Response.json({ reply: response.choices[0].message.content });
}

// app/components/ChatWidget.tsx
"use client";

export function ChatWidget() {
  const handleSubmit = async (message: string) => {
    const res = await fetch("/api/chat", { // calls YOUR server, not OpenAI
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message }),
    });
  };
}

Option B: Server Actions (cleaner for form submissions)

If your v0 app uses form inputs that trigger AI or payment calls, Server Actions are a cleaner pattern:

// app/actions/chat.ts
"use server";
import OpenAI from "openai";

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

export async function generateReply(message: string) {
  const response = await client.chat.completions.create({
    model: "gpt-4o",
    messages: [{ role: "user", content: message }],
  });
  return response.choices[0].message.content;
}

The "use server" directive ensures this module only runs on the server, even if imported in a client component.

Step 3: Configure Vercel Environment Variables

Vercel injects environment variables at build time (for NEXT_PUBLIC_ variables) or at runtime (for everything else). Your secrets need to be runtime-only.

1

In your Vercel project, go to Settings > Environment Variables. For each secret you found in Step 1, add it without the NEXT_PUBLIC_ prefix:

Old name (leaking)New name (safe)
NEXT_PUBLIC_OPENAI_API_KEYOPENAI_API_KEY
NEXT_PUBLIC_ANTHROPIC_API_KEYANTHROPIC_API_KEY
NEXT_PUBLIC_STRIPE_SECRET_KEYSTRIPE_SECRET_KEY
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEYSUPABASE_SERVICE_ROLE_KEY
NEXT_PUBLIC_DATABASE_URLDATABASE_URL
2

Set the scope correctly. Vercel env vars can be scoped to Production, Preview, or Development separately.

  • Secret API keys: Production only (or Production + Development if needed for local testing)
  • Test/sandbox keys: Preview + Development
  • Never use your production Stripe live key in Preview

This keeps your production keys away from preview deployments, which are more accessible (shared links, team members, CI runs).

3

Redeploy after updating variables. Vercel does not automatically redeploy when you change environment variables. Go to Deployments and trigger a fresh build, or push a commit.

Update your .env.local file in parallel. Rename the variables there too, then verify locally that the app works with the new names before pushing. If the app breaks locally after renaming, that is a sign your Route Handler or Server Action is not importing the variable correctly.

Step 4: Add Security Headers

Security headers stop several common attack classes: clickjacking, cross-site scripting, content sniffing. A fresh v0 project ships with none of them.

Add headers in next.config.js:

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: [
          { key: "X-Content-Type-Options", value: "nosniff" },
          { key: "X-Frame-Options", value: "DENY" },
          { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
          {
            key: "Permissions-Policy",
            value: "camera=(), microphone=(), geolocation=()",
          },
          {
            key: "Content-Security-Policy",
            value: [
              "default-src 'self'",
              "script-src 'self' 'unsafe-inline' 'unsafe-eval'", // tighten after testing
              "style-src 'self' 'unsafe-inline'",
              "img-src 'self' data: https:",
              "connect-src 'self' https://api.openai.com", // add your own API domains
            ].join("; "),
          },
        ],
      },
    ];
  },
};

module.exports = nextConfig;

Start with this and tighten the CSP after you've verified your app works. unsafe-inline and unsafe-eval are intentionally included for initial setup because v0-generated apps often use inline styles and dynamic evaluation. Remove them once you've confirmed your app works without them.

Run your URL through securityheaders.com after deploying. It grades your headers from A+ to F and tells you exactly what is missing. Aim for at least a B before launch.

Step 5: Protect Preview Deployments

Every push to your repo triggers a preview deployment at a URL like your-project-abc123.vercel.app. Anyone with that URL can access your app. By default, Vercel does not require authentication to view it.

1

Enable Vercel Deployment Protection. In Vercel Dashboard > Settings > Deployment Protection, turn on "Vercel Authentication" for Preview deployments. Anyone trying to access the preview URL will need to log in with a Vercel account that has access to your project.

2

Scope your preview env vars. Your preview deployments should use test/sandbox credentials, not production keys. In your Vercel env var settings, set production secrets to Production scope only, and create separate test credentials for Preview scope.

A leaked preview URL with production credentials is just as damaging as a leaked production URL.

Step 6: Check Supabase RLS (if you are using Supabase)

v0 apps frequently connect to Supabase for data persistence. The Supabase anon key is public by design, but it only stays safe if Row Level Security is enabled on every table. Without RLS, the anon key gives anyone full read and write access to your database.

-- Run this in Supabase SQL Editor to check which tables have RLS disabled
SELECT
  schemaname,
  tablename,
  rowsecurity
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY tablename;

Any row with rowsecurity = false is exposed. For tables that should be private, enable RLS and add policies:

-- Enable RLS on a table
ALTER TABLE your_table ENABLE ROW LEVEL SECURITY;

-- Basic policy: users can only see their own rows
CREATE POLICY "Users see own rows"
ON your_table
FOR ALL
USING (auth.uid() = user_id);

If v0 generated a Supabase client using NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY (the service_role key bypasses RLS entirely), treat it as a critical issue. The service_role key must never be in a client component. Move it to a Route Handler.

Is v0 safe to use for production apps?

v0 generates working Next.js code but it frequently uses NEXT_PUBLIC_-prefixed variables for secrets, which bakes those secrets into your client bundle. The code runs correctly in development, but the exposure is invisible until you inspect the bundle or a scanner flags it. Audit for NEXT_PUBLIC_ before deploying.

What does NEXT_PUBLIC_ do to my API keys?

Next.js embeds any variable prefixed with NEXT_PUBLIC_ into your client-side JavaScript at build time. This means any visitor can open DevTools, search for the prefix, and read the key. It is not a bug in Next.js, the prefix exists to share non-secret values with browser code, but v0 sometimes uses it for secrets like NEXT_PUBLIC_OPENAI_API_KEY.

Does v0 only generate UI components?

Not anymore. v0 now generates full Next.js pages with data fetching, server actions, and third-party API integrations. When you prompt it to add AI features, payment forms, or database queries, it often writes code that touches secrets. Always audit before deploying.

Do I need Vercel Deployment Protection if I have auth in my app?

Yes. Preview deployments expose a public URL that anyone with the link can access. Your app's own auth layer may not protect test accounts or may be bypassed in preview mode. Vercel Deployment Protection adds a layer requiring a Vercel account to view the URL at all.

What security headers should a v0 app have?

At minimum: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, and a Content-Security-Policy that restricts where scripts can load from. A Referrer-Policy and Permissions-Policy are good additions. Run your URL through securityheaders.com after deploying to check your grade.

Check your deployed v0 app for API keys in the JavaScript bundle, missing security headers, and Supabase RLS gaps. Free scan, takes under 60 seconds.

How-To Guides

How to Deploy v0 Apps Securely (2026)