DEV Community

Cover image for SSR Authentication Across Subdomains Using TanStack Start and Better-Auth
simonxabris
simonxabris

Posted on

SSR Authentication Across Subdomains Using TanStack Start and Better-Auth

TLDR: When using TanStack Start with authentication handled on a different subdomain (e.g., api.domain.com), fetching the user session directly in your SSR loader won’t work as expected because server-to-server requests don’t include browser cookies. To fix the header flicker and SSR the correct user state, create a server function that forwards the original request’s cookies to your auth server. Call this function in your loader to ensure the authenticated user is available during SSR, eliminating the flicker and improving UX.


The situation is the following, you're developing an app with Tanstack Start, you deploy it to app.domain.com, but you have a separate API server that handles logic and authentication using better-auth, which runs on api.subdomain.com.

You create a header in your app that displays the logged in user, but you see that when you log in and reload the app, the header flickers because initially it displays the logged out state and once the user data arrives, it flips to the logged in state.

Of course you're a good developer who wants to deliver good UX, so you decide to fix it by fetching the user on the server and then the proper logged in/out state will get rendered into the HTML and the flicker is gone.

What you do is you go and write a loader function like this:

export const Route = createRootRouteWithContext<{
  queryClient: QueryClient;
}>()({
  loader: async () => {
    const session = await authClient.getSession();

    return { user: session?.data?.user }
  },
  component: RootComponent,
});

Enter fullscreen mode Exit fullscreen mode

and then in the RootComponent, you have access to user and can pass it to your Header or Navigation component, whatever you want to call it.

function RootComponent() {
  const { user } = Route.useRouteContext();
  return (
    <html>
      <head>
        <HeadContent />
      </head>
      <body>
        <AuthProvider>
          <Navigation user={user} />
          <main className="min-h-screen bg-background">
            <Outlet />
          </main>
          <TanStackRouterDevtools />
          <Toaster />
          <Scripts />
        </AuthProvider>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Great, it was very easy, now you go and open your app to marvel at your excellence and then boom, the flicker is still there. You scratch your head, you have no idea what's going wrong (I know I didn't).

This is where if this article existed, it could have saved me quite a few agonizing hours, hope it can for someone else as well.

The problem is that when authClient.getSession makes a request to api.domain.com, this request is going from your server that is running tanstack start to your server that is running auth, which means there are no cookies involved here, since cookies are stored in the browser. This is where you might say, but hey, the browser is making the request to the tanstack server to render the page, so that request must have the cookies, right? If you said this, you're absolutely right, this is what we're going to take advantage of to solve the problem.

The solution is, you have to create a server function that fetches the user and then call that server function in your loader. I don't think this is documented anywhere (or maybe I haven't found it), but server function include all credentials like cookies from the original request the browser made, so you can write one like this:

export const fetchUser = createServerFn({ method: "GET" }).handler(async () => {
  const event = getEvent();

  const session = await authClient.getSession({
    fetchOptions: {
      headers: {
        Cookie: event.headers.get("Cookie") || "", // For brevity I copy all cookies here, but you might want to just parse out the auth cookie and send that.
      },
    },
  });

  if (!session.data) {
    return null;
  }

  return session.data.user;
});
Enter fullscreen mode Exit fullscreen mode

call it in your loader:

  loader: async () => {
    const user = await fetchUser();

    return { user };
  },
Enter fullscreen mode Exit fullscreen mode

and you're done. With this you can now get the authenticated user on the server and properly SSR your pages.

Top comments (2)

Collapse
 
hamed_mohammadzadeh_6633d profile image
Hamed Mohammadzadeh • Edited

I have problem with this scenario can help me?
I setup backend with Nest.js with better-auth and createAuthClient with better-auth in tanstack start and in baseUrl setup backend URL and when try login the cookie set in browser and authClient.getSession won't work because cookie is empty but when try url rewrite with nitro ruleRoutes and then change base url in createAuthClient to tanstack app url and now getSession have data but when try use useSession show data like questionMark I think encoded.
do u know how can fix it.
because I use tanstack query and need fetch data from client and can't get data and get 403 error but with severFn can get
this my code

const config = defineConfig({
  plugins: [
    devtools(),
    nitro({
      routeRules: {
        '/api/**': {
          proxy: 'https://example.com/api/**',
        },
      },
    }),

    // this is the plugin that enables path aliases
    viteTsConfigPaths({
      projects: ['./tsconfig.json'],
    }),
    tailwindcss(),
    tanstackStart(),
    viteReact(),
  ],
})

export default config
Enter fullscreen mode Exit fullscreen mode
import { createAuthClient } from 'better-auth/react'
import {
  customSessionClient,
  emailOTPClient,
  genericOAuthClient,
  inferAdditionalFields,
  lastLoginMethodClient,
  phoneNumberClient,
  usernameClient,
} from 'better-auth/client/plugins'

export const authClient = createAuthClient({
  baseURL: 'http://localhost:3000',
  basePath: '/api/auth',
  plugins: [
    usernameClient(),
    emailOTPClient(),
    phoneNumberClient(),
    customSessionClient(),
    lastLoginMethodClient(),
    genericOAuthClient(),
  ],
})

Enter fullscreen mode Exit fullscreen mode

in this component I get Data but i can't read data because show question mark or i don't know maybe encoded

import { Button } from '@/components/ui/button'
import { authClient } from '@/lib/auth'
import { useNavigate } from '@tanstack/react-router'

export default function Logout() {
  const { data } = authClient.useSession()
  console.log('data', data)
  const navigate = useNavigate()
  return (
    <div>
      <Button
        onClick={async () => {
          const { data, error } = await authClient.signOut()
          console.log('logout', data)
          if (data?.success) {
            navigate({
              to: '/login',
            })
          }

          if (error) {
            console.log('error', error)
          }
        }}
      >
        logout
      </Button>
    </div>
  )
}

Enter fullscreen mode Exit fullscreen mode

in this serverFn work.

const handler = createServerFn().handler(async () => {
  const request= await getRequest()

  const session = await authClient.getSession({
    fetchOptions: {
      headers: {
        cookie: request.headers.get('cookie')?.toString(),
      },
    },
  })

  console.log(session)

  return null
})
Enter fullscreen mode Exit fullscreen mode
Collapse
 
mohamede1945 profile image
Mohamed Afifi

Oh man! I have spent too much time trying to figure this out. Really appreciate sharing this simple and working solution.

Thank you!