Migrating to TanStack Start

A while ago, I decided to break away from full-stack React frameworks and use a client-server architecture. That is, separate the backend and the frontend, each being a standalone app.

So, I picked the following stack:

  • Backend: Hono + Bun + PostgreSQL + Zod + Prisma + TypeScript + Better Auth
  • Frontend: React + Vite + TypeScript + TanStack Router

It's a blast working with these technologies, and I don't regret the decision at all. I wrote about it here if you're curious.

However, the issue with this stack is the lack of SSR for SEO. I'm building a course platform, and I realized that I want some pages indexed by search engines:

  • course pages
  • author pages
  • tag pages

I should've thought about it before starting the project, but... here we are. That said, I explored a couple of options before deciding on TanStack Start.

1st option: Astro for marketing

The first option is to create a static marketing site with Astro.

I could fetch the data from the course platform's backend in the Astro site, and redirect people to the course platform SPA to get/consume the course.

It's a valid option, but I don't want to create and maintain another app, even if it's a small one.

2nd option: TanStack Router's SSR

Another option is to use the TanStack Router SSR mode. I gave it a go, but I only ran into issues when trying to set it up, so I abandoned the idea.

Also, if I go that route, why not simply migrate to TanStack Start and use it without most of its "backend" features? For now, I can use the selective SSR feature that allows you to choose which routes to render on the server.

In the future, if I need more "backend" features, I can opt-in instead of migrating the application when it becomes more complex.

Solution: TanStack Start Migration

That being said, let's look at how the migration from a SPA that uses TanStack Router to TanStack Start looks like.

If you don't want to read the whole post, you can browse the migration pull request.

Router file

I started the migration with the main.tsx file that uses TanStack Router to create the router.

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRouter, RouterProvider } from "@tanstack/react-router";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { useSession } from "@/lib/auth-client";
import NotFound from "./components/not-found";

import { routeTree } from "./routeTree.gen";
import "./index.css";
import { LoadingSpinner } from "./components/ui/loading-spinner";

const queryClient = new QueryClient();

const router = createRouter({
  routeTree,
  context: { auth: undefined, queryClient },
  defaultPendingComponent: () => {
    return <LoadingSpinner fullScreen />;
  },
  defaultNotFoundComponent: () => {
    return <NotFound />;
  },
});

declare module "@tanstack/react-router" {
  interface Register {
    router: typeof router;
  }
}

export function RouterWithAuthContext() {
  const { data: auth, isPending, error } = useSession();

  if (!isPending && !error) {
    return <RouterProvider router={router} context={{ auth }} />;
  }
}

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <RouterWithAuthContext />
    </QueryClientProvider>
  </StrictMode>
);

The above code:

  • Initializes a new React Query Client instance
  • Sets up a Router instance with a specific configuration:
    • a default pending component that routes should use if no pending component is provided for those routes
    • a default not found component that works similarly to the default pending component, except that it's used when users try to visit a route that doesn't exist
    • a context object available in all routes that contains authentication details and the Query client
  • Registers things for type safety (the declare module... part)
  • Fetches the authentication state and inserts it into the Router's context
  • Mounts the application in the DOM, wrapping it with StrictModeQueryClientProvider, and the router context.

The first change I made in the Start migration was to rename the file from main.tsx to router.tsx. That's the recommended name in their docs, so I followed that.

The code below is the new Router creation process in TanStack Start:

import { QueryClient } from "@tanstack/react-query";
import { createRouter as createRouterTanstack } from "@tanstack/react-router";
import NotFound from "./components/not-found";
import { routerWithQueryClient } from "@tanstack/react-router-with-query";

import { routeTree } from "./routeTree.gen";
import { LoadingSpinner } from "./components/ui/loading-spinner";

export function createRouter() {
  const queryClient = new QueryClient();

  return routerWithQueryClient(
    createRouterTanstack({
      routeTree,
      context: { auth: undefined, queryClient },
      defaultPreload: "intent",
      defaultPendingComponent: () => {
        return <LoadingSpinner fullScreen />;
      },
      defaultNotFoundComponent: () => {
        return <NotFound />;
      },
    }),
    queryClient
  );
}

declare module "@tanstack/react-router" {
  interface Register {
    router: ReturnType<typeof createRouter>;
  }
}

