Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b26da8a
add manual server fn id option and literal extraction
LadyBluenotes Jun 25, 2026
f9c10d6
add manual server fn ids with reservation and validation
LadyBluenotes Jun 25, 2026
da14d36
stabalize manual server fn ids across chains and shared compilers
LadyBluenotes Jun 25, 2026
b0be51f
deduplicate generated custom IDs and enforce manual ID constraints in…
LadyBluenotes Jun 25, 2026
6e015f7
add tests to preserve static manual IDs with unrelated spreads and co…
LadyBluenotes Jun 25, 2026
9a32289
dedupe generated server fn ids against known ids and reject computed …
LadyBluenotes Jun 25, 2026
a8a2739
add test to reuse canonical known server fn IDs without suffixing
LadyBluenotes Jun 25, 2026
e81f6d6
docs
LadyBluenotes Jun 25, 2026
6bc295e
clarify behavior of manual IDs in server functions and update documen…
LadyBluenotes Jun 25, 2026
20c9157
add validation to prevent duplicate manual server function IDs and en…
LadyBluenotes Jun 25, 2026
deb212f
ci: apply automated fixes
autofix-ci[bot] Jun 25, 2026
cb51cfb
remove redundant tests for template literal manual IDs in createServerFn
LadyBluenotes Jun 25, 2026
d59e3c8
add validation to ensure static string literals or constant bindings …
LadyBluenotes Jun 25, 2026
7bd7dd2
refactor tests for createServerFn to use parameterized test cases for…
LadyBluenotes Jun 25, 2026
7947990
cleanup tests
LadyBluenotes Jun 25, 2026
f75b7bd
Merge branch 'stable-serverfn-ids-2' of https://github.com/TanStack/r…
LadyBluenotes Jun 25, 2026
b96a0c6
ci: apply automated fixes
autofix-ci[bot] Jun 25, 2026
87fa40a
Merge branch 'main' into stable-serverfn-ids-2
LadyBluenotes Jun 25, 2026
230cdcc
fix linting error
LadyBluenotes Jun 25, 2026
148dfab
Merge branch 'stable-serverfn-ids-2' of https://github.com/TanStack/r…
LadyBluenotes Jun 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions docs/start/framework/react/guide/server-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,71 @@ The build process replaces server function implementations with RPC stubs in cli
> const { getUser } = await import('~/utils/users.functions')
> ```

## Manual Server Function IDs

By default, TanStack Start generates a function ID for each server function. You can optionally provide a manual ID when you want a stable, explicit identifier under your control.

Use manual IDs when you need predictable identifiers across refactors, clearer diagnostics, or explicit naming conventions.

```tsx
import { createServerFn } from '@tanstack/react-start'

export const getUser = createServerFn({ method: 'GET', id: 'get-user' })
.validator((data: { userId: string }) => data)
.handler(async ({ data }) => {
return findUserById(data.userId)
})
```

### Rules for Manual IDs

Manual IDs must follow these rules:

1. Allowed characters are letters, numbers, underscore, and dash.
2. The ID must match this pattern: `[a-zA-Z0-9_-]+`.
3. The ID must be statically analyzable:
- String literal is allowed.
- Constant string binding is allowed.
- Computed ID keys are not supported.

```tsx
// ✅ literal string ID
createServerFn({ id: 'create-user' }).handler(async () => {
return { ok: true }
})

// ✅ constant string binding ID
const userLookupId = 'lookup-user'
createServerFn({ id: userLookupId }).handler(async () => {
return { ok: true }
})

