Next.js has revolutionized React development by introducing a powerful Server Components architecture within its App Router. This architecture brings new considerations for developers, particularly around code organization and execution context.
However, the boundaries between server and client code have become more blurred, and developers are often caught unaware by potential pitfalls concerning where the code is being executed. This can lead to pretty bad outcomes.
In this guide, we'll explore how to effectively manage the separation between server and client code in your Next.js applications, how to avoid leaking secrets and ensuring your code is only ever executed server-side.
The Foundation: Server vs Client Components
Before diving into the specifics of avoiding bundling server side code into the client, we need to understand the main culprits that lead to this problem.
With the introduction of Next.js App Router and React Server Components, we can now write React Components that are exclusively executed server-side. Here's the thing: data fetching also happens inside these components, which can lead to server-side data making its way to the client.
Data Fetching in Server Components
Let's take a look at an example of data fetching in a Server Component:
- A
DashboardPage
Server Component - A
Chart
Client Component (eg. it usesuse client
, which means the component will both be rendered on the server and the client)
const apiSecret = process.env.API_SECRET;async function DashboardPage() { const data = await fetchData(apiSecret); return <Chart data={data} />;}
This server component fetches data from an API and renders a chart based on that data. It uses a secret key called API_SECRET
to authenticate the request.
If we changes this slightly, we can see that the secret key is now leaked to the client as we mistakenly pass the apiSecret
to the client component as a prop:
const apiSecret = process.env.API_SECRET;async function DashboardPage() { const data = await fetchData(apiSecret); return <Chart data={{data, apiSecret}} />;}
The apiSecret
is now leaked to the client, which is not ideal. As uncommon a mistake as this may seem, you'd be surprised how many people make this mistake.
So, kinda bad, right? Let's take a look at how we can avoid this.
Better conventions for server-side code
The first practical step is to ensure that all server-side code is properly separated from the client.
In Makerkit, a SaaS Starter Kit for Next.js and Supabase, the convention is to always create a lib/server
directory for server-side code, whether that's in a package or in the main app directory.
- components - ... (all your components)- lib // Zod schemas, shared between server and client - schema // server-side code - server - auth.service.ts // any shared code - utils
This doesn't prevent you from accidentally importing server-side code into the client, but it does make it more explicit that you're aware of the boundaries between server and client code.
💡 When you write server-side code, always make sure to be explicit about where it's being executed.
Using the 'server-only' Package
The very best way to avoid accidentally importing server-side code into the client is to use the server-only
package.
import 'server-only';const apiSecret = process.env.API_SECRET;export function fetchData() { // sensitive operation, only executed on the server const data = db.getData(apiSecret); return data;}
If you accidentally import a component using this file, you'll get a runtime error - and you'll know that you're doing something wrong.
💡 Add all server-side code into files that are marked with the server-only
package.
Using the Taint API
The experimental Taint API is a new React API that allows you to mark values as tainted, which means they're not safe to pass to the client.
This is useful when you want to ensure that a value is only used on the server, and not on the client.
We can think of it asn additional layer of security, as it ensures that the value is only used on the server, and not on the client.
import { experimental_taintUniqueValue as taintUniqueValue} from 'react/experimental';const apiSecret = taintUniqueValue( 'The API secret is only used on the server', process, process.env.API_SECRET,);export function fetchData() { // sensitive operation, only executed on the server const data = db.getData(apiSecret); return data;}
This alone is not enough to ensure that the value is only used on the server, since there are ways to bypass the taint API.
const key = taintUniqueValue( 'The API secret is only used on the server', process, process.env.API_SECRET,);const derivedKey = key.toUpperCase();
The constant key
is now tainted, however, the derivedKey
is not. This means that the value is still accessible on the client if it was accidentally passed to the client.
💡 Consider using the taint API to ensure that values are only used on the server, but do not solely rely on it to prevent leaking secrets.
Summarizing best practices
Here are the best practices we've discussed in this guide:
- Code Organization: Always ensure that all server-side code is properly separated from the client at the folder level.
- Place server code in their own files: Use the
server-only
package to ensure that server-side code is only ever executed on the server. - Use the taint API to ensure that values are only used on the server, but do not solely rely on it to prevent leaking secrets.
While bundlers have gotten better at optimizing code, they're not perfect.
By following these best practices, you can build more secure, performant, and maintainable applications that leverage the full power of Next.js's Server Components.
Conclusion
In this guide, we've explored the importance of establishing clear boundaries between server and client code in Next.js applications. We've also discussed how to avoid leaking secrets and ensure that server-side code is only ever executed on the server.
By following these best practices, you can build more secure, performant, and maintainable applications that leverage the full power of Next.js's Server Components.