router icon indicating copy to clipboard operation
router copied to clipboard

Improve start error message when importing server code on the client

Open lino-levan opened this issue 2 months ago • 19 comments

Which project does this relate to?

Start

Describe the bug

When someone imports server code on the client in the current iteration of tanstack start, vite gives a very hard-to-debug error which doesn't expose what file is generating that error. This is a pretty awful issue to debug, especially for projects moving to the RC.

Split off of https://github.com/TanStack/router/issues/5196#issuecomment-3398768616, which you should read for more context.

Example error message:

../node_modules/@tanstack/start-server-core/dist/esm/loadVirtualModule.js:5:26:
  5 │       return await import("tanstack-start-manifest:v");
    ╵                           ~~~~~~~~~~~~~~~~~~~~~~~~~~~

Your Example Website or App

https://github.com/TanStack/router/issues/5196#issuecomment-3398826558

Steps to Reproduce the Bug or Issue

import { getRequest } from "@tanstack/react-start/server";

(or similar) on a client-only file

Expected behavior

As a user, I am alright with this erroring, but the error should be actionable instead of blowing up without further guidance. Specifically, I'd really love for the error to point to the offending files.

Screenshots or Videos

No response

Platform

  • Router / Start Version: 1.131.7
  • OS: macOS
  • Browser: All
  • Bundler: Vite
  • Bundler Version: 7.1.7

Additional context

No response

lino-levan avatar Oct 13 '25 21:10 lino-levan

Have you found any approach to debugging this?

karl-run avatar Oct 14 '25 14:10 karl-run

Decided to punt the upgrade internally until we have more time but what has worked for a couple of them was delete everything, introduce files part by part, one by one to locate what is causing the issues.

lino-levan avatar Oct 14 '25 17:10 lino-levan

I just ran into this issue, or at least an issue that presents in the same way (hard-to-debug error, opaque references to virtual modules). Granted, first time using tanstack start and was moving fast, likely have done something silly, but I'm at a loss for how to deal with this.

Repo with my setup, along with steps to reproduce in the README: https://github.com/zemccartney/tanstack-start-crash-repro

Notably, I can make the error temporarily go away by deleting src files, then restarting the dev server. And the app appears to build just fine. I would have thought clearing out node_modules and reinstalling would have affected how this bug presents, but as far as I can tell, no effect.

Platform

  • Router / Start Version: 1.133.36
  • OS: macOS
  • Browser: All
  • Bundler: Vite
  • Bundler Version: 7.1.12
  • Node version: v22.14.0

zemccartney avatar Nov 01 '25 18:11 zemccartney

I am having the same issues, the only remedy i found is going back to the last good commit, then apply the next one and remove lines one after the other until it starts working. It ALWAYS happens when you have server functions that import other server only code. I mean I dont want to wrap EVERY single utility server method into a server function! This is not going to fly. Also I had some issues integration testing or unit testing server functions (throwing weird error messages around, ended up exporting a stripped down method that didnt include any of the server function stuff. So in general I feel still very skeptical even though the hello world experience is truly quite good. It only starts to suck if you want to build a real app (you know with test etc) that things tend to go awry. I would really love to like tanstack-start (because I do love tanstack-router), but its making it SUPER difficult right now as there are many paper cuts like this one. I feel that everyone trying to build someting moderately complex with db, tests, etc), will pull his hair out with tanstack-start right now. Can I please get a "use server" on the top of files where I can tell the bundler to PLEASE PLEASE PLEASE dont even think bundling this in the client?

rburgst avatar Nov 02 '25 14:11 rburgst

no there wont be a "use server" but something similar maybe.

schiller-manuel avatar Nov 02 '25 15:11 schiller-manuel

I dont mind how its called, what I would really like is some way to have one set of files that are server only (maybe have them in a separate folder, have some file naming convention, have some ignored first line like "use server", I dont care).