The code is similar to the TanStack Router code, with a few exceptions:

  • it doesn't fetch the authentication state anymore
  • it wraps the createRouter function in the routerWithQueryClient function
  • it doesn't mount the application in the DOM

Initially, I moved the authentication check to the root route, but I'll probably remove it from there too and only use it in the routes where it's needed.

Root route

The next file to modify was the root route of the application.

The code below sets up the root route with TanStack Router. The root route is the highest in the route tree, and it encapsulates all the other routes as children.

import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/router-devtools";

import { NavBar } from "@/components/nav-bar";
import type { Session } from "@/lib/auth-client";
import { ThemeProvider } from "@/components/theme-provider";
import { QueryClient } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/sonner";
import { Footer } from "@/components/footer";

export interface MyRouterContext {
  auth: Session | null | undefined;
  queryClient: QueryClient;
}

export const Route = createRootRouteWithContext<MyRouterContext>()({
  component: Root,
});

function Root() {
  return (
    <ThemeProvider defaultTheme="system" storageKey="learn-course-ui-theme">
      <div className="flex flex-col min-h-screen">
        <NavBar />
        <main className="flex-grow mx-auto max-w-6xl pt-12 w-full">
          <Outlet />
        </main>
        <Footer />
        <Toaster richColors={true} toastOptions={{}} />
        <TanStackRouterDevtools />
      </div>
    </ThemeProvider>
  );
}

The above code:

  • Defines a new router interface that describes the structure of the context object.
  • Creates the root route with the custom context and the Root function as the component.
  • Implements the Root component that defines the layout for the application.
    • It wraps the app in a theme provider that allows users to switch between dark and light modes.
      • It sets the default theme to the user's system preference.
    • It renders the navigation bar at the top of the app.
    • Renders the Outlet component inside the main content area of the app.
      • The Outlet component renders the child routes.
    • Renders the footer at the bottom of the application.
    • Lastly, it renders the Toaster component to display toast notifications and the TanStack Router Devtools.

In summary, it configures the root route with a custom context, theme management, and the TanStack dev tools.

Migrating the root route to TanStack Start didn't require too many changes:

import {
  Outlet,
  HeadContent,
  Scripts,
  createRootRouteWithContext,
  RouterProvider,
  useRouter,
} from "@tanstack/react-router";
import {
  QueryClient,
  QueryClientProvider,
  useQueryClient,
} from "@tanstack/react-query";
import appCss from "@/index.css?url";
import { getWebRequest } from "@tanstack/react-start/server";

import { NavBar } from "@/components/nav-bar";
import { getSession, type Session } from "@/lib/auth-client";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { Footer } from "@/components/footer";
import { createServerFn } from "@tanstack/react-start";

export interface MyRouterContext {
  auth: Session | null | undefined;
  queryClient: QueryClient;
}

const fetchAuth = createServerFn({ method: "GET" }).handler(async () => {
  const session = await getSession({
    fetchOptions: {
      headers: {
        cookie: getWebRequest().headers.get("cookie") || "",
      },
    },
  });

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

  return session.data;
});

export const Route = createRootRouteWithContext<MyRouterContext>()({
  beforeLoad: async () => {
    const auth = await fetchAuth();

    return { auth };
  },
  head: () => ({
    meta: [
      {
        charset: "utf-8",
      },
      {
        name: "viewport",
        content: "width=device-width, initial-scale=1.0",
      },
      {
        title: "Learn Course",
      },
      {
        name: "description",
        content: "Learn Course is a platform for learning courses",
      },
    ],
    links: [
      {
        rel: "stylesheet",
        href: appCss,
      },
    ],
  }),
  component: RootComponent,
});

function Providers() {
  const queryClient = useQueryClient();

  return (
    <QueryClientProvider client={queryClient}>
      <div className="flex flex-col min-h-screen">
        <NavBar />
        <main className="flex-grow mx-auto max-w-6xl pt-12 w-full">
          <Outlet />
        </main>
        <Footer />
        <Toaster richColors={true} toastOptions={{}} />
      </div>
    </QueryClientProvider>
  );
}

function RootComponent() {
  return (
    <RootDocument>
      <Providers />
    </RootDocument>
  );
}

function RootDocument({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <head>
        <HeadContent />
      </head>
      <body>
        {children}
        <Scripts />
      </body>
    </html>
  );
}

