React Server Components allow developers to render components on the server without sending their component implementation to the browser. They can also access server-side resources, such as databases, internal services, and files, without requiring the same code to run in the client.
However, running a component on the server does not automatically make its output private. If a Server Component reads confidential information and passes it to a Client Component, includes it in rendered content, or exposes it through a serialized response, that information can reach the browser.
This distinction is important when building applications that handle user profiles, authentication details, billing information, internal records, or administrative data.
The goal is not simply to keep database queries on the server. It is to ensure that only the data a user is authorized to receive crosses the server-client boundary.
How React Server Components Handle Data
A Server Component runs in a server environment and can access server-side resources. Its implementation is not included in the client-side JavaScript bundle in the same way as a Client Component.
A Client Component is marked with the "use client" directive. It can use browser APIs, state, effects, and event handlers, but it also becomes part of the client module graph.
Consider a simplified application that retrieves a user's account details.
// app/account/page.jsx
import { getAccount } from "@/lib/accounts";
export default async function AccountPage() {
const account = await getAccount();
return (
<main>
<h1>Welcome, {account.name}</h1>
<p>Plan: {account.plan}</p>
</main>
);
}
This Server Component retrieves account information and renders selected fields. The browser receives the rendered output needed to display the page, not the server component's implementation as ordinary client-side JavaScript.
But the example is safe only if getAccount() retrieves the correct user's account and the rendered fields are appropriate for that user. Server-side execution does not replace authentication, authorization, or careful data handling.
Understand the Server-Client Boundary
The "use client" directive marks a module and its imported dependencies as part of the client module graph. It does not mean that every value passed to that component is safe to expose.
For example, the following implementation creates a potential data exposure problem:
// app/account/page.jsx
import AccountDetails from "./AccountDetails";
import { getAccount } from "@/lib/accounts";
export default async function AccountPage() {
const account = await getAccount();
return <AccountDetails account={account} />;
}
// app/account/AccountDetails.jsx
"use client";
export default function AccountDetails({ account }) {
return (
<section>
<h1>{account.name}</h1>
<p>{account.email}</p>
</section>
);
}
Suppose the account object contains fields such as:
{
id: "user-123",
name: "Aarav",
email: "[email protected]",
plan: "Business",
internalNotes: "Account under investigation",
billingReference: "private-reference"
}
The Client Component needs only the name and email, but the entire object is passed across the boundary.
Depending on the framework and rendering path, props passed to a Client Component are represented in the data used to render and hydrate the client interface. Developers should therefore assume that values passed to client components are visible to the browser user.
Even if the component never displays internalNotes, passing the complete object unnecessarily exposes data that the client does not need.
The safer approach is to select the required fields on the server before passing them to the client.
Return Only the Fields the Client Needs
Create a small object containing only the fields that the interface requires.
// app/account/page.jsx
import AccountDetails from "./AccountDetails";
import { getAccount } from "@/lib/accounts";
export default async function AccountPage() {
const account = await getAccount();
const publicAccount = {
name: account.name,
email: account.email
};
return <AccountDetails account={publicAccount} />;
}
The Client Component remains simple:
// app/account/AccountDetails.jsx
"use client";
export default function AccountDetails({ account }) {
return (
<section>
<h1>{account.name}</h1>
<p>{account.email}</p>
</section>
);
}
The server now passes only two fields to the browser. Internal notes, billing references, and other unnecessary properties stay on the server.
The variable name publicAccount is only descriptive; it does not enforce security. The actual protection comes from explicitly selecting the allowed fields and ensuring that the selected fields are authorized for the current user.
For larger applications, use a dedicated data transfer object or a schema that defines exactly what the client is allowed to receive. This makes it easier to review the boundary when database models change.
Protect Server-Only Modules
Another important practice is to prevent server-only code from accidentally entering the client module graph.
Consider a database module:
// lib/accounts.js
import { database } from "./database";
export async function getAccountById(id) {
return database.account.findUnique({
where: { id }
});
}
If a developer imports this module into a Client Component, the application may fail to build or may create an inappropriate dependency between client and server code, depending on the framework and module structure.
In Next.js applications, the server-only package can help enforce this boundary.
Install it if the project does not already include it:
npm install server-only
Then mark sensitive modules explicitly:
// lib/accounts.js
import "server-only";
import { database } from "./database";
export async function getAccountById(id) {
return database.account.findUnique({
where: { id }
});
}
Importing this module into a Client Component will cause a build-time error in supported Next.js configurations.
This is useful for modules that contain database access, secret-dependent API clients, private configuration, or authorization logic.
However, server-only is not a substitute for access control. It prevents inappropriate imports; it does not guarantee that the server returns only authorized data.
Never Send Secrets Through Component Props
Secrets should remain on the server. Examples include API keys, database credentials, signing keys, private tokens, and internal service credentials.
Consider a server-side API client:
// lib/payment-service.js
import "server-only";
export async function getPaymentStatus(paymentId) {
const response = await fetch(
`${process.env.PAYMENT_API_URL}/payments/${paymentId}`,
{
headers: {
Authorization:
`Bearer ${process.env.PAYMENT_API_KEY}`
},
cache: "no-store"
}
);
if (!response.ok) {
throw new Error("Unable to retrieve payment status");
}
return response.json();
}
The API key is read from a server-side environment variable and used in a server-side request.
Do not pass the key into a Client Component:
// Do not do this.
return (
<PaymentStatus
apiKey={process.env.PAYMENT_API_KEY}
payment={payment}
/>
);
Instead, pass only the information the interface needs:
return (
<PaymentStatus
status={payment.status}
amount={payment.amount}
/>
);
In Next.js, environment variables prefixed with NEXT_PUBLIC_ are intended to be exposed to client-side code. Never use that prefix for secrets.
Also, do not assume that a variable is safe simply because it lacks the prefix. A secret can still leak if the server passes it into rendered content, component props, an API response, or a log that is accessible to unauthorized users.
Authenticate and Authorize Every Data Request
Filtering fields is not enough if the server retrieves another user's data.
For example, this implementation is unsafe when the supplied identifier is controlled by the user and ownership is not checked:
// Unsafe without authorization checks.
export default async function AccountPage({ searchParams }) {
const params = await searchParams;
const account = await getAccountById(params.id);
return <h1>{account.name}</h1>;
}
An attacker could attempt to change the identifier and access another account.
The server should obtain the authenticated user from a trusted session and verify that the requested record belongs to that user or is otherwise accessible to them.
// app/account/page.jsx
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/auth";
import { getAccountForUser } from "@/lib/accounts";
export default async function AccountPage() {
const user = await getCurrentUser();
if (!user) {
redirect("/login");
}
const account = await getAccountForUser(user.id);
if (!account) {
return <p>Account not found.</p>;
}
return (
<main>
<h1>Welcome, {account.name}</h1>
<p>Plan: {account.plan}</p>
</main>
);
}
The getAccountForUser() function should enforce ownership in the data-access layer rather than trusting an identifier supplied by the browser.
For example, a database query can include both the record identifier and the authenticated user's identifier in its conditions. This reduces the chance that another route or component accidentally skips the ownership check.
Server Actions and route handlers need the same protection. A Server Action is not automatically authorized just because it executes on the server. Every operation that reads or changes protected data must validate the caller's identity and permissions.
Be Careful with Server Actions
Server Actions allow client-side interactions to invoke server-side functions. They are useful for operations such as updating a profile or submitting a form.
However, a Server Action should be treated as an externally invocable entry point. Do not rely on the fact that a particular button is hidden or that a user normally reaches the action through a specific page.
A protected action should authenticate the caller, validate its input, and check authorization before changing data.
// app/account/actions.js
"use server";
import { getCurrentUser } from "@/lib/auth";
import { updateAccountName } from "@/lib/accounts";
export async function changeAccountName(formData) {
const user = await getCurrentUser();
if (!user) {
throw new Error("Authentication required");
}
const name = String(
formData.get("name") ?? ""
).trim();
if (name.length < 1 || name.length > 100) {
throw new Error("Invalid account name");
}
await updateAccountName(user.id, name);
}
This example assumes that updateAccountName() updates only the account associated with the supplied authenticated user ID. The data-access function must enforce that condition.
For production applications, return appropriate validation errors through the application's normal error-handling mechanism. Avoid exposing database details, stack traces, or internal service responses to the client.
Check API Responses and Route Handlers
Not all data reaches the browser through Server Component props. Applications may also expose data through route handlers, API endpoints, or Server Actions.
For example, returning a complete database record from an API endpoint can expose fields even if the page itself renders only a few of them.
Instead of returning the raw database object, construct a response containing the permitted fields:
// app/api/account/route.js
import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { getAccountForUser } from "@/lib/accounts";
export async function GET() {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
const account = await getAccountForUser(user.id);
if (!account) {
return NextResponse.json(
{ error: "Not found" },
{ status: 404 }
);
}
return NextResponse.json({
name: account.name,
email: account.email,
plan: account.plan
});
}
The endpoint checks authentication and returns only the fields needed by the client. The exact authentication implementation depends on the application's session and identity provider.
The same rule applies to error responses. Do not return internal exception messages, database queries, secret values, or sensitive identifiers merely to make debugging easier.
Understand Caching and Private Data
Caching introduces another potential source of data exposure. A response containing one user's private information must not be reused for another user.
For example, if a shared cache stores account data without considering the authenticated user, a later request could receive the wrong account's response.
When fetching private data, review the framework's caching behavior and the caching options used by the application. In Next.js, a server-side fetch can explicitly disable caching when appropriate:
const response = await fetch(
`${process.env.INTERNAL_API_URL}/account`,
{
headers: {
Authorization: `Bearer ${accessToken}`
},
cache: "no-store"
}
);
Use cache: "no-store" when the data must be fetched without using a cached response. It is not necessary for every request, and it does not automatically secure every other caching layer.
Also review any application-level cache, reverse proxy, CDN, and HTTP cache configuration. Private responses should have caching rules that match their sensitivity and authorization model.
For user-specific data, cache keys and invalidation rules must prevent one user's data from being served to another. Avoid caching authenticated responses publicly unless the response has been deliberately designed for public access.
Inspect the Browser Response During Testing
A page can look correct while still transferring unnecessary data to the client. Security testing should therefore inspect network responses, not just the rendered interface.
Use the browser's developer tools to inspect the Network panel while loading the page and interacting with it. Look for sensitive fields in document responses, React Server Component payloads, API responses, and action responses.
In automated tests, create records containing distinctive internal-only values, such as a test-only billing reference. Then verify that those values do not appear in responses accessible to a user who should not receive them.
Test with different account roles as well. A regular user and an administrator may legitimately receive different fields, but the server must enforce those differences.
Remember that hiding a field with CSS or choosing not to display it in JSX does not protect it if the value has already been sent to the browser.
Common Mistakes to Avoid
Several practices frequently cause data exposure in React applications:
Passing complete database records to Client Components when only a few fields are needed.
Assuming Server Components make every value they read private.
Importing server-side modules into the client module graph without enforcing the boundary.
Exposing API keys through client-side environment variables or component props.
Checking authentication on the page but not inside Server Actions or route handlers.
Trusting record IDs supplied by the browser without verifying ownership.
Returning raw database objects or internal exception messages from APIs.
Caching user-specific data in a shared cache without suitable isolation.
Testing only the visible page instead of inspecting network responses.
These problems are easier to prevent when data-access functions return purpose-built objects and authorization checks are applied consistently across every entry point.
Conclusion
React Server Components can reduce the amount of application code sent to the browser, but they do not automatically prevent private data from being exposed.
Keep database access and secrets in server-only modules, select the fields required by each Client Component, and authorize every operation using the authenticated user's identity and permissions. Apply the same rules to Server Actions, route handlers, caching, and error handling.
Finally, inspect actual browser responses during testing. The key security question is not whether a component runs on the server, but whether every value crossing the server-client boundary is appropriate for the person receiving it.
Join the conversation! Your thoughts help the community grow.