// ❌ computed ID key is not supported
const key = 'id'
createServerFn({ [key]: 'create-user' }).handler(async () => {
return { ok: true }
})
```

### Automatic and Manual IDs

Automatic IDs are the default and work for most apps. Provide a manual `id` only when you want a fixed, recognizable identifier (for example, stable values in logs or analytics).

Switching between automatic and manual IDs does not change how you call a server function. The call signature, arguments, and return value stay the same, but the server function lookup ID and request URL change to use the manual ID.

Manual IDs are exact reservations. If a generated or custom `generateFunctionId` value collides with a manual ID, the generated value is suffixed instead. If two manual IDs collide, compilation fails.

### Security Caveats

Manual IDs improve stability and readability, but they are not a security feature.

1. Do not treat function IDs as secrets.
2. Do not rely on manual IDs for authorization.
3. Authorize every server function in middleware or inside the handler.
4. Keep same-origin and CSRF protections enabled for server function endpoints.

Changing from automatic IDs to manual IDs does not change your security boundary. Your security boundary remains the server-side authorization and request protection checks.

## Parameters & Validation

Server functions accept a single `data` parameter. Since they cross the network boundary, validation ensures type safety and runtime correctness.
Expand Down
2 changes: 2 additions & 0 deletions packages/start-client-core/src/createServerFn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export interface ServerFnOptions<
> {
method?: TMethod
strict?: TStrict
id?: string
}

export type ServerFnStrictInput<TStrict extends ServerFnStrict> =
Expand Down Expand Up @@ -502,6 +503,7 @@ export type ServerFnBaseOptions<
> = {
method: TMethod
strict?: TStrict
id?: string
middleware?: Constrain<
TMiddlewares,
ReadonlyArray<AnyFunctionMiddleware | AnyRequestMiddleware>
Expand Down
2 changes: 2 additions & 0 deletions packages/start-client-core/src/tests/createServerFn.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ test('createServerFn without middleware', () => {
// TODO remove upon stable
expectTypeOf(createServerFn()).toHaveProperty('inputValidator')

expectTypeOf(createServerFn({ id: 'get-user' })).toHaveProperty('handler')

createServerFn({ method: 'GET' }).handler((options) => {
expectTypeOf(options).toEqualTypeOf<{
context: undefined
Expand Down
136 changes: 112 additions & 24 deletions packages/start-plugin-core/src/start-compiler/compiler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import crypto from 'node:crypto'
import * as t from '@babel/types'
import path from 'pathe'
import {
deadCodeElimination,
extractModuleInfoFromAst,
Expand Down Expand Up @@ -514,6 +515,9 @@ export class StartCompiler {
string,
Map<string, ExportResolution | null>
>()
private reservedManualFunctionIds = new Set<string>()
private reservedManualFunctionIdOwners = new Map<string, string>()
private reservedManualFunctionIdsByFilename = new Map<string, Set<string>>()
// Fast lookup for direct imports from known libraries (e.g., '@tanstack/react-start')
// Maps: libName → (exportName → Kind)
// This allows O(1) resolution for the common case without async resolveId calls
Expand Down Expand Up @@ -612,37 +616,22 @@ export class StartCompiler {
extractedFilename: string
}): string {
if (this.mode === 'dev') {
// In dev, encode the file path and export name for direct lookup.
// Each bundler adapter supplies its own strategy for encoding
// module specifiers that work with its dev server runtime.
const encodeModuleSpecifier =
this.options.devServerFnModuleSpecifierEncoder
if (!encodeModuleSpecifier) {
throw new Error(
'devServerFnModuleSpecifierEncoder is required in dev mode.',
)
}
const file = encodeModuleSpecifier({
extractedFilename: opts.extractedFilename,
root: this.options.root,
})

const serverFn = {
file,
export: opts.functionName,
}
return Buffer.from(JSON.stringify(serverFn), 'utf8').toString('base64url')
return this.generateDevFunctionId(opts)
}

// Production build: use custom generator or hash
const entryId = `${opts.filename}--${opts.functionName}`
let functionId = this.entryIdToFunctionId.get(entryId)
if (functionId === undefined) {
const knownFn = Object.values(this.options.getKnownServerFns()).find(
const knownServerFns = this.options.getKnownServerFns()
const knownFn = Object.values(knownServerFns).find(
(serverFn) =>
serverFn.functionName === opts.functionName &&
serverFn.extractedFilename === opts.extractedFilename,
)
const knownFunctionIds = new Set(
Object.values(knownServerFns).map((serverFn) => serverFn.functionId),
)

if (knownFn) {
functionId = knownFn.functionId
Expand All @@ -657,13 +646,23 @@ export class StartCompiler {
if (!functionId) {
functionId = crypto.createHash('sha256').update(entryId).digest('hex')
}
// Deduplicate in case the generated id conflicts with an existing id
if (this.functionIds.has(functionId)) {
const isCanonicalKnownMatch = knownFn?.functionId === functionId
// Deduplicate generated/custom IDs so manual reservations stay exact
// without making the older generateFunctionId hook a breaking change.
if (
this.functionIds.has(functionId) ||
this.reservedManualFunctionIds.has(functionId) ||
(!isCanonicalKnownMatch && knownFunctionIds.has(functionId))
) {
let deduplicatedId
let iteration = 0
do {
deduplicatedId = `${functionId}_${++iteration}`
} while (this.functionIds.has(deduplicatedId))
} while (
this.functionIds.has(deduplicatedId) ||
this.reservedManualFunctionIds.has(deduplicatedId) ||
knownFunctionIds.has(deduplicatedId)
)
functionId = deduplicatedId
}
this.entryIdToFunctionId.set(entryId, functionId)
Expand All @@ -672,6 +671,78 @@ export class StartCompiler {
return functionId
}

private reserveFunctionId(opts: {
filename: string
functionName: string
extractedFilename: string
functionId: string
}) {
const entryId = `${opts.filename}--${opts.functionName}`
const existingOwner = this.reservedManualFunctionIdOwners.get(
opts.functionId,
)
const knownFn = this.options.getKnownServerFns()[opts.functionId]

if (
knownFn &&
(knownFn.functionName !== opts.functionName ||
knownFn.extractedFilename !== opts.extractedFilename)
) {
throw new Error(`Duplicate server function id: ${opts.functionId}`)
}

if (existingOwner && existingOwner !== entryId) {
throw new Error(`Duplicate server function id: ${opts.functionId}`)
}

if (!existingOwner && this.functionIds.has(opts.functionId)) {
throw new Error(`Duplicate server function id: ${opts.functionId}`)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (!existingOwner) {
this.reservedManualFunctionIdOwners.set(opts.functionId, entryId)
this.reservedManualFunctionIds.add(opts.functionId)

let reservedIds = this.reservedManualFunctionIdsByFilename.get(
opts.filename,
)
if (!reservedIds) {
reservedIds = new Set<string>()
this.reservedManualFunctionIdsByFilename.set(opts.filename, reservedIds)
}
reservedIds.add(opts.functionId)
}

return this.mode === 'dev'
? this.generateDevFunctionId(opts)
: opts.functionId
}

private generateDevFunctionId(opts: {
functionName: string
extractedFilename: string
}): string {
// In dev, encode the file path and export name for direct lookup.
// Each bundler adapter supplies its own strategy for encoding
// module specifiers that work with its dev server runtime.
const encodeModuleSpecifier = this.options.devServerFnModuleSpecifierEncoder
if (!encodeModuleSpecifier) {
throw new Error(
'devServerFnModuleSpecifierEncoder is required in dev mode.',
)
}
const file = encodeModuleSpecifier({
extractedFilename: opts.extractedFilename,
root: this.options.root,
})

const serverFn = {
file,
export: opts.functionName,
}
return Buffer.from(JSON.stringify(serverFn), 'utf8').toString('base64url')
}

private get mode(): 'dev' | 'build' {
return this.options.mode ?? 'dev'
}
Expand Down Expand Up @@ -871,6 +942,7 @@ export class StartCompiler {
for (const moduleId of Array.from(this.moduleCache.keys())) {
const normalizedModuleId = cleanId(moduleId)
if (normalizedIds.has(normalizedModuleId)) {
this.clearReservedManualFunctionIdsForModule(normalizedModuleId)
this.moduleCache.delete(moduleId)
deletedModuleIds.add(normalizedModuleId)
}
Expand All @@ -895,6 +967,21 @@ export class StartCompiler {
return deletedModuleIds
}

private clearReservedManualFunctionIdsForModule(moduleId: string): void {
const relativeFilename = path.relative(this.options.root, moduleId)
const reservedIds =
this.reservedManualFunctionIdsByFilename.get(relativeFilename)
if (!reservedIds) {
return
}

for (const functionId of reservedIds) {
this.reservedManualFunctionIds.delete(functionId)
this.reservedManualFunctionIdOwners.delete(functionId)
}
this.reservedManualFunctionIdsByFilename.delete(relativeFilename)
}

public async getTransitiveImporters(
ids: string | Iterable<string>,
): Promise<Set<string>> {
Expand Down Expand Up @@ -1368,6 +1455,7 @@ export class StartCompiler {
warn: warnFn,

generateFunctionId: (opts) => this.generateFunctionId(opts),
reserveFunctionId: (opts) => this.reserveFunctionId(opts),
getKnownServerFns: this.options.getKnownServerFns,
serverFnProviderModuleDirectives:
this.options.serverFnProviderModuleDirectives,
Expand Down
Loading
Loading