However it MUST be trivial to solve those issues. Because right now if you are trying to build a drizzle powered server which uses the same schema-derived data structures to use in your client forms (tanstack-forms, I am looking at you), then this is like pulling good teeth. It sucks really bad. I had to resort to using dynamic imports just to get the bundler to stop following server only imports. It shouldnt be so hard. I am watching tons of youtube videos where people are chanting over how nice tanstack start is, when you look at their implementations, they use in memory server side stuff where most likely all their server code IS being bundled on the client, but its so trivial and doesnt use any library that it doesnt matter. For a good framework that people will continue to use it should be the other way round. The more you learn about it, the more pleasant it should become. Unfortunately right now its the other way round. As soon as you are trying to do something interesting it becomes unreasonably hard where neither stackoverflow, the github issue tracker or any LLM can really help you. So @schiller-manuel as soon as you have something that would fit that bill, please let me know (I hope by then I will still be on tanstack-start).

rburgst avatar Nov 02 '25 15:11 rburgst

can you share a complete project where this occured? also what would you expect if the client env imports something directly or transitively from such a marked file?

schiller-manuel avatar Nov 02 '25 15:11 schiller-manuel

hi, will try to whip something together in the next few days, here is some example for a rough idea

import {taskRepository} from './server/task-repository'

export const getListTasks = createServerFn({ method: 'GET' })
  .middleware([authWithLocaleMiddleware])
  .handler(async (ctx) => {
    const tasks = await taskRepository.getTasks()
    return tasks
  })

basically this does not work as it would then try to bundle task-repository (and drizzle etc) into the client and there is no obvious way to make this work, regardless of whether the export is a function or anything else.

I would hope that marked files (lets just call it 'use server' for now as I dont know what else to call it) would then be ignored by the bundler and will not make it into the client bundle. That way will allow you to write "normal" server code and then brigdge the gap via createServerFunction in case you want to expose this method to the client.

Just found this doc

https://tanstack.com/start/latest/docs/framework/react/guide/execution-model#server-only-execution

It nicely hides where db is actually coming from. I pretty much guarantee that this wont work either due to the same problem

import { createServerFn, createServerOnlyFn } from '@tanstack/react-start'

// RPC: Server execution, callable from client
const updateUser = createServerFn({ method: 'POST' })
  .inputValidator((data: UserData) => data)
  .handler(async ({ data }) => {
    // Only runs on server, but client can call it
    return await db.users.update(data)
  })

// Utility: Server-only, client crashes if called
const getEnvVar = createServerOnlyFn(() => process.env.DATABASE_URL)

So basically, I urge you to try and get this to work without wrapping everything in createServerOnlyFunction (and still then your import of the drizzle db object will break your neck).

Maybe I am holding it wrong and there is a super simple solution for such problems. If thats the case - great - but please update the docs.

rburgst avatar Nov 03 '25 22:11 rburgst

I just discovered this thread after having decided to migrate my Next.js app to TanStack, arggh I'm puzzled, how is it possible to build even a simple app if we cannot import server code from a server function ?

abenhamdine avatar Nov 04 '25 08:11 abenhamdine

Same here, been trying to migrate a React Router 7 + Hono custom setup to TansStack and hitting the same error as the OP. I have multiple server-only utilities to interface with my DB, Redis, etc. I would have expected for these to be importable within a file that defines a server function like @rburgst says however it seems I am hitting the same limitation. I understand Start is RC still but I don't seem to be able to find any examples where a proper full-stack app with drizzle/prisma/etc is used with a server function, is there such an example? I know I could create API routes but that kinda defeats the purpose of migrating on the first place.

alessandrojcm avatar Nov 05 '25 10:11 alessandrojcm

@alessandrojcm I have to confess that the create-tanstack-app generator creates a working version where this problem is not happening, I am still in the process of trying to merge my app (where I have the problem) and the working generated app to illustrate the problem better. However, it does seem that at least for simple apps it is in fact working. As stated earlier the more involved your app becomes the more likely you are to run into this problem. I was actually considering converting my app to hono + TSR :)

rburgst avatar Nov 05 '25 11:11 rburgst

@rburgst yeah, that is fair. I know that the generator apps and simple apps work, the issue is that my app is already pretty involved and such these errors are really hard to track down. I've seen the TSR + Hono approach. I just think that using a server framework inside a full-stack framework is a bit convoluted.

alessandrojcm avatar Nov 05 '25 12:11 alessandrojcm

@schiller-manuel I am having problems replicating a simplified example. I do have my (complex) project where i can reproduce the problem, do you have any ideas how I can debug the problem to find out what exactly makes it break. I tried running the build with DEBUG=* pnpm build but that is generating so much log that its quite useless