The differences you can spot at first sight are as follows:

  • There's a fetchAuth server function that fetches the session data.
    • It then calls the server function in the beforeLoad function.
    • The session data is not used anywhere in this example, but you can use it via the useRouteContext hook. You can then pass it to the child routes.
  • It includes a meta function that contains a link to the main CSS file and meta tags for SEO.

I'll likely remove the auth-related code from the root route and fetch session data in the relevant routes that require it. However, I left it in the code as an example of how you can use auth in server functions.

Lastly, I haven't yet figured out how to adapt the ThemeProvider to work on the server. I still have to figure that out.

Vite changes

The Vite configuration requires some changes, too. Here's how the Vite configuration looks for the SPA app using TanStack Router:

import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
import path from "path";
import { defineConfig } from "vite";
import { visualizer } from "rollup-plugin-visualizer";
import tailwindcss from "@tailwindcss/vite";

// https://vite.dev/config/
export default defineConfig({
  plugins: [
    TanStackRouterVite(),
    visualizer({
      emitFile: true,
      filename: "stats.html",
    }),
    react(),
    tailwindcss(),
  ],
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
      "@server": path.resolve(__dirname, "../server"),
    },
  },
  server: {
    proxy: {
      "/api": {
        target:
          process.env.NODE_ENV === "development"
            ? "http://localhost:9999"
            : "https://learn.self-host.tech",
        changeOrigin: true,
      },
    },
  },
});

It uses a couple of plugins such as:

  • TanStackRouterVite() - it integrates TanStack Router with Vite and allows you to do things such as using file-based routing.
    • By the way, TanStackRouterVite() is now deprecated, and you should use tanstackRouter instead. My code is a bit old and uses a deprecated method.
  • visualizer - it generates an HTML file with the bundle size and dependencies, helping you to optimize your builds.
  • react() - it enables React fast refresh and JSX/TSX support, among others.
  • tailwindcss() - it integrates TailwindCSS with Vite.

The Vite file also contains 2 path aliases and a proxy for the server API requests.

The server proxy is used in dev mode, and it forwards all the requests made to the client for the /api path to the server.

The migration to TanStack Start required replacing a couple of plugins and removing the server proxying.

import viteReact from "@vitejs/plugin-react";
import path from "path";
import { defineConfig } from "vite";
import tsConfigPaths from "vite-tsconfig-paths";
import tailwindcss from "@tailwindcss/vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";

export default defineConfig({
  plugins: [
    tsConfigPaths(),
    tanstackStart({ customViteReactPlugin: true }),
    viteReact(),
    tailwindcss(),
  ],
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
      "@server": path.resolve(__dirname, "../server"),
    },
  },
  server: {
    port: 5173,
  },
});

After making all these changes, the application worked properly.

Other changes

The migration required other changes with Better Auth, the auth framework, and Hono, the framework for the server.

The auth client now requires you to specify the base URL of your auth server. In my case, Better Auth integrates with Hono, so my base URL is the server URL.

...

export const authClient = createAuthClient({
  baseURL: "http://localhost:9999", <<<<<<< new addition
  plugins: [
    inferAdditionalFields({
      user: {
        roles: {
          type: "string[]",
        },
      },
    }),
  ],
});

...

Lastly, the Hono RPC stopped working for some reason. The solution is to compile the code before using it and include the credentials and send the cookies to the server.

// Before

....

const client = hc<AppType>("/api");

....

// After

....

const client = hcWithType("http://localhost:9999/api", {
  init: {
    credentials: "include",
  },
});

....

And yes, the hardcoded URLs need replacing with proper environment variables.

The end

After the migration, the application works properly, but it is now a TanStack Start application.

What's left to do:

  • remove the auth fetching from the root route in the routes where it's needed
  • find a way to make the theme provider work
  • replace hardcoded values with environment variables

You can track the progress of the migration in this pull request.

๐Ÿ’ก Found this helpful? ๐Ÿ’ก

This content is completely free and took significant time to create. If it helped you solve a problem or learn something new, consider buying me a coffee โ˜•.

Your support helps me to:

  • Keep writing detailed tutorials
  • Research and test new technologies
  • Build more tools and applications
โ˜• Buy me a coffee - any amount helps!

๐Ÿงก No pressure - sharing this post helps too!