Repository: TanStack/router
Pull request #2942 (2024-12-07 13:26:55)

feat(start): createIsomorphicFn
see https://github.com/TanStack/router/discussions/2937 for a full brief of this idea.

Allows creating a function with different implementations for server and client.

```ts
import { getRequestURL } from "vinxi/http"

const getIsomorphicUrl = createIsomorphicFn()
  .server(getRequestURL)
  .client(() => new URL(window.location.href));

// server
import { getRequestURL } from "vinxi/http"

const getIsomorphicUrl = getRequestURL

// client
const getIsomorphicUrl = () => new URL(window.location.href)
```

Any implementations not provided will default to no-ops.
```ts
const serverOnlyFn = createIsomorphicFn().server(() => "foo");

// server
const serverOnlyFn = () => "foo"

// client
const serverOnlyFn = () => {}
```

Not providing any implementations is allowed, but a warning will be logged as this is likely a mistake.
```ts
const noImpl = createIsomorphicFn()

// server
const noImpl = () => {}

// client
const noImpl = () => {}

// error logged during build (open to rewording)
// 
// createIsomorphicFn called without a client or server implementation! 
// This will result in a no-op function. Variable name: noImpl
```

Implementations should accept the same arguments, but can return different results.

nx-cloud[bot]: # ☁️ Nx Cloud Report

CI is running for commit 24a54e49f4700fdd8beb2a89b270912a47d87943.