This is my current error where its trying to bundle pg into the client.

✗ Build failed in 7.35s
error during build:
[vite]: Rollup failed to resolve import "pg" from "/Users/rainer/git/private/project/src/lib/database.ts".
This is most likely unintended because it can break your application at runtime.
If you do want to externalize this module explicitly add it to
`build.rollupOptions.external`

rburgst avatar Nov 10 '25 07:11 rburgst

I guess this is related https://github.com/TanStack/router/issues/5892

rburgst avatar Nov 19 '25 20:11 rburgst

Ok so I get the samne error, tried everything nothing work.

_root.tsx

import {
  HeadContent,
  Scripts,
  createRootRouteWithContext,
  useRouter,
} from '@tanstack/react-router'
// import { useEffect } from 'react'
import { ToastContainer } from 'react-toastify'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { ThemeProvider } from 'next-themes'
import { TanStackDevtools } from '@tanstack/react-devtools'

import { useEffect } from 'react'
import TanStackQueryDevtools from '../integrations/tanstack-query/devtools'

import appCss from '../styles.css?url'
import Footer from '@/components/templates/Footer'
import Header from '@/components/templates/Header'
import { useAuthStore } from '@/store'

export const Route = createRootRouteWithContext()({
  head: () => ({
    meta: [
      {
        charSet: 'utf-8',
      },
      {
        name: 'viewport',
        content: 'width=device-width, initial-scale=1',
      },
      {
        title: 'TanStack Start Starter',
      },
    ],
    links: [
      {
        rel: 'stylesheet',
        href: appCss,
      },
    ],
  }),
  shellComponent: RootDocument,
})

function RootDocument({ children }: { children: React.ReactNode }) {
  const router = useRouter()
  const pathname = router.state.location.pathname

  // ↓↓↓ Add these lines ↓↓↓
  const { session, profile } = Route.useRouteContext()
  const hydrate = useAuthStore((s) => s.hydrate)

  // Hydrate Zustand on the client
  useEffect(() => {
    hydrate(session, profile)
  }, [session, profile, hydrate])

  return (
    <html lang="en">
      <head>
        <HeadContent />
      </head>
      <body>
        <ThemeProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          {pathname !== '/auth/login' && <Header />}
          {children}
          <TanStackDevtools
            config={{
              position: 'bottom-right',
            }}
            plugins={[
              {
                name: 'Tanstack Router',
                render: <TanStackRouterDevtoolsPanel />,
              },
              TanStackQueryDevtools,
            ]}
          />
          <Footer />
          <ToastContainer />
          <Scripts />
        </ThemeProvider>
      </body>
    </html>
  )
}

router.tsx

import { createRouter } from '@tanstack/react-router'
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
import * as TanstackQuery from './integrations/tanstack-query/root-provider'

// Import the generated route tree
import { routeTree } from './routeTree.gen'
import { getSupabaseServerClient } from './lib/supabase.server'
import { DefaultCatchBoundary } from './components/DefaultCatchBoundary'
import { NotFound } from './components/NotFound'
import type { QueryClient } from '@tanstack/react-query'

// Define the context type for the router
export interface MyRouterContext {
  queryClient: QueryClient
  supabase: ReturnType<typeof getSupabaseServerClient>
}

// Create a new router instance
export const getRouter = () => {
  const rqContext = TanstackQuery.getContext()

  // Create supabase client for SSR (cookies-based)
  const supabase = getSupabaseServerClient()

  const router = createRouter({
    routeTree,
    context: { ...rqContext, supabase },
    defaultPreload: 'intent',
    defaultErrorComponent: DefaultCatchBoundary,
    defaultNotFoundComponent: () => <NotFound />,
    Wrap: (props: { children: React.ReactNode }) => {
      return (
        <TanstackQuery.Provider {...rqContext}>
          {props.children}
        </TanstackQuery.Provider>
      )
    },
  })

  setupRouterSsrQueryIntegration({ router, queryClient: rqContext.queryClient })

  return router
}

eybel avatar Dec 04 '25 18:12 eybel

@eybel full repo please

schiller-manuel avatar Dec 05 '25 21:12 schiller-manuel

@eybel full repo please @schiller-manuel

It seems it was this line:

const supabase = getSupabaseServerClient()

