Experimental: Import protection is experimental and subject to change.
Import protection prevents server-only code from leaking into client bundles and client-only code from leaking into server bundles. It runs as a Vite plugin and is enabled by default in TanStack Start.
TanStack Start builds your application for two environments: client and server. Some code should only run in one environment. Import protection checks every import in your source files during development and build, and either blocks or mocks imports that cross environment boundaries.
There are two ways an import can be denied:
Import protection is enabled out of the box with these defaults:
| Setting | Default |
|---|---|
| behavior (dev) | 'mock' -- warn and replace with a mock module |
| behavior (build) | 'error' -- fail the build |
| log | 'once' -- deduplicate repeated violations |
| Scope | Files inside Start's srcDirectory |
Client environment denials:
Server environment denials:
These defaults mean you can use the .server.ts / .client.ts naming convention to restrict files to a single environment without any configuration. To also deny entire directories (e.g. server/ or client/), add them via files in your deny rules configuration — for example files: ['**/*.server.*', '**/server/**'] for the client environment.
You can explicitly mark a module as server-only or client-only by adding a side-effect import at the top of the file:
// src/lib/secrets.ts
import '@tanstack/react-start/server-only'
export const API_KEY = process.env.API_KEY
// src/lib/local-storage.ts
import '@tanstack/react-start/client-only'
export function savePreferences(prefs: Record<string, string>) {
localStorage.setItem('prefs', JSON.stringify(prefs))
}
When the plugin sees a marker import, it records the file as restricted. If that file is later imported from the wrong environment, the import is denied. Both markers in the same file is always an error.
Markers are useful when a file doesn't follow the .server.* / .client.* naming convention but still contains environment-specific code.
The behavior option controls what happens when a violation is detected:
Mock mode is useful during development because it lets you keep working even when your import graph has violations. The mock module returns a recursive Proxy, so any property access or function call on a mocked import returns another mock instead of crashing.
You can override the defaults:
// vite.config.ts
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
export default defineConfig({
plugins: [
tanstackStart({
importProtection: {
// Always error, even in dev
behavior: 'error',
},
}),
],
})
Or set different behaviors per mode:
importProtection: {
behavior: {
dev: 'mock',
build: 'error',
},
}
You can add your own deny rules on top of the defaults. Rules are specified per environment using glob patterns (via picomatch) or regular expressions.
// vite.config.ts
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
export default defineConfig({
plugins: [
tanstackStart({
importProtection: {
client: {
// Block specific npm packages from the client bundle
specifiers: ['@prisma/client', 'bcrypt'],
// Block files in a custom directory
files: ['**/db/**'],
},
server: {
// Block browser-only libraries from the server
specifiers: ['localforage'],
},
},
}),
],
})
By default, import protection only checks files inside Start's srcDirectory. You can change the scope with include, exclude, and ignoreImporters:
importProtection: {
// Only check files matching these patterns
include: ['src/**'],
// Skip checking these files
exclude: ['src/generated/**'],
// Ignore violations when these files are the importer
ignoreImporters: ['**/*.test.ts', '**/*.spec.ts'],
}
When a violation is detected, the plugin shows a diagnostic message with the full import chain that led to the violation, a code snippet highlighting the offending line, and actionable suggestions.
This example shows a *.server.* file being imported transitively in the client environment:
[import-protection] Import denied in client environment
Denied by file pattern: **/*.server.*
Importer: src/features/auth/session.ts:5:27
Import: "../db/queries.server"
Resolved: src/db/queries.server.ts
Trace:
1. src/routes/index.tsx:2:34 (entry) (import "../features/auth/session")
2. src/features/auth/session.ts:5:27 (import "../db/queries.server")
Code:
3 | import { logger } from '../utils/logger'
4 |
> 5 | import { getUsers } from '../db/queries.server'
| ^
6 |
7 | export function loadAuth() {
src/features/auth/session.ts:5:27
Suggestions:
- Wrap in createServerFn().handler(() => ...) to make it callable from the client via RPC
- Wrap in createServerOnlyFn(() => ...) if it should not be callable from the client
- Use createIsomorphicFn().client(() => ...).server(() => ...) for environment-specific implementations
- Split the file so client-safe exports are separate
This example shows a *.client.* file imported in the SSR environment. Because the code snippet contains JSX, the <ClientOnly> suggestion is shown first:
[import-protection] Import denied in server environment
Denied by file pattern: **/*.client.*
Importer: src/components/dashboard.tsx:3:30
Import: "./browser-widget.client"
Resolved: src/components/browser-widget.client.tsx
Trace:
1. src/routes/dashboard.tsx:1:32 (entry) (import "../components/dashboard")
2. src/components/dashboard.tsx:3:30 (import "./browser-widget.client")
Code:
1 | import { BrowserWidget } from './browser-widget.client'
2 |
> 3 | export function Dashboard() { return <BrowserWidget /> }
| ^
4 |
src/components/dashboard.tsx:3:30
Suggestions:
- Wrap in <ClientOnly fallback={...}>...</ClientOnly> to render only after hydration
- Wrap in createClientOnlyFn(() => ...) if it should only run in the browser
- Use createIsomorphicFn().client(() => ...).server(() => ...) for environment-specific implementations
- Split the file so server-safe exports are separate
Each violation message has these sections:
| Section | Description |
|---|---|
| Header | Environment type where the violation occurred ("client" or "server") |
| Denied by | The rule that matched: file pattern, specifier pattern, or marker |
| Importer / Import / Resolved | The importing file (with file:line:col), the raw import string, and the resolved target path |
| Trace | The full import chain from the entry point to the denied import. Each step shows file:line:col and the import specifier used. Step 1 is always the entry point |
| Code | A source code snippet with a > marker on the offending line and a ^ caret pointing to the exact column |
| Suggestions | Actionable steps to fix the violation, tailored to the direction (server-in-client vs client-in-server) |
The trace reads top-to-bottom, from the entry point to the denied module. This helps you find where the chain starts so you can restructure your code.
It can look like Start "should have removed that server-only import". The important detail is that this is handled by the Start compiler:
In practice, when the compiler replaces a createServerFn() handler with a client RPC stub, it can also remove server-only imports that were only used by the removed implementation.
Example (client build):
import { getUsers } from './db/queries.server'
import { createServerFn } from '@tanstack/react-start'
export const fetchUsers = createServerFn().handler(async () => {
return getUsers()
})
Conceptually, the client build output becomes something like (simplified):
import { createClientRpc } from '@tanstack/react-start/client-rpc'
import { createServerFn } from '@tanstack/react-start'
// Compiler replaces the handler with a client RPC stub.
// (The id is generated by the compiler; treat it as an opaque identifier.)
export const fetchUsers = TanStackStart.createServerFn({
method: 'GET',
}).handler(createClientRpc('sha256:deadbeef...'))
// The server-only import is removed by the compiler.
If the import "leaks" into code that survives compilation, it stays live and import protection will still flag it:
import { getUsers } from './db/queries.server'
import { createServerFn } from '@tanstack/react-start'
// This is fine -- the server implementation is removed for the client build
export const fetchUsers = createServerFn().handler(async () => {
return getUsers()
})
// This keeps the import alive in the client build
export function leakyHelper() {
return getUsers() // referenced outside server boundary
}
When this happens, you have a few options depending on what you want leakyHelper to be:
Option A: split the file so client code cannot accidentally import the leak
// src/users.server.ts
import { getUsers } from './db/queries.server'
import { createServerFn } from '@tanstack/react-start'
// Safe to import from client code (compiler rewrites the handler)
export const fetchUsers = createServerFn().handler(async () => {
return getUsers()
})
// src/users-leaky.server.ts
import { getUsers } from './db/queries.server'
// Server-only helper; do not import this from client code
export function leakyHelper() {
return getUsers()
}
Option B: keep it in the same file, but wrap the helper in createServerOnlyFn
This is useful when the helper should exist, but must never run on the client. Make sure the server-only import is only referenced inside the createServerOnlyFn(() => ...) callback:
import { createServerOnlyFn } from '@tanstack/react-start'
import { getUsers } from './db/queries.server'
export const leakyHelper = createServerOnlyFn(() => {
return getUsers()
})
On the client, the compiler output is effectively:
export const leakyHelper = () => {
throw new Error(
'createServerOnlyFn() functions can only be called on the server!',
)
}
Notice that the createServerOnlyFn import is gone, and the server-only getUsers import is also gone because it is no longer referenced after compilation.
The same idea applies to createIsomorphicFn(): the compiler removes the non-target implementation and prunes anything that becomes unused.
If you see an import-protection violation for a file you expected to be "compiled away", check whether the import is referenced outside a compiler-recognized environment boundary (or is otherwise kept live by surviving code).
You can hook into violations for custom reporting or to override the verdict:
importProtection: {
onViolation: (info) => {
// info.env -- environment name (e.g. 'client', 'ssr', ...)
// info.envType -- 'client' or 'server'
// info.type -- 'specifier', 'file', or 'marker'
// info.specifier -- the raw import string
// info.importer -- absolute path of the importing file
// info.resolved -- absolute path of the resolved target (if available)
// info.trace -- array of { file, line?, column?, specifier? } objects
// info.snippet -- { lines, location } with the source code snippet (if available)
// info.message -- the formatted diagnostic message
// Return false to allow this specific import (override the denial)
if (info.specifier === 'some-special-case') {
return false
}
},
}
To disable import protection entirely:
importProtection: {
enabled: false,
}
interface ImportProtectionOptions {
enabled?: boolean
behavior?:
| 'error'
| 'mock'
| { dev?: 'error' | 'mock'; build?: 'error' | 'mock' }
log?: 'once' | 'always'
include?: Array<string | RegExp>
exclude?: Array<string | RegExp>
ignoreImporters?: Array<string | RegExp>
maxTraceDepth?: number
client?: {
specifiers?: Array<string | RegExp>
files?: Array<string | RegExp>
}
server?: {
specifiers?: Array<string | RegExp>
files?: Array<string | RegExp>
}
onViolation?: (info: ViolationInfo) => boolean | void
}
| Option | Type | Default | Description |
|---|---|---|---|
| enabled | boolean | true | Set to false to disable the plugin |
| behavior | string | object | { dev: 'mock', build: 'error' } | What to do on violation |
| log | 'once' | 'always' | 'once' | Whether to deduplicate repeated violations |
| include | Pattern[] | Start's srcDirectory | Only check importers matching these patterns |
| exclude | Pattern[] | [] | Skip importers matching these patterns |
| ignoreImporters | Pattern[] | [] | Ignore violations from these importers |
| maxTraceDepth | number | 20 | Maximum depth for import traces |
| client | object | See defaults above | Additional deny rules for the client environment |
| server | object | See defaults above | Additional deny rules for the server environment |
| onViolation | function | undefined | Callback invoked on every violation |