Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 025 / 100

Keep server code on the server: best practices explained

Prevent secrets from leaking into client bundles.

Expona Admin7 min read

Introduction

We've all seen it happen: a database password appears in the browser console, an API key ends up in the JavaScript bundle, or a third-party secret leaks into the client-side source code. These are not theoretical risks—they're production incidents waiting to happen. In this article, we'll explore how to keep server code on the server: best practices explained through practical patterns that prevent sensitive data from ever reaching the browser.

Next.js gives us powerful tools to enforce this separation, and when used correctly, they create an invisible wall between your secure backend logic and your public client bundles. Understanding these boundaries is essential for any team building production applications.

Understanding the server-client boundary

The fundamental challenge in modern JavaScript is that the same codebase can run in two environments: the server (Node.js) and the browser. A module imported by a Server Component runs safely on the server. The same module imported by a Client Component gets bundled and shipped to every user's browser. This creates an opportunity for accidental exposure.

Next.js 13+ introduced App Router with built-in Server Component support. By default, components are Server Components unless you explicitly opt into Client Components with the 'use client' directive. This means most of your sensitive logic can stay on the server—but only if you're intentional about the boundary.

The risk comes when you:

  • Import a module containing secrets into a Client Component
  • Call a server-only function from the browser
  • Accidentally bundle environment variables that should never leave the server

Next.js won't stop you from making these mistakes automatically. That's where explicit tooling helps.

Using 'server-only' to enforce boundaries

The server-only package (built-in to Next.js) is your first line of defense. By importing it at the top of a module, you ensure that if a Client Component ever tries to use that module, the build will fail.

tsx
// lib/database.ts
import 'server-only';

import { Pool } from 'pg';

const connectionString = process.env.DATABASE_URL;
const pool = new Pool({ connectionString });

export async function fetchUserById(id: string) {
  const result = await pool.query('SELECT * FROM users WHERE id = $1', [id]);
  return result.rows[0];
}

This module is now protected. If someone on your team writes a Client Component like this:

tsx
// app/components/UserProfile.tsx
'use client';

import { fetchUserById } from '@/lib/database';

export default function UserProfile({ userId }: { userId: string }) {
  // This will cause a build error
  const user = fetchUserById(userId);
  return <div>{user.name}</div>;
}

The build will fail immediately with a clear error message: This module cannot be imported into the client. No surprises in production. No secrets leaked during deployment.

Similarly, you can use 'use client' in modules that should never run on the server, though this is less common for security purposes.

Environment variables: NEXT_PUBLIC_ is the only exception

Next.js has a simple rule for environment variables: only variables prefixed with NEXT_PUBLIC_ are exposed to the browser. Everything else stays on the server.

Variable Server access Client access Use case
NEXT_PUBLIC_API_URL ✓ ✓ Public API endpoint
DATABASE_URL ✓ ✗ Server-only connection string
STRIPE_SECRET_KEY ✓ ✗ Payment processing (always server-only)
NEXT_PUBLIC_ANALYTICS_ID ✓ ✓ Public tracking ID