It took me a while to understand it. It has to do with the fact that that function has a server function being called From a client side when passing in the context.

eybel avatar Dec 05 '25 22:12 eybel

how does getSupabaseServerClient() look like?

schiller-manuel avatar Dec 05 '25 23:12 schiller-manuel

how does getSupabaseServerClient() look like?

I think I was putting my getSupabaseServerClient() inside the __root.tsx and by reading and re-searching I found out that we can't do that, server functions only stay on the server side, what I had to do was to create a provider, and in this provider (root-provider.tsx in my case) I call that serverf function so se haver a first getUser fetch to hydrate my client side of the app.

This is my new approach...

getSupabaseServerClient() & supabase.server.ts

import { getCookies, setCookie } from '@tanstack/react-start/server'
import { createServerClient } from '@supabase/ssr'

const VITE_SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL // fallback on cloud
const VITE_SUPABASE_PUBLISHABLE_KEY = import.meta.env
  .VITE_SUPABASE_PUBLISHABLE_KEY //  fallback on cloud

if (!VITE_SUPABASE_URL) {
  throw new Error('SUPABASE_URL environment variable is not set.')
}
if (!VITE_SUPABASE_PUBLISHABLE_KEY) {
  throw new Error(
    'VITE_SUPABASE_PUBLISHABLE_KEY environment variable is not set.',
  )
}

// Create a Supabase client for server-side usage
export function getSupabaseServerClient() {
  return createServerClient(VITE_SUPABASE_URL, VITE_SUPABASE_PUBLISHABLE_KEY, {
    cookies: {
      getAll() {
        return Object.entries(getCookies()).map(([name, value]) => ({
          name,
          value,
        }))
      },
      setAll(cookies) {
        cookies.forEach((cookie) => {
          setCookie(cookie.name, cookie.value)
        })
      },
    },
  })
}

this is my root-provider.tsx

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { getUserFn } from '@/auth/auth.server'

export async function getContext() {
  const queryClient = new QueryClient()

  // // This runs ONLY on the server
  const { session, profile } = await getUserFn()

  return {
    queryClient,
    session,
    profile,
  }
}

export function Provider({
  children,
  queryClient,
}: {
  children: React.ReactNode
  queryClient: QueryClient
}) {
  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  )
}

This is my router.tsx:

import { createRouter } from '@tanstack/react-router'
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
import * as TanstackQuery from './integrations/tanstack-query/root-provider'

// Import the generated route tree
import { routeTree } from './routeTree.gen'
import { DefaultCatchBoundary } from './components/DefaultCatchBoundary'
import { NotFound } from './components/NotFound'
import type { QueryClient } from '@tanstack/react-query'
import type { getSupabaseServerClient } from './lib/supabase.server'

// Define the context type for the router
export interface MyRouterContext {
  queryClient: QueryClient
  supabase: ReturnType<typeof getSupabaseServerClient>
}

// Create a new router instance
export const getRouter = async () => {
  const rqContext = await TanstackQuery.getContext()

  const router = createRouter({
    routeTree,
    context: { ...rqContext },
    defaultPreload: 'intent',
    defaultErrorComponent: DefaultCatchBoundary,
    defaultNotFoundComponent: () => <NotFound />,
    Wrap: (props: { children: React.ReactNode }) => {
      return (
        <TanstackQuery.Provider {...rqContext}>
          {props.children}
        </TanstackQuery.Provider>
      )
    },
  })

  setupRouterSsrQueryIntegration({ router, queryClient: rqContext.queryClient })

  return router
}

eybel avatar Dec 06 '25 01:12 eybel

Could a similar convention like this be of help? https://reactrouter.com/api/framework-conventions/server-modules

I've found it very convenient to annotate files and/or directories as being server / client only.

remen avatar Dec 14 '25 18:12 remen

@remen we plan to add something like this (but configurable and opt-in)

schiller-manuel avatar Dec 14 '25 19:12 schiller-manuel

In the meantime I decided to adopt the convention of putting all server code in a folder .server/ and vibe coded a Vite plugin that detects .server/ code leaking into client bundles at build time. It hooks into generateBundle and uses Rollup's bundle metadata to show exactly which files leaked:

import type { Plugin } from "vite";

type BuildType = "client" | "ssr" | "nitro";