[📂 Click to track the progress, see the status, the terminal output, and the build insights.](https://cloud.nx.app/cipes/67544d3b5e23017d7f3e39d6?utm_source=pull-request&utm_medium=comment)


---

Sent with 💌 from [NxCloud.](https://nx.app?utm_source=pull-request&utm_medium=comment)
pkg-pr-new[bot]: 
[Open in Stackblitz](https://pkg.pr.new/template/2221fb64-ae99-4b7b-a51b-c6c2cbbba1e3)
<details><summary><b>More templates</b></summary><p>

- [tanstack-router-react-example-authenticated-routes](https://pkg.pr.new/template/a806bdfc-f239-4c64-9020-6e2c6d37890e)
- [tanstack-router-react-example-basic](https://pkg.pr.new/template/c4e9f5b3-c591-42bb-ba4e-f232e663382a)
- [tanstack-router-react-example-basic-file-based](https://pkg.pr.new/template/814ba650-31e0-44f8-a6d7-7da37e9bb612)
- [tanstack-router-react-example-basic-default-search-params](https://pkg.pr.new/template/c2140281-3030-4ff0-ba30-41d5cb5cc924)
- [tanstack-router-react-example-react-query](https://pkg.pr.new/template/fac0c435-0baa-4822-9a3f-13bc40368bac)
- [tanstack-router-react-example-basic-file-based-codesplitting](https://pkg.pr.new/template/8f969ef1-e916-4f5d-9ac0-234940c403c9)
- [tanstack-router-react-example-basic-react-query-file-based](https://pkg.pr.new/template/bad34c83-2ef7-4f88-a756-a0438d8c189a)
- [tanstack-router-react-example-basic-ssr-file-based](https://pkg.pr.new/template/43b7c95a-6838-4a71-be4c-903ff91b0b53)
- [tanstack-router-react-example-basic-ssr-streaming-file-based](https://pkg.pr.new/template/3ea0eafe-842d-43f6-b08b-b23790dc303e)
- [tanstack-router-react-example-basic-virtual-file-based](https://pkg.pr.new/template/34e03586-dac9-4dc0-a9da-19d099de56d5)
- [tanstack-router-react-example-basic-virtual-inside-file-based](https://pkg.pr.new/template/880c854e-bb14-4ba3-ba3e-452164b2359a)
- [tanstack-router-react-example-deferred-data](https://pkg.pr.new/template/54699351-3868-4fff-99be-8f84344973e9)
- [tanstack-router-react-example-kitchen-sink-file-based](https://pkg.pr.new/template/7603a202-3daa-4fe0-b3cf-eea157010c54)
- [tanstack-router-react-example-kitchen-sink](https://pkg.pr.new/template/d8b5263c-534d-46f0-8591-181753b4f243)
- [tanstack-router-react-example-kitchen-sink-react-query](https://pkg.pr.new/template/eddaf760-541c-47e4-92b4-7b36408072ca)
- [tanstack-router-react-example-kitchen-sink-react-query-file-based](https://pkg.pr.new/template/647401c7-581b-4717-913b-af0ac93300ba)
- [tanstack-router-react-example-large-file-based](https://pkg.pr.new/template/7004ebdb-5c14-4a1a-8fbd-f422614a0ce0)
- [tanstack-router-react-example-location-masking](https://pkg.pr.new/template/72ed38ed-5592-42d6-97e5-247ed62c0cc1)
- [tanstack-router-react-example-navigation-blocking](https://pkg.pr.new/template/def73452-f83d-42a4-ab87-0022912253e2)
- [tanstack-router-react-example-quickstart](https://pkg.pr.new/template/7f8941a4-1d2f-48a0-a284-905a93e431f8)
- [tanstack-router-react-example-quickstart-esbuild-file-based](https://pkg.pr.new/template/5a5f842d-0d41-4a99-b0fa-365fe52c4117)
- [tanstack-router-react-example-quickstart-file-based](https://pkg.pr.new/template/c7489915-9f72-44cc-9f58-9ebc135820f0)
- [tanstack-router-react-example-quickstart-rspack-file-based](https://pkg.pr.new/template/b7793331-6afc-4e5e-a1db-f6e79579d277)
- [tanstack-router-react-example-quickstart-webpack-file-based](https://pkg.pr.new/template/8c425a8d-922e-4bf6-8cb2-e7a6bd4082c9)
- [router-monorepo-react-query](https://pkg.pr.new/template/00cc30db-ca84-4aaf-ab5a-844acf799d82)
- [router-mono-simple](https://pkg.pr.new/template/94876bbd-9cf1-45a2-88b8-46aec0fe9090)
- [tanstack-router-react-example-scroll-restoration](https://pkg.pr.new/template/4f81d4fa-7d68-4591-850f-f6731bd7310d)
- [tanstack-search-validator-adapters](https://pkg.pr.new/template/aa1e6fbc-099e-4ea6-b08f-e479a1ae7e27)
- [tanstack-start-example-basic](https://pkg.pr.new/template/6fb53cde-12a9-42f2-a1f1-21f97497fb65)
- [tanstack-start-example-basic-auth](https://pkg.pr.new/template/de5713bc-0547-4a5c-9308-87a228a392f7)
- [tanstack-start-example-basic-react-query](https://pkg.pr.new/template/6309b8c8-24b9-4fd6-9105-8494e18e82be)
- [tanstack-start-example-basic-rsc](https://pkg.pr.new/template/446a6f9c-2701-474c-bf60-4e73889d362e)
- [tanstack-start-example-clerk-basic](https://pkg.pr.new/template/c66caf15-58b5-49fa-ad7a-3dc6fc0934d4)
- [tanstack-start-example-counter](https://pkg.pr.new/template/a5739a8e-3898-4130-a343-d1522d560458)
- [tanstack-start-example-convex-trellaux](https://pkg.pr.new/template/58b960b8-9ab6-486b-b3d8-fd401eb51085)
- [tanstack-start-example-large](https://pkg.pr.new/template/c53f37a4-8c8f-4b1e-90af-9d4a5334bf0c)
- [tanstack-start-example-supabase-basic](https://pkg.pr.new/template/89eceaae-1d30-4c3b-ae01-d6702d626866)
- [tanstack-start-example-trellaux](https://pkg.pr.new/template/08e8abe4-003a-458b-bf86-7c3a26753a27)
- [tanstack-router-react-example-with-framer-motion](https://pkg.pr.new/template/d3d7fe74-03cf-42d5-8a3b-4dfb9172d873)
- [tanstack-router-react-example-with-trpc](https://pkg.pr.new/template/475fb7b5-5231-49f8-be0f-d1ecd822cc39)
- [tanstack-router-react-example-with-trpc-react-query](https://pkg.pr.new/template/d3bff3ff-9063-4bf5-863b-2dbcf1b97556)

</p></details>
      
    


<details><summary><b>@tanstack/arktype-adapter</b></summary><p>

```
npm i https://pkg.pr.new/@tanstack/arktype-adapter@2942
```

</p></details>
      
    

<details><summary><b>@tanstack/create-router</b></summary><p>

```
npm i https://pkg.pr.new/@tanstack/create-router@2942
```

</p></details>
      
    

<details><summary><b>@tanstack/history</b></summary><p>

```
npm i https://pkg.pr.new/@tanstack/history@2942
```

</p></details>
      
    

<details><summary><b>@tanstack/eslint-plugin-router</b></summary><p>

```
npm i https://pkg.pr.new/@tanstack/eslint-plugin-router@2942
```

</p></details>
      
    

<details><summary><b>@tanstack/react-cross-context</b></summary><p>

```
npm i https://pkg.pr.new/@tanstack/react-cross-context@2942
```

</p></details>
      
    

<details><summary><b>@tanstack/react-router</b></summary><p>

```
npm i https://pkg.pr.new/@tanstack/react-router@2942
```

</p></details>
      
    

<details><summary><b>@tanstack/react-router-with-query</b></summary><p>

```
npm i https://pkg.pr.new/@tanstack/react-router-with-query@2942
```

</p></details>
      
    

<details><summary><b>@tanstack/router-cli</b></summary><p>

```
npm i https://pkg.pr.new/@tanstack/router-cli@2942
```

</p></details>
      
    

<details><summary><b>@tanstack/router-devtools</b></summary><p>

```
npm i https://pkg.pr.new/@tanstack/router-devtools@2942
```

</p></details>
      
    

<details><summary><b>@tanstack/router-generator</b></summary><p>

```
npm i https://pkg.pr.new/@tanstack/router-generator@2942
```

</p></details>
      
    

<details><summary><b>@tanstack/router-plugin</b></summary><p>

```
npm i https://pkg.pr.new/@tanstack/router-plugin@2942
```

</p></details>
      
    

<details><summary><b>@tanstack/router-vite-plugin</b></summary><p>

```
npm i https://pkg.pr.new/@tanstack/router-vite-plugin@2942
```

</p></details>
      
    

<details><summary><b>@tanstack/start</b></summary><p>

```
npm i https://pkg.pr.new/@tanstack/start@2942
```

</p></details>
      
    

<details><summary><b>@tanstack/start-vite-plugin</b></summary><p>

```
npm i https://pkg.pr.new/@tanstack/start-vite-plugin@2942
```

</p></details>
      
    

<details><summary><b>@tanstack/valibot-adapter</b></summary><p>

```
npm i https://pkg.pr.new/@tanstack/valibot-adapter@2942
```

</p></details>
      
    

<details><summary><b>@tanstack/virtual-file-routes</b></summary><p>

```
npm i https://pkg.pr.new/@tanstack/virtual-file-routes@2942
```

</p></details>
      
    

<details><summary><b>@tanstack/zod-adapter</b></summary><p>

```
npm i https://pkg.pr.new/@tanstack/zod-adapter@2942
```

</p></details>
      
    

_commit: <a href="https://github.com/TanStack/router/runs/34072697856"><code>24a54e4</code></a>_


Patch:
diff --git a/docs/framework/react/devtools.md b/docs/framework/react/devtools.md
index df3435a05..36d54bdbb 100644
--- a/docs/framework/react/devtools.md
+++ b/docs/framework/react/devtools.md
@@ -12,8 +12,28 @@ When you begin your TanStack Router journey, you'll want these devtools by your
 
 ## Installation
 
+The devtools are a separate package that you need to install:
+
+```sh
+npm install -D @tanstack/router-devtools
+```
+
+or
+
+```sh
+pnpm add -D @tanstack/router-devtools
+```
+
+or
+
+```sh
+yarn add -D @tanstack/router-devtools
+```
+
+or
+
 ```sh
-npm install @tanstack/router-devtools --save
+bun add -D @tanstack/router-devtools
 ```
 
 ## Import the Devtools
diff --git a/packages/create-router/src/utils/copyTemplateFiles.ts b/packages/create-router/src/utils/copyTemplateFiles.ts
index 1ab212583..b557a0340 100644
--- a/packages/create-router/src/utils/copyTemplateFiles.ts
+++ b/packages/create-router/src/utils/copyTemplateFiles.ts
@@ -45,7 +45,7 @@ async function copy(src: string, dest: string) {
   const statResult = await stat(src)
   const replacedDest = dest.replace(DOT_PREFIX, '.')
   if (statResult.isDirectory()) {
-    copyDir(src, replacedDest)
+    await copyDir(src, replacedDest)
   } else {
     await copyFile(src, replacedDest)
   }
diff --git a/packages/start-vite-plugin/src/compilers.ts b/packages/start-vite-plugin/src/compilers.ts
index a3b7d617e..a2117d649 100644
--- a/packages/start-vite-plugin/src/compilers.ts
+++ b/packages/start-vite-plugin/src/compilers.ts
@@ -59,6 +59,7 @@ export function compileStartOutput(opts: ParseAstOptions) {
           createServerFn: IdentifierConfig
           createMiddleware: IdentifierConfig
           serverOnly: IdentifierConfig
+          createIsomorphicFn: IdentifierConfig
         } = {
           createServerFn: {
             name: 'createServerFn',
@@ -81,6 +82,13 @@ export function compileStartOutput(opts: ParseAstOptions) {
             handleCallExpression: handleServerOnlyCallExpression,
             paths: [],
           },
+          createIsomorphicFn: {
+            name: 'createIsomorphicFn',
+            type: 'ImportSpecifier',
+            namespaceId: '',
+            handleCallExpression: handleCreateIsomorphicFnCallExpression,
+            paths: [],
+          },
         }
 
         const identifierKeys = Object.keys(identifiers) as Array<
@@ -572,6 +580,82 @@ function handleServerOnlyCallExpression(
   )
 }
 
+function handleCreateIsomorphicFnCallExpression(
+  path: babel.NodePath<t.CallExpression>,
+  opts: ParseAstOptions,
+) {
+  const rootCallExpression = getRootCallExpression(path)
+
+  if (debug)
+    console.info(
+      'Handling createIsomorphicFn call expression:',
+      rootCallExpression.toString(),
+    )
+
+  // Check if the call is assigned to a variable
+  if (!rootCallExpression.parentPath.isVariableDeclarator()) {
+    throw new Error('createIsomorphicFn must be assigned to a variable!')
+  }
+
+  const callExpressionPaths = {
+    client: null as babel.NodePath<t.CallExpression> | null,
+    server: null as babel.NodePath<t.CallExpression> | null,
+  }
+
+  const validMethods = Object.keys(callExpressionPaths)
+
+  rootCallExpression.traverse({
+    MemberExpression(memberExpressionPath) {
+      if (t.isIdentifier(memberExpressionPath.node.property)) {
+        const name = memberExpressionPath.node.property
+          .name as keyof typeof callExpressionPaths
+
+        if (
+          validMethods.includes(name) &&
+          memberExpressionPath.parentPath.isCallExpression()
+        ) {
+          callExpressionPaths[name] = memberExpressionPath.parentPath
+        }
+      }
+    },
+  })
+
+  if (
+    validMethods.every(
+      (method) =>
+        !callExpressionPaths[method as keyof typeof callExpressionPaths],
+    )
+  ) {
+    const variableId = rootCallExpression.parentPath.node.id
+    console.warn(
+      'createIsomorphicFn called without a client or server implementation!',
+      'This will result in a no-op function.',
+      'Variable name:',
+      t.isIdentifier(variableId) ? variableId.name : 'unknown',
+    )
+  }
+
+  const envCallExpression = callExpressionPaths[opts.env]
+
+  if (!envCallExpression) {
+    // if we don't have an implementation for this environment, default to a no-op
+    rootCallExpression.replaceWith(
+      t.arrowFunctionExpression([], t.blockStatement([])),
+    )
+    return
+  }
+
+  const innerInputExpression = envCallExpression.node.arguments[0]
+
+  if (!t.isExpression(innerInputExpression)) {
+    throw new Error(
+      `createIsomorphicFn().${opts.env}(func) must be called with a function!`,
+    )
+  }
+
+  rootCallExpression.replaceWith(innerInputExpression)
+}
+
 function getRootCallExpression(path: babel.NodePath<t.CallExpression>) {
   // Find the highest callExpression parent
   let rootCallExpression: babel.NodePath<t.CallExpression> = path
diff --git a/packages/start-vite-plugin/src/index.ts b/packages/start-vite-plugin/src/index.ts
index 2298e2ab9..55e0b2231 100644
--- a/packages/start-vite-plugin/src/index.ts
+++ b/packages/start-vite-plugin/src/index.ts
@@ -26,11 +26,12 @@ export function TanStackStartViteServerFn(
       url.searchParams.delete('v')
       id = fileURLToPath(url).replace(/\\/g, '/')
 
-      const includesToken = /createServerFn|createMiddleware|serverOnly/.test(
-        code,
-      )
+      const includesToken =
+        /createServerFn|createMiddleware|serverOnly|createIsomorphicFn/.test(
+          code,
+        )
       const includesEitherFunc =
-        /(function createServerFn|function createMiddleware|function serverOnly)/.test(
+        /(function createServerFn|function createMiddleware|function serverOnly|function createIsomorphicFn)/.test(
           code,
         )
 
@@ -93,7 +94,9 @@ export function TanStackStartViteDeadCodeElimination(
 
       if (
         code.includes('createServerFn') ||
-        code.includes('createMiddleware')
+        code.includes('createMiddleware') ||
+        code.includes('serverOnly') ||
+        code.includes('createIsomorphicFn')
       ) {
         const compiled = compileEliminateDeadCode({
           code,
diff --git a/packages/start/src/client/createIsomorphicFn.ts b/packages/start/src/client/createIsomorphicFn.ts
new file mode 100644
index 000000000..8703bd465
--- /dev/null
+++ b/packages/start/src/client/createIsomorphicFn.ts
@@ -0,0 +1,36 @@
+// a function that can have different implementations on the client and server.
+// implementations not provided will default to a no-op function.
+
+export type IsomorphicFn<
+  TArgs extends Array<any> = [],
+  TServer = undefined,
+  TClient = undefined,
+> = (...args: TArgs) => TServer | TClient
+
+export interface ServerOnlyFn<TArgs extends Array<any>, TServer>
+  extends IsomorphicFn<TArgs, TServer> {
+  client: <TClient>(
+    clientImpl: (...args: TArgs) => TClient,
+  ) => IsomorphicFn<TArgs, TServer, TClient>
+}
+
+export interface ClientOnlyFn<TArgs extends Array<any>, TClient>
+  extends IsomorphicFn<TArgs, undefined, TClient> {
+  server: <TServer>(
+    serverImpl: (...args: TArgs) => TServer,
+  ) => IsomorphicFn<TArgs, TServer, TClient>
+}
+
+export interface IsomorphicFnBase extends IsomorphicFn {
+  server: <TArgs extends Array<any>, TServer>(
+    serverImpl: (...args: TArgs) => TServer,
+  ) => ServerOnlyFn<TArgs, TServer>
+  client: <TArgs extends Array<any>, TClient>(
+    clientImpl: (...args: TArgs) => TClient,
+  ) => ClientOnlyFn<TArgs, TClient>
+}
+
+// this is a dummy function, it will be replaced by the transformer
+export function createIsomorphicFn(): IsomorphicFnBase {
+  return null!
+}
diff --git a/packages/start/src/client/index.tsx b/packages/start/src/client/index.tsx
index 63db8db20..ea73a9641 100644
--- a/packages/start/src/client/index.tsx
+++ b/packages/start/src/client/index.tsx
@@ -1,5 +1,12 @@
 /// <reference types="vinxi/types/client" />
 export { Asset } from './Asset'
+export {
+  createIsomorphicFn,
+  type IsomorphicFn,
+  type ServerOnlyFn,
+  type ClientOnlyFn,
+  type IsomorphicFnBase,
+} from './createIsomorphicFn'
 export {
   createServerFn,
   type JsonResponse,


Test patch:
diff --git a/e2e/start/basic/app/routeTree.gen.ts b/e2e/start/basic/app/routeTree.gen.ts
index 5668aecde..2ab05c9e3 100644
--- a/e2e/start/basic/app/routeTree.gen.ts
+++ b/e2e/start/basic/app/routeTree.gen.ts
@@ -17,6 +17,7 @@ import { Route as ServerFnsImport } from './routes/server-fns'
 import { Route as SearchParamsImport } from './routes/search-params'
 import { Route as RedirectImport } from './routes/redirect'
 import { Route as PostsImport } from './routes/posts'
+import { Route as IsomorphicFnsImport } from './routes/isomorphic-fns'
 import { Route as DeferredImport } from './routes/deferred'
 import { Route as LayoutImport } from './routes/_layout'
 import { Route as IndexImport } from './routes/index'
@@ -67,6 +68,12 @@ const PostsRoute = PostsImport.update({
   getParentRoute: () => rootRoute,
 } as any)
 
+const IsomorphicFnsRoute = IsomorphicFnsImport.update({
+  id: '/isomorphic-fns',
+  path: '/isomorphic-fns',
+  getParentRoute: () => rootRoute,
+} as any)
+
 const DeferredRoute = DeferredImport.update({
   id: '/deferred',
   path: '/deferred',
@@ -156,6 +163,13 @@ declare module '@tanstack/react-router' {
       preLoaderRoute: typeof DeferredImport
       parentRoute: typeof rootRoute
     }
+    '/isomorphic-fns': {
+      id: '/isomorphic-fns'
+      path: '/isomorphic-fns'
+      fullPath: '/isomorphic-fns'
+      preLoaderRoute: typeof IsomorphicFnsImport
+      parentRoute: typeof rootRoute
+    }
     '/posts': {
       id: '/posts'
       path: '/posts'
@@ -312,6 +326,7 @@ export interface FileRoutesByFullPath {
   '/': typeof IndexRoute
   '': typeof LayoutLayout2RouteWithChildren
   '/deferred': typeof DeferredRoute
+  '/isomorphic-fns': typeof IsomorphicFnsRoute
   '/posts': typeof PostsRouteWithChildren
   '/redirect': typeof RedirectRoute
   '/search-params': typeof SearchParamsRoute
@@ -331,6 +346,7 @@ export interface FileRoutesByTo {
   '/': typeof IndexRoute
   '': typeof LayoutLayout2RouteWithChildren
   '/deferred': typeof DeferredRoute
+  '/isomorphic-fns': typeof IsomorphicFnsRoute
   '/redirect': typeof RedirectRoute
   '/search-params': typeof SearchParamsRoute
   '/server-fns': typeof ServerFnsRoute
@@ -349,6 +365,7 @@ export interface FileRoutesById {
   '/': typeof IndexRoute
   '/_layout': typeof LayoutRouteWithChildren
   '/deferred': typeof DeferredRoute
+  '/isomorphic-fns': typeof IsomorphicFnsRoute
   '/posts': typeof PostsRouteWithChildren
   '/redirect': typeof RedirectRoute
   '/search-params': typeof SearchParamsRoute
@@ -371,6 +388,7 @@ export interface FileRouteTypes {
     | '/'
     | ''
     | '/deferred'
+    | '/isomorphic-fns'
     | '/posts'
     | '/redirect'
     | '/search-params'
@@ -389,6 +407,7 @@ export interface FileRouteTypes {
     | '/'
     | ''
     | '/deferred'
+    | '/isomorphic-fns'
     | '/redirect'
     | '/search-params'
     | '/server-fns'
@@ -405,6 +424,7 @@ export interface FileRouteTypes {
     | '/'
     | '/_layout'
     | '/deferred'
+    | '/isomorphic-fns'
     | '/posts'
     | '/redirect'
     | '/search-params'
@@ -426,6 +446,7 @@ export interface RootRouteChildren {
   IndexRoute: typeof IndexRoute
   LayoutRoute: typeof LayoutRouteWithChildren
   DeferredRoute: typeof DeferredRoute
+  IsomorphicFnsRoute: typeof IsomorphicFnsRoute
   PostsRoute: typeof PostsRouteWithChildren
   RedirectRoute: typeof RedirectRoute
   SearchParamsRoute: typeof SearchParamsRoute
@@ -439,6 +460,7 @@ const rootRouteChildren: RootRouteChildren = {
   IndexRoute: IndexRoute,
   LayoutRoute: LayoutRouteWithChildren,
   DeferredRoute: DeferredRoute,
+  IsomorphicFnsRoute: IsomorphicFnsRoute,
   PostsRoute: PostsRouteWithChildren,
   RedirectRoute: RedirectRoute,
   SearchParamsRoute: SearchParamsRoute,
@@ -461,6 +483,7 @@ export const routeTree = rootRoute
         "/",
         "/_layout",
         "/deferred",
+        "/isomorphic-fns",
         "/posts",
         "/redirect",
         "/search-params",
@@ -482,6 +505,9 @@ export const routeTree = rootRoute
     "/deferred": {
       "filePath": "deferred.tsx"
     },
+    "/isomorphic-fns": {
+      "filePath": "isomorphic-fns.tsx"
+    },
     "/posts": {
       "filePath": "posts.tsx",
       "children": [
diff --git a/e2e/start/basic/app/routes/isomorphic-fns.tsx b/e2e/start/basic/app/routes/isomorphic-fns.tsx
new file mode 100644
index 000000000..6797b3ff6
--- /dev/null
+++ b/e2e/start/basic/app/routes/isomorphic-fns.tsx
@@ -0,0 +1,61 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { createIsomorphicFn, createServerFn } from '@tanstack/start'
+import { useState } from 'react'
+
+const getEnv = createIsomorphicFn()
+  .server(() => 'server')
+  .client(() => 'client')
+const getServerEnv = createServerFn().handler(() => getEnv())
+
+const getEcho = createIsomorphicFn()
+  .server((input: string) => 'server received ' + input)
+  .client((input) => 'client received ' + input)
+const getServerEcho = createServerFn()
+  .validator((input: string) => input)
+  .handler(({ data }) => getEcho(data))
+
+export const Route = createFileRoute('/isomorphic-fns')({
+  component: RouteComponent,
+})
+
+function RouteComponent() {
+  const [results, setResults] = useState<Partial<Record<string, string>>>()
+  async function handleClick() {
+    const env = getEnv()
+    const echo = getEcho('hello')
+    const [serverEnv, serverEcho] = await Promise.all([
+      getServerEnv(),
+      getServerEcho({ data: 'hello' }),
+    ])
+    setResults({ env, echo, serverEnv, serverEcho })
+  }
+  const { env, echo, serverEnv, serverEcho } = results || {}
+  return (
+    <div>
+      <button onClick={handleClick} data-testid="test-isomorphic-results-btn">
+        Run
+      </button>
+      {!!results && (
+        <div>
+          <h1>
+            <code>getEnv</code>
+          </h1>
+          When we called the function on the server it returned:
+          <pre data-testid="server-result">{JSON.stringify(serverEnv)}</pre>
+          When we called the function on the client it returned:
+          <pre data-testid="client-result">{JSON.stringify(env)}</pre>
+          <br />
+          <h1>
+            <code>echo</code>
+          </h1>
+          When we called the function on the server it returned:
+          <pre data-testid="server-echo-result">
+            {JSON.stringify(serverEcho)}
+          </pre>
+          When we called the function on the client it returned:
+          <pre data-testid="client-echo-result">{JSON.stringify(echo)}</pre>
+        </div>
+      )}
+    </div>
+  )
+}
diff --git a/e2e/start/basic/tests/base.spec.ts b/e2e/start/basic/tests/base.spec.ts
index 0abc18773..d1e7ca996 100644
--- a/e2e/start/basic/tests/base.spec.ts
+++ b/e2e/start/basic/tests/base.spec.ts
@@ -146,3 +146,24 @@ test('submitting multipart/form-data as server function input', async ({
     expected,
   )
 })
+
+test('isomorphic functions can have different implementations on client and server', async ({
+  page,
+}) => {
+  await page.goto('/isomorphic-fns')
+
+  await page.waitForLoadState('networkidle')
+
+  await page.getByTestId('test-isomorphic-results-btn').click()
+  await page.waitForLoadState('networkidle')
+
+  await expect(page.getByTestId('server-result')).toContainText('server')
+  await expect(page.getByTestId('client-result')).toContainText('client')
+
+  await expect(page.getByTestId('server-echo-result')).toContainText(
+    'server received hello',
+  )
+  await expect(page.getByTestId('client-echo-result')).toContainText(
+    'client received hello',
+  )
+})
diff --git a/packages/start-vite-plugin/tests/createIsomorphicFn/createIsomorphicFn.test.ts b/packages/start-vite-plugin/tests/createIsomorphicFn/createIsomorphicFn.test.ts
new file mode 100644
index 000000000..9510def00
--- /dev/null
+++ b/packages/start-vite-plugin/tests/createIsomorphicFn/createIsomorphicFn.test.ts
@@ -0,0 +1,102 @@
+import { readFile, readdir } from 'node:fs/promises'
+import path from 'node:path'
+import { afterAll, describe, expect, test, vi } from 'vitest'
+
+import { compileStartOutput } from '../../src/compilers'
+
+async function getFilenames() {
+  return await readdir(path.resolve(import.meta.dirname, './test-files'))
+}
+
+describe('createIsomorphicFn compiles correctly', async () => {
+  const noImplWarning =
+    'createIsomorphicFn called without a client or server implementation!'
+
+  const originalConsoleWarn = console.warn
+  const consoleSpy = vi.spyOn(console, 'warn').mockImplementation((...args) => {
+    // we want to avoid sending this warning to the console, we know about it
+    if (args[0] === noImplWarning) {
+      return
+    }
+    originalConsoleWarn(...args)
+  })
+
+  afterAll(() => {
+    consoleSpy.mockRestore()
+  })
+
+  const filenames = await getFilenames()
+
+  describe.each(filenames)('should handle "%s"', async (filename) => {
+    const file = await readFile(
+      path.resolve(import.meta.dirname, `./test-files/${filename}`),
+    )
+    const code = file.toString()
+
+    test.each(['client', 'server'] as const)(
+      `should compile for ${filename} %s`,
+      async (env) => {
+        const compiledResult = compileStartOutput({
+          env,
+          code,
+          root: './test-files',
+          filename,
+        })
+
+        await expect(compiledResult.code).toMatchFileSnapshot(
+          `./snapshots/${env}/${filename}`,
+        )
+      },
+    )
+  })
+  test('should error if implementation not provided', () => {
+    expect(() => {
+      compileStartOutput({
+        env: 'client',
+        code: `
+        import { createIsomorphicFn } from '@tanstack/start'
+        const clientOnly = createIsomorphicFn().client()`,
+        root: './test-files',
+        filename: 'no-fn.ts',
+      })
+    }).toThrowError()
+    expect(() => {
+      compileStartOutput({
+        env: 'server',
+        code: `
+        import { createIsomorphicFn } from '@tanstack/start'
+        const serverOnly = createIsomorphicFn().server()`,
+        root: './test-files',
+        filename: 'no-fn.ts',
+      })
+    }).toThrowError()
+  })
+  test('should be assigned to a variable', () => {
+    expect(() => {
+      compileStartOutput({
+        env: 'client',
+        code: `
+        import { createIsomorphicFn } from '@tanstack/start'
+        createIsomorphicFn()`,
+        root: './test-files',
+        filename: 'no-fn.ts',
+      })
+    }).toThrowError()
+  })
+  test('should warn to console if no implementations provided', () => {
+    compileStartOutput({
+      env: 'client',
+      code: `
+      import { createIsomorphicFn } from '@tanstack/start'
+      const noImpl = createIsomorphicFn()`,
+      root: './test-files',
+      filename: 'no-fn.ts',
+    })
+    expect(consoleSpy).toHaveBeenCalledWith(
+      noImplWarning,
+      'This will result in a no-op function.',
+      'Variable name:',
+      'noImpl',
+    )
+  })
+})
diff --git a/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnDestructured.tsx b/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnDestructured.tsx
new file mode 100644
index 000000000..6fcb30691
--- /dev/null
+++ b/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnDestructured.tsx
@@ -0,0 +1,16 @@
+import { createIsomorphicFn } from '@tanstack/start';
+const noImpl = () => {};
+const serverOnlyFn = () => {};
+const clientOnlyFn = () => 'client';
+const serverThenClientFn = () => 'client';
+const clientThenServerFn = () => 'client';
+function abstractedServerFn() {
+  return 'server';
+}
+const serverOnlyFnAbstracted = () => {};
+function abstractedClientFn() {
+  return 'client';
+}
+const clientOnlyFnAbstracted = abstractedClientFn;
+const serverThenClientFnAbstracted = abstractedClientFn;
+const clientThenServerFnAbstracted = abstractedClientFn;
\ No newline at end of file
diff --git a/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnDestructuredRename.tsx b/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnDestructuredRename.tsx
new file mode 100644
index 000000000..d273a49f4
--- /dev/null
+++ b/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnDestructuredRename.tsx
@@ -0,0 +1,16 @@
+import { createIsomorphicFn as isomorphicFn } from '@tanstack/start';
+const noImpl = () => {};
+const serverOnlyFn = () => {};
+const clientOnlyFn = () => 'client';
+const serverThenClientFn = () => 'client';
+const clientThenServerFn = () => 'client';
+function abstractedServerFn() {
+  return 'server';
+}
+const serverOnlyFnAbstracted = () => {};
+function abstractedClientFn() {
+  return 'client';
+}
+const clientOnlyFnAbstracted = abstractedClientFn;
+const serverThenClientFnAbstracted = abstractedClientFn;
+const clientThenServerFnAbstracted = abstractedClientFn;
\ No newline at end of file
diff --git a/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnStarImport.tsx b/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnStarImport.tsx
new file mode 100644
index 000000000..ffb2ffe79
--- /dev/null
+++ b/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnStarImport.tsx
@@ -0,0 +1,16 @@
+import * as TanStackStart from '@tanstack/start';
+const noImpl = () => {};
+const serverOnlyFn = () => {};
+const clientOnlyFn = () => 'client';
+const serverThenClientFn = () => 'client';
+const clientThenServerFn = () => 'client';
+function abstractedServerFn() {
+  return 'server';
+}
+const serverOnlyFnAbstracted = () => {};
+function abstractedClientFn() {
+  return 'client';
+}
+const clientOnlyFnAbstracted = abstractedClientFn;
+const serverThenClientFnAbstracted = abstractedClientFn;
+const clientThenServerFnAbstracted = abstractedClientFn;
\ No newline at end of file
diff --git a/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnDestructured.tsx b/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnDestructured.tsx
new file mode 100644
index 000000000..0e0134e34
--- /dev/null
+++ b/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnDestructured.tsx
@@ -0,0 +1,16 @@
+import { createIsomorphicFn } from '@tanstack/start';
+const noImpl = () => {};
+const serverOnlyFn = () => 'server';
+const clientOnlyFn = () => {};
+const serverThenClientFn = () => 'server';
+const clientThenServerFn = () => 'server';
+function abstractedServerFn() {
+  return 'server';
+}
+const serverOnlyFnAbstracted = abstractedServerFn;
+function abstractedClientFn() {
+  return 'client';
+}
+const clientOnlyFnAbstracted = () => {};
+const serverThenClientFnAbstracted = abstractedServerFn;
+const clientThenServerFnAbstracted = abstractedServerFn;
\ No newline at end of file
diff --git a/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnDestructuredRename.tsx b/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnDestructuredRename.tsx
new file mode 100644
index 000000000..3ba9d0598
--- /dev/null
+++ b/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnDestructuredRename.tsx
@@ -0,0 +1,16 @@
+import { createIsomorphicFn as isomorphicFn } from '@tanstack/start';
+const noImpl = () => {};
+const serverOnlyFn = () => 'server';
+const clientOnlyFn = () => {};
+const serverThenClientFn = () => 'server';
+const clientThenServerFn = () => 'server';
+function abstractedServerFn() {
+  return 'server';
+}
+const serverOnlyFnAbstracted = abstractedServerFn;
+function abstractedClientFn() {
+  return 'client';
+}
+const clientOnlyFnAbstracted = () => {};
+const serverThenClientFnAbstracted = abstractedServerFn;
+const clientThenServerFnAbstracted = abstractedServerFn;
\ No newline at end of file
diff --git a/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnStarImport.tsx b/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnStarImport.tsx
new file mode 100644
index 000000000..c77c6ac83
--- /dev/null
+++ b/packages/start-vite-plugin/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnStarImport.tsx
@@ -0,0 +1,16 @@
+import * as TanStackStart from '@tanstack/start';
+const noImpl = () => {};
+const serverOnlyFn = () => 'server';
+const clientOnlyFn = () => {};
+const serverThenClientFn = () => 'server';
+const clientThenServerFn = () => 'server';
+function abstractedServerFn() {
+  return 'server';
+}
+const serverOnlyFnAbstracted = abstractedServerFn;
+function abstractedClientFn() {
+  return 'client';
+}
+const clientOnlyFnAbstracted = () => {};
+const serverThenClientFnAbstracted = abstractedServerFn;
+const clientThenServerFnAbstracted = abstractedServerFn;
\ No newline at end of file
diff --git a/packages/start-vite-plugin/tests/createIsomorphicFn/test-files/createIsomorphicFnDestructured.tsx b/packages/start-vite-plugin/tests/createIsomorphicFn/test-files/createIsomorphicFnDestructured.tsx
new file mode 100644
index 000000000..e6c5c3594
--- /dev/null
+++ b/packages/start-vite-plugin/tests/createIsomorphicFn/test-files/createIsomorphicFnDestructured.tsx
@@ -0,0 +1,35 @@
+import { createIsomorphicFn } from '@tanstack/start'
+
+const noImpl = createIsomorphicFn()
+
+const serverOnlyFn = createIsomorphicFn().server(() => 'server')
+
+const clientOnlyFn = createIsomorphicFn().client(() => 'client')
+
+const serverThenClientFn = createIsomorphicFn()
+  .server(() => 'server')
+  .client(() => 'client')
+
+const clientThenServerFn = createIsomorphicFn()
+  .client(() => 'client')
+  .server(() => 'server')
+
+function abstractedServerFn() {
+  return 'server'
+}
+
+const serverOnlyFnAbstracted = createIsomorphicFn().server(abstractedServerFn)
+
+function abstractedClientFn() {
+  return 'client'
+}
+
+const clientOnlyFnAbstracted = createIsomorphicFn().client(abstractedClientFn)
+
+const serverThenClientFnAbstracted = createIsomorphicFn()
+  .server(abstractedServerFn)
+  .client(abstractedClientFn)
+
+const clientThenServerFnAbstracted = createIsomorphicFn()
+  .client(abstractedClientFn)
+  .server(abstractedServerFn)
diff --git a/packages/start-vite-plugin/tests/createIsomorphicFn/test-files/createIsomorphicFnDestructuredRename.tsx b/packages/start-vite-plugin/tests/createIsomorphicFn/test-files/createIsomorphicFnDestructuredRename.tsx
new file mode 100644
index 000000000..9c31330b1
--- /dev/null
+++ b/packages/start-vite-plugin/tests/createIsomorphicFn/test-files/createIsomorphicFnDestructuredRename.tsx
@@ -0,0 +1,35 @@
+import { createIsomorphicFn as isomorphicFn } from '@tanstack/start'
+
+const noImpl = isomorphicFn()
+
+const serverOnlyFn = isomorphicFn().server(() => 'server')
+
+const clientOnlyFn = isomorphicFn().client(() => 'client')
+
+const serverThenClientFn = isomorphicFn()
+  .server(() => 'server')
+  .client(() => 'client')
+
+const clientThenServerFn = isomorphicFn()
+  .client(() => 'client')
+  .server(() => 'server')
+
+function abstractedServerFn() {
+  return 'server'
+}
+
+const serverOnlyFnAbstracted = isomorphicFn().server(abstractedServerFn)
+
+function abstractedClientFn() {
+  return 'client'
+}
+
+const clientOnlyFnAbstracted = isomorphicFn().client(abstractedClientFn)
+
+const serverThenClientFnAbstracted = isomorphicFn()
+  .server(abstractedServerFn)
+  .client(abstractedClientFn)
+
+const clientThenServerFnAbstracted = isomorphicFn()
+  .client(abstractedClientFn)
+  .server(abstractedServerFn)
diff --git a/packages/start-vite-plugin/tests/createIsomorphicFn/test-files/createIsomorphicFnStarImport.tsx b/packages/start-vite-plugin/tests/createIsomorphicFn/test-files/createIsomorphicFnStarImport.tsx
new file mode 100644
index 000000000..4c1bed574
--- /dev/null
+++ b/packages/start-vite-plugin/tests/createIsomorphicFn/test-files/createIsomorphicFnStarImport.tsx
@@ -0,0 +1,37 @@
+import * as TanStackStart from '@tanstack/start'
+
+const noImpl = TanStackStart.createIsomorphicFn()
+
+const serverOnlyFn = TanStackStart.createIsomorphicFn().server(() => 'server')
+
+const clientOnlyFn = TanStackStart.createIsomorphicFn().client(() => 'client')
+
+const serverThenClientFn = TanStackStart.createIsomorphicFn()
+  .server(() => 'server')
+  .client(() => 'client')
+
+const clientThenServerFn = TanStackStart.createIsomorphicFn()
+  .client(() => 'client')
+  .server(() => 'server')
+
+function abstractedServerFn() {
+  return 'server'
+}
+
+const serverOnlyFnAbstracted =
+  TanStackStart.createIsomorphicFn().server(abstractedServerFn)
+
+function abstractedClientFn() {
+  return 'client'
+}
+
+const clientOnlyFnAbstracted =
+  TanStackStart.createIsomorphicFn().client(abstractedClientFn)
+
+const serverThenClientFnAbstracted = TanStackStart.createIsomorphicFn()
+  .server(abstractedServerFn)
+  .client(abstractedClientFn)
+
+const clientThenServerFnAbstracted = TanStackStart.createIsomorphicFn()
+  .client(abstractedClientFn)
+  .server(abstractedServerFn)
diff --git a/packages/start/src/client/tests/createIsomorphicFn.test-d.ts b/packages/start/src/client/tests/createIsomorphicFn.test-d.ts
new file mode 100644
index 000000000..89f427d8c
--- /dev/null
+++ b/packages/start/src/client/tests/createIsomorphicFn.test-d.ts
@@ -0,0 +1,72 @@
+import { expectTypeOf, test } from 'vitest'
+import { createIsomorphicFn } from '../createIsomorphicFn'
+
+test('createIsomorphicFn with no implementations', () => {
+  const fn = createIsomorphicFn()
+
+  expectTypeOf(fn).toBeCallableWith()
+  expectTypeOf(fn).returns.toBeUndefined()
+
+  expectTypeOf(fn).toHaveProperty('server')
+  expectTypeOf(fn).toHaveProperty('client')
+})
+
+test('createIsomorphicFn with server implementation', () => {
+  const fn = createIsomorphicFn().server(() => 'data')
+
+  expectTypeOf(fn).toBeCallableWith()
+  expectTypeOf(fn).returns.toEqualTypeOf<string | undefined>()
+
+  expectTypeOf(fn).toHaveProperty('client')
+  expectTypeOf(fn).not.toHaveProperty('server')
+})
+
+test('createIsomorphicFn with client implementation', () => {
+  const fn = createIsomorphicFn().client(() => 'data')
+
+  expectTypeOf(fn).toBeCallableWith()
+  expectTypeOf(fn).returns.toEqualTypeOf<string | undefined>()
+
+  expectTypeOf(fn).toHaveProperty('server')
+  expectTypeOf(fn).not.toHaveProperty('client')
+})
+
+test('createIsomorphicFn with server and client implementation', () => {
+  const fn = createIsomorphicFn()
+    .server(() => 'data')
+    .client(() => 'data')
+
+  expectTypeOf(fn).toBeCallableWith()
+  expectTypeOf(fn).returns.toEqualTypeOf<string>()
+
+  expectTypeOf(fn).not.toHaveProperty('server')
+  expectTypeOf(fn).not.toHaveProperty('client')
+})
+
+test('createIsomorphicFn with varying returns', () => {
+  const fn = createIsomorphicFn()
+    .server(() => 'data')
+    .client(() => 1)
+  expectTypeOf(fn).toBeCallableWith()
+  expectTypeOf(fn).returns.toEqualTypeOf<string | number>()
+})
+
+test('createIsomorphicFn with arguments', () => {
+  const fn = createIsomorphicFn()
+    .server((a: number, b: string) => 'data')
+    .client((...args) => {
+      expectTypeOf(args).toEqualTypeOf<[number, string]>()
+      return 1
+    })
+  expectTypeOf(fn).toBeCallableWith(1, 'a')
+  expectTypeOf(fn).returns.toEqualTypeOf<string | number>()
+
+  const fn2 = createIsomorphicFn()
+    .client((a: number, b: string) => 'data')
+    .server((...args) => {
+      expectTypeOf(args).toEqualTypeOf<[number, string]>()
+      return 1
+    })
+  expectTypeOf(fn2).toBeCallableWith(1, 'a')
+  expectTypeOf(fn2).returns.toEqualTypeOf<string | number>()
+})