TanStack Start Security Best Practices
Learn best practices for TanStack Start in the TanStack Start Prisma SaaS Kit.
This section contains a list of best practices for TanStack Start in general, applicable to both the TanStack Start Prisma SaaS Kit or any other TanStack Start application.
Best practices for TanStack Start in the TanStack Start Prisma SaaS Kit
Learn best practices for TanStack Start in the TanStack Start Prisma SaaS Kit.
What is reachable from client code will be sent to the client
The first and most important rule you must remember is that anything reachable from a client component — or from any module that ends up in the client bundle — will be shipped to the browser.
In TanStack Start, the security boundary is the server function (createServerFn) and the route loader: their handlers run on the server only, and only the data they return crosses to the client. By contrast, any module imported (directly or transitively) by a component is bundled and sent to the browser.
From this rule, you can derive the following best practices:
- Do not reference secrets from code that the client bundle can reach. If you're using an API Key server-side, keep it inside a server function, a loader, or a service that only server code imports — never inline it in a component.
- Avoid exporting server and client code from the same file. Instead, define separate entry points for server and client code.
- Keep server-only modules in
*.server.tsfiles and import them only from server functions/loaders. The Viteserver-leak-guardplugin (apps/web/vite/server-leak-guard.ts) fails the client build if the server graph (the database layer,*.servermodules) leaks into the browser bundle.
Do not pass sensitive data to client code
One common mistake is passing sensitive data back to the client. This is easier than it sounds, and it can happen without you even noticing it.
Let's look at the following example: we read config in a loader, and we end up returning the API Key so the component receives it.
import { createFileRoute } from '@tanstack/react-router';import { createServerFn } from '@tanstack/react-start';const loadData = createServerFn({ method: 'GET' }).handler(async () => { const config = { apiKey: process.env.API_KEY, storeId: process.env.STORE_ID, }; const data = await fetchData(config); // returning `config` leaks the API key to the client return { data, config };});export const Route = createFileRoute('/example')({ loader: () => loadData(),});Here the config object contains sensitive data such as the API Key (the Store ID is a public identifier for the store, so it is safe to expose). Because the loader returns it, the value is serialized into the page payload and the client can read it.
function ExampleComponent() { // `config.apiKey` is now in the client payload — a security risk const { data, config } = Route.useLoaderData(); // ...}This is a problem, because the config object contains sensitive data such as the API Key. The fact we return it from the loader means it is serialized to the client, which leaks the secret.
A better approach is to define the service in a *.server.ts module and return only what the client actually needs.
fetch-data.server.ts
const config = { apiKey: process.env.API_KEY, storeId: process.env.STORE_ID,};export async function fetchData() { const data = await fetchDataFromExternalApi(config); const storeId = config.storeId; // only return non-sensitive values return { data, storeId };}Now, we can use this service from the server function/loader and return only the safe values.
import { createServerFn } from '@tanstack/react-start';import { fetchData } from './fetch-data.server';export const loadData = createServerFn({ method: 'GET' }).handler(async () => { return fetchData();});While this doesn't fully solve the problem (you can still return the config object), it's a good start and will help you separate concerns: only the explicit return value of a server function or loader is sent to the client, so keep that return value free of secrets.
Do not mix up client and server imports
Sometimes, you have a package that exports both client and server code. This is a problem, because the server code can end up in the client bundle.
For example, assume we have a barrel file index.ts from which we export a fetchData function and a client component ClientComponent.
export * from './fetch-data';export * from './client-component';This is a problem, because it's possible that some server-side code ends up in the client bundle.
The fix is to use different entry points for server and client code; then use the exports field in package.json to expose them separately.
{ "exports": { "./server": "./server.ts", "./client": "./client.tsx" }}This way, you can import the server and client code separately and you won't end up with a mix of server and client code in the client bundle. This is exactly how the kit's packages are structured (for example @kit/i18n/provider vs @kit/i18n/messages).
Environment variables
Environment variables are essential for configuring your application across different environments. However, they require careful management to prevent security vulnerabilities.
Use the VITE_ prefix for environment variables that are available on the client
TanStack Start is built on Vite, which distinguishes between server-only and client-available variables through a prefix (configured via envPrefix: ['VITE_'] in apps/web/vite.config.ts):
- Variables without the
VITE_prefix are only available on the server, read viaprocess.env.* - Variables with the
VITE_prefix are available on both server and client, read viaimport.meta.env.VITE_*
Client code can only access environment variables prefixed with VITE_:
// This is available in client codeconsole.log(import.meta.env.VITE_SITE_URL)// This is undefined in the client bundle (server-only)console.log(process.env.SECRET_API_KEY)The kit centralizes this in @kit/shared/env, which prefers import.meta.env.VITE_* and falls back to process.env.* for unprefixed server secrets.
Never use VITE_ variables for sensitive data
Since VITE_ variables are embedded in the JavaScript bundle sent to browsers, they should never contain sensitive information:
# .env# UNSAFE: This will be exposed to the clientVITE_API_KEY=sk_live_1234567890 # NEVER DO THIS!SAFE: This is only available server-side
API_KEY=sk_live_1234567890 # This is correctRemember:
- API keys, secrets, tokens, and passwords should never use the
VITE_prefix - Use
VITE_only for genuinely public information like the public site URL, feature flags, or public identifiers
Proper use of .env files
Vite supports various .env files for different environments:
.env # Loaded in all environments.env.local # Loaded in all environments, ignored by git.env.development # Only loaded in development environment.env.production # Only loaded in production environment.env.development.local # Only loaded in development, ignored by git.env.production.local # Only loaded in production, ignored by gitAs a general rule, never add sensitive data to the .env file or any other committed file. Instead, add it to the .env.local file, which by default is ignored by git (though you must check this if you're not using Makerkit).
Best practices:
- Store sensitive values in
.env.localwhich should not be committed to your repository - Use environment-specific files for values that differ between environments
- Use environment variables for sensitive data, not hardcoded values
- Use the
VITE_prefix for environment variables that are available on the client - Never use
VITE_variables for sensitive data - Keep server-only code in
*.server.tsmodules imported only from server functions/loaders; theserver-leak-guardbuild check catches leaks - Separate server and client code in different files and never mix them up in barrel files
- Keep secrets out of the return value of server functions and loaders — only their return value crosses to the client