function detectBuildType(outputDir: string): BuildType | null {
  // TanStack Start + Nitro output structure
  if (/\.output\/public\/?$/.test(outputDir)) return "client";
  if (/\.nitro\/.*\/ssr\/?$/.test(outputDir)) return "ssr";
  if (/\.output\/server\/?$/.test(outputDir)) return "nitro";
  return null;
}

export function serverLeakDetector(): Plugin {
  const serverPatterns = [/\/.server\//];

  return {
    name: "server-leak-detector",
    apply: "build",

    generateBundle(opts, bundle) {
      const buildType = detectBuildType(opts.dir || "");

      // Fail closed - error if we can't determine build type
      if (buildType === null) {
        return this.error(
          `[server-leak-detector] Cannot determine build type for output directory: ${opts.dir}\n` +
            `Expected one of:\n` +
            `  - .output/public (client)\n` +
            `  - node_modules/.nitro/.../ssr (ssr)\n` +
            `  - .output/server (nitro)\n` +
            `If the output structure has changed, update the plugin.`,
        );
      }

      if (buildType !== "client") return;

      const leaks: { chunk: string; modules: string[] }[] = [];

      for (const [fileName, chunk] of Object.entries(bundle)) {
        if (chunk.type !== "chunk") continue;
        const serverModules = Object.keys(chunk.modules).filter((id) =>
          serverPatterns.some((p) => p.test(id)),
        );
        if (serverModules.length > 0) {
          leaks.push({ chunk: fileName, modules: serverModules });
        }
      }

      if (leaks.length > 0) {
        this.error(formatLeakReport(leaks));
      }
    },
  };
}

function formatLeakReport(leaks: { chunk: string; modules: string[] }[]): string {
  const lines = ["", "═".repeat(60), "SERVER CODE LEAKED INTO CLIENT BUNDLE", "═".repeat(60), ""];

  for (const leak of leaks) {
    lines.push(`Chunk: ${leak.chunk}`);
    for (const mod of leak.modules) {
      const cleanPath = mod.replace(/.*\/src\//, "src/");
      lines.push(`  - ${cleanPath}`);
    }
    lines.push("");
  }

  lines.push(
    "Files in .server/ directories should never be bundled into the client.",
    "",
    "Check that:",
    "  1. Server-only imports are inside .server() callbacks",
    "  2. Middleware files are NOT in .server/ directories",
    "  3. Type-only imports use 'import type { ... }'",
    "═".repeat(60),
  );

  return lines.join("\n");
}

Usage in vite.config.ts:

import { serverLeakDetector } from "./scripts/vite-plugin-server-leak-detector";

export default defineConfig({
  plugins: [
    // ... other plugins
    serverLeakDetector(),
  ],
});

Key insight: Middleware should live outside .server/ directories. Middleware is isomorphic - the client needs the stub/reference, and the .server() callback handles the split. The .server/ directory is reserved for truly server-only code (DB clients, secrets, etc.) that must never touch the client bundle.

This gives clear build-time errors like:

════════════════════════════════════════════════════════════
SERVER CODE LEAKED INTO CLIENT BUNDLE
════════════════════════════════════════════════════════════

Chunk: assets/main-xxx.js
  - src/.server/config.ts
  - src/.server/database.ts

Files in .server/ directories should never be bundled into the client.

Check that:
  1. Server-only imports are inside .server() callbacks
  2. Middleware files are NOT in .server/ directories
  3. Type-only imports use 'import type { ... }'
════════════════════════════════════════════════════════════

muniter avatar Dec 16 '25 15:12 muniter

I "server" an active folder for server functions? or that is not yet implemented. In case is not, a good move may be to just create a "server" folder and that tanstack tells you to put all server function inside that folder, and that would be easier for everyone. Or maybe just adding "server" in the filename also. Since documentation is being updated, not sure if this is already the case. Sorry if I misinterpretate docs

eybel avatar Dec 16 '25 16:12 eybel

I think a great solution could be supporting vite-env-only. That's what we used with remix/reactrouter7. And it's also what we tried to use when first moving over to tanstack/start... but it's clear they don't play nice together. There's something about how tanstack/start does imports that triggers vite-env-only errors (at least on the first build).

willhoney7 avatar Dec 17 '25 16:12 willhoney7