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.ts files and import them only from server functions/loaders. The Vite server-leak-guard plugin (apps/web/vite/server-leak-guard.ts) fails the client build if the server graph (the database layer, *.server modules) 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 via process.env.*
  • Variables with the VITE_ prefix are available on both server and client, read via import.meta.env.VITE_*

Client code can only access environment variables prefixed with VITE_:

// This is available in client code
console.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 client
VITE_API_KEY=sk_live_1234567890 # NEVER DO THIS!

SAFE: This is only available server-side

API_KEY=sk_live_1234567890 # This is correct

Remember:

  1. API keys, secrets, tokens, and passwords should never use the VITE_ prefix
  2. 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 git

As 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:

  1. Store sensitive values in .env.local which should not be committed to your repository
  2. Use environment-specific files for values that differ between environments
  3. Use environment variables for sensitive data, not hardcoded values
  4. Use the VITE_ prefix for environment variables that are available on the client
  5. Never use VITE_ variables for sensitive data
  6. Keep server-only code in *.server.ts modules imported only from server functions/loaders; the server-leak-guard build check catches leaks
  7. Separate server and client code in different files and never mix them up in barrel files
  8. Keep secrets out of the return value of server functions and loaders — only their return value crosses to the client