React Server Components (RSC) allow developers to render components on the server instead of sending all component code to the browser. This can reduce the amount of JavaScript sent to users and make it easier to fetch data directly from databases and other server-side services.
However, there is one important security detail: running code on the server does not automatically make the data it handles private.
A Server Component might read a database record containing a user's email address, password hash, access token, and internal notes. If that entire record is passed to a Client Component, some of those values can become available in the browser even if the component never displays them.
This article explains how React Server Components handle data, where accidental data exposure happens, and how to keep private information out of client responses.
What Are React Server Components?
React Server Components are components that execute on the server and can access server-side resources without requiring those operations to run in the browser.
For example, a Server Component can query a database and display a user's profile.
// app/profile/page.tsx
import { db } from "@/lib/db";
export default async function ProfilePage() {
const user = await db.user.findUnique({
where: { id: "user-123" },
select: {
name: true,
email: true,
},
});
if (!user) {
return <p>User not found.</p>;
}
return (
<main>
<h1>{user.name}</h1>
<p>{user.email}</p>
</main>
);
}
In this example, the database query runs on the server. The database connection and query implementation do not need to be included in the browser's JavaScript bundle.
This is useful, but it is important to understand the difference between server-side execution and client-visible data.
A Server Component's implementation is not shipped as ordinary client-side component code. However, its rendered output and the data passed to Client Components can still reach the browser.
The security rule is simple: keep sensitive operations on the server and send only the data the client actually needs.
How Private Data Can Reach the Browser
Consider a database record that contains several fields:
type User = {
id: string;
name: string;
email: string;
passwordHash: string;
apiToken: string;
internalNotes: string;
};
The application may need only the user's name and avatar to render a profile card. Passing the entire database record to a Client Component creates an unnecessary exposure risk.
For example, this is unsafe:
// app/profile/page.tsx
import ProfileCard from "./ProfileCard";
import { db } from "@/lib/db";
export default async function ProfilePage() {
const user = await db.user.findUnique({
where: { id: "user-123" },
});
if (!user) {
return <p>User not found.</p>;
}
return <ProfileCard user={user} />;
}
The problem is not that the database query runs on the server. The problem is that the complete database record is passed to a Client Component.
If the record contains sensitive properties, they may be included in the data serialized for the client. A developer does not need to render those properties in the UI for the data to be exposed.
This can happen with password hashes, API tokens, private customer information, internal support notes, and other fields that should remain server-side.
Use DTOs to Control What the Client Receives
One of the simplest ways to prevent accidental data exposure is to return a Data Transfer Object (DTO) containing only the fields needed by the client.
Instead of passing a complete database entity, create a small object with an explicit list of allowed properties.
Step 1: Select only the required database fields
// lib/users.ts
import "server-only";
import { db } from "@/lib/db";
export async function getPublicUserProfile(userId: string) {
const user = await db.user.findUnique({
where: { id: userId },
select: {
id: true,
name: true,
avatarUrl: true,
},
});
if (!user) {
return null;
}
return {
id: user.id,
name: user.name,
avatarUrl: user.avatarUrl,
};
}
This example uses a Prisma-style query. The exact query syntax depends on the database library you use.
The select option limits the fields retrieved from the database. The returned object then explicitly defines what the application makes available to other components.
The server-only import is supported by frameworks such as Next.js. It helps detect accidental imports of this module into client-side code during development or builds.
Step 2: Fetch the data in a Server Component
// app/profile/page.tsx
import { getPublicUserProfile } from "@/lib/users";
import ProfileCard from "./ProfileCard";
export default async function ProfilePage() {
const user = await getPublicUserProfile("user-123");
if (!user) {
return <p>User not found.</p>;
}
return <ProfileCard user={user} />;
}
The Server Component fetches the profile and passes the restricted object to the client.
In a real application, the user ID should normally come from a trusted, authenticated server-side context or be checked against the current user's permissions. A hardcoded ID is used here only to keep the example focused.
Step 3: Accept only the required properties in the Client Component
// app/profile/ProfileCard.tsx
"use client";
type ProfileCardProps = {
user: {
id: string;
name: string;
avatarUrl: string | null;
};
};
export default function ProfileCard({
user,
}: ProfileCardProps) {
return (
<section>
<h2>{user.name}</h2>
{user.avatarUrl && (
<img src={user.avatarUrl} alt={user.name} />
)}
</section>
);
}
The Client Component receives only the profile fields it needs. It does not receive the password hash, API token, or internal notes.
This approach also makes the component easier to maintain because its data requirements are clear.
Keep Database Access and Secrets in Server-Only Modules
A common mistake is placing database utilities, private API clients, or environment variable access in files that can be imported by both server and client code.
For example, a module might contain a database connection and several helper functions. If a Client Component imports that module, the application may fail to build, or sensitive implementation details may end up in an inappropriate part of the application.
Use server-only modules for code that must never run in the browser.
// lib/private-api.ts
import "server-only";
const apiToken = process.env.INTERNAL_API_TOKEN;
export async function getPrivateData() {
if (!apiToken) {
throw new Error("Internal API token is not configured.");
}
const response = await fetch(
"https://internal-api.example/data",
{
headers: {
Authorization: `Bearer ${apiToken}`,
},
cache: "no-store",
}
);
if (!response.ok) {
throw new Error("Failed to fetch private data.");
}
return response.json();
}
This is an illustrative example. In production, validate the response shape and return only the fields that callers are permitted to receive.
The server-only marker helps prevent this module from being imported into Client Components in supported frameworks. It does not replace authorization checks or prevent sensitive values from being returned accidentally.
Also, do not put secrets in public environment variables. In Next.js, variables prefixed with NEXT_PUBLIC_ are intended for browser access and can be included in client-side bundles.
For example, use a server-only variable such as INTERNAL_API_TOKEN, not NEXT_PUBLIC_INTERNAL_API_TOKEN, for private credentials.
Protect Server Actions Like Public API Endpoints
Server Actions allow Client Components to request server-side operations. They are useful for updating records, submitting forms, and performing other tasks that require access to server resources.
However, a Server Action should not be treated as private simply because it is defined in a server-side file or called through a button.
A caller may be able to invoke an action without following the intended UI flow. Therefore, every action must validate its input, authenticate the caller, and authorize the requested operation.
Consider a function that updates a user's display name.
// app/profile/actions.ts
"use server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
export async function updateDisplayName(
formData: FormData
) {
const session = await auth();
if (!session?.user?.id) {
throw new Error("You must be signed in.");
}
const displayName = formData.get("displayName");
if (
typeof displayName !== "string" ||
displayName.trim().length < 2 ||
displayName.trim().length > 50
) {
throw new Error("Invalid display name.");
}
await db.user.update({
where: { id: session.user.id },
data: {
name: displayName.trim(),
},
});
return { success: true };
}
This example assumes the authentication library provides a trusted session and the database schema matches the code.
The important detail is that the database update uses the authenticated user's ID from the server-side session. It does not accept an arbitrary user ID from the form and trust it.
For operations involving another user's data, perform an explicit authorization check before making changes. A valid login proves identity; it does not automatically grant permission to access every record.
Framework-provided protections, including relevant request and origin checks, should remain enabled. They do not eliminate the need for authentication, authorization, and input validation.
Do Not Depend on Hidden UI Elements for Security
Hiding a button is a user interface decision, not an authorization mechanism.
For example, an application might hide an administrative action from regular users:
{isAdmin && (
<button onClick={deleteAccount}>
Delete Account
</button>
)}
This can improve the user experience, but the server must still verify administrative permissions when the operation is requested.
The same rule applies to Server Actions, API routes, and other server-side operations. A user should not gain access to protected data simply by constructing a request that the UI normally prevents.
Authorization belongs at the server-side operation that reads or modifies the protected resource.
Common Mistakes That Expose Private Data
Mistake | Why it is risky | Better approach |
|---|
Passing full database entities to Client Components | Unnecessary private fields may be serialized | Return minimal DTOs |
Assuming server-rendered data is always private | Rendered output and props can reach the browser | Inspect what is actually returned |
Putting secrets in NEXT_PUBLIC_* variables | Public variables can be exposed in client bundles | Keep credentials in server-only variables |
Importing database code into client modules | Can break server-client boundaries | Separate server-only data access |
Trusting hidden buttons | Requests can be made outside the intended UI flow | Enforce authorization on the server |
Validating only in the browser | Client-side checks can be bypassed | Validate inputs again on the server |
Returning complete records from Server Actions | Sensitive fields may be sent to the caller | Return a small success result or approved DTO |
How to Check Whether Private Data Is Being Exposed
A useful security review should examine the actual data sent to the browser, not just the JSX visible on the screen.
1. Inspect browser network responses
Open the browser's developer tools and inspect the Network tab while loading pages and performing actions.
Look for application responses and framework payloads containing fields such as passwordHash, apiToken, internalNotes, or private customer details.
React Server Components may use framework-specific payload formats rather than ordinary JSON. The exact representation varies by framework and version, so inspect the relevant requests instead of assuming every response is JSON.
2. Review Client Component props
Search the codebase for Client Components and inspect the objects passed to them. Pay particular attention to props originating from database queries, private API responses, or authentication records.
Ask a simple question for each prop: does the browser need every field in this object?
If the answer is no, reduce the object before passing it across the server-client boundary.
3. Test authorization independently
Try accessing protected operations with different user roles and identities in a test environment.
Verify that unauthenticated users cannot perform protected actions and that authenticated users cannot modify records they do not own. Test the server-side action directly rather than relying only on whether the UI displays a button.
4. Add regression tests
Create tests that verify server functions return only approved fields and that unauthorized requests are rejected. These tests help prevent a future database schema change from accidentally expanding the data sent to the client.
Best Practices for Keeping React Server Components Secure
Follow these practices when building React applications with Server Components:
Fetch private data on the server. Keep database credentials, internal API tokens, and other secrets out of client-side modules.
Return minimal data objects. Select only the required database fields and map them to explicit DTOs.
Use server-only boundaries. Mark sensitive modules with server-only where supported and avoid importing them into Client Components.
Protect every Server Action. Authenticate the caller, validate inputs, and check authorization for every protected operation.
Keep public and private environment variables separate. Never use public prefixes for secrets.
Inspect network payloads. Do not assume a value is private because it is absent from the visible page.
Treat rendered content as browser-visible. Any information sent to a browser should be considered accessible to that browser's user.
Use tests and code reviews. Check data boundaries whenever components, database queries, or server actions change.
React Server Components provide a useful separation between server-side work and client-side interaction, but they are not a complete security system. They do not automatically prevent data leaks, cross-site scripting, broken access control, or other application vulnerabilities.
Conclusion
React Server Components make it easier to fetch data and perform server-side work without sending all application code to the browser. The important security responsibility is deciding which information crosses that boundary.
Avoid passing complete database records to Client Components. Use explicit DTOs, keep private modules server-only, and never rely on hidden UI elements to protect sensitive operations. Treat Server Actions as endpoints that require authentication, authorization, and input validation.
When reviewing an application, inspect the actual client payloads and verify that every exposed field is necessary. Keeping the server-client boundary small and deliberate is one of the most effective ways to reduce accidental data exposure in React applications.
Join the conversation! Your thoughts help the community grow.