If you need to access DATABASE_URL` in a Client Component, you'll get `undefined` at runtime. This is intentional. If you need to run a database query from the browser, the correct pattern is to call a Server Action or API route instead.

Here's the wrong way:

tsx
// ❌ DON'T DO THIS
'use client';

const dbUrl = process.env.DATABASE_URL; // Will be undefined
fetch(dbUrl + '/users'); // Broken

Here's the right way:

tsx
// ✓ Correct approach
'use client';

async function loadUsers() {
  const response = await fetch('/api/users');
  return response.json();
}

export default function UsersList() {
  return <button onClick={loadUsers}>Load</button>;
}

The API route handles the database connection securely:

tsx
// app/api/users/route.ts
import 'server-only';
import { fetchAllUsers } from '@/lib/database';

export async function GET() {
  const users = await fetchAllUsers();
  return Response.json(users);
}

Fetching with secrets in Server Components only

Server Components are the natural place for authenticated requests, database queries, and operations that require secrets. They run on your server before the page HTML is sent to the browser.

Consider an e-commerce app that needs to fetch order history with a Stripe API key:

tsx
// app/dashboard/orders/page.tsx
import 'server-only';

async function getOrdersFromStripe(customerId: string) {
  const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
  const orders = await stripe.charges.list({
    customer: customerId,
  });
  return orders.data;
}

export default async function OrdersPage() {
  const orders = await getOrdersFromStripe('cust_123');
  
  return (
    <ul>
      {orders.map((order) => (
        <li key={order.id}>${order.amount / 100}</li>
      ))}
    </ul>
  );
}

This works because:

  • The component is a Server Component (no 'use client')
  • The Stripe key is read server-side only
  • Only the rendered HTML (the order list) reaches the browser
  • The secret key never appears in the JavaScript bundle

If you need dynamic client interactions, combine Server Components with Client Components intelligently:

tsx
// app/dashboard/orders/page.tsx (Server Component)
async function getOrdersFromStripe(customerId: string) {
  // Secrets used here
}

export default async function OrdersPage() {
  const orders = await getOrdersFromStripe('cust_123');
  
  return <OrdersList initialOrders={orders} />;
}

// app/dashboard/orders/OrdersList.tsx (Client Component)
'use client';

export function OrdersList({ initialOrders }: { initialOrders: Order[] }) {
  const [orders, setOrders] = useState(initialOrders);
  
  // Client-side interactivity only, no secrets here
  return <div>...</div>;
}

The Server Component fetches securely, the Client Component handles interactivity. Both run in their proper domain.

Server Actions for client-initiated operations

When a Client Component needs to trigger a server operation—like creating a payment, updating a database record, or calling an external API—use Server Actions. They're functions that run on the server but can be called from Client Components.

tsx
// app/actions/payments.ts
'use server';

import 'server-only';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function createPaymentIntent(amount: number) {
  return stripe.paymentIntents.create({
    amount,
    currency: 'usd',
  });
}

Now a Client Component can safely call this:

tsx
// app/checkout/PaymentForm.tsx
'use client';

import { createPaymentIntent } from '@/app/actions/payments';

export default function PaymentForm() {
  async function handleSubmit(e: FormEvent) {
    e.preventDefault();
    const intent = await createPaymentIntent(9999);
    // Use intent.client_secret for frontend payment
  }
  
  return <form onSubmit={handleSubmit}>...</form>;
}

The Server Action runs server-side. The Client Component never touches STRIPE_SECRET_KEY. The boundary is enforced by the framework.

Common mistakes

Mistake 1: Assuming NEXT_PUBLIC_ is safe because it's not a secret.

Even non-secret values should be intentional. A public API URL might reveal internal infrastructure details. Only mark variables NEXT_PUBLIC_ if the browser truly needs them.

Mistake 2: Wrapping secrets with try-catch and hoping for the best.

tsx
// ❌ BAD - still leaks if imported
'use client';

try {
  const secret = process.env.DATABASE_URL;
} catch (e) {
  // Error won't save you
}

Try-catch doesn't prevent bundling. Use server-only instead.

Mistake 3: Fetching secrets at build time and storing them in the bundle.

tsx
// ❌ BAD
const CONFIG = {
  apiKey: process.env.OPENAI_API_KEY, // Baked into the bundle
};

export default function App() {
  return <div>{CONFIG.apiKey}</div>;
}

Never inline secrets. Fetch them only when you need them, in a Server Component or Server Action.

Mistake 4: Overlooking indirect imports.

A Client Component might not directly import a module with secrets, but a utility it depends on might. Use server-only consistently across your entire codebase, not just the top-level functions.

Key takeaways

  • Add import 'server-only' to any module containing database connections, API keys, or secrets. Your build will fail if a Client Component accidentally imports it.
  • Only variables prefixed with NEXT_PUBLIC_ reach the browser. Everything else stays on the server by design.
  • Perform all authenticated requests, database queries, and secret-dependent operations in Server Components or Server Actions, never in Client Components.
  • When Client Components need to trigger secure operations, use Server Actions. They execute server-side even though they're called from the browser.
  • Combine Server Components and Client Components intentionally: fetch and prepare data securely, pass clean props to interactive components.
  • Test your build artifact. Run next build and inspect the output to verify secrets aren't present in .next/static.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles