I was setting up a site that primarily serves a front-page plus documentation, where most parts are static and I wanted to fully generate them ahead of time to serve from a bucket. Astro makes this very easy, and also enables me to create and iterate on content for documentation very quickly. Furthermore, it also has the nice benefit being able to weave in interactive components on the fly if needed.

The docs in question are fully generated from markdown, but I like to have a few interactive examples so that users can build a better understanding of features. My use case is basically just a complex JSON configuration in a single object, not multi-file setups so users. It is very useful to have some visual feedback to play around with, but having to jump into a code sandbox for testing any change seemed overkill.

To create this, Astro reads my .mdx templates which includes embedded React components. Via Astro Islands I can serve this very efficiently and only have to load the components when needed.

However, to actually use the platform I have a fully interactive part which acts as a dashboard to the user. Here they can configure their account specific settings, upload files and do whatever needs to be done. There is a lot of moving parts, so I opted to build it as an SPA instead of relying on Astro. This comes with so many nice ease-of-use features and reactivity plus the package ecosystem of React. It just made sense to me for building such a dashboard.

I wanted to use TanStack Router as the navigation for this portion and while their documentation is extremely well written and easy to follow it is more meant for a "classic" Vite setup. There is no official integration setup, but it was still fairly easy to configure in the end.

My file tree layout is as follows:

./
|- astro.config.mjs
|- src/
|  |- layout/                  <  These are the regular Astro folders,
|  |- content/                 < 
|  |- pages/                   <  
|  |  |- index.astro           <
|  |  |- dashboard
|  |  |  |- [...page].astro    < This Astro page imports the React dashboard from
|  |                             from src/dashboard/main
|  |
|  |- components/              <  Folders for shared (react) components,
|  |  |- astro/                <  they can be imported in Astro or the Dashboard 
|  |  |- react/                <  code.
|  |
|  |- dashboard/               <  Dashboard code, here I can set up
|  |  |- main.tsx              <  @tanstack/react-router files as
|  |  |- routes/               <  in a regular project. 
|  |  |  |- __root.tsx         < 
|  |  |  |- dashboard/         <  Matching the route `/dashboard` of the Astro page
|  |  |  |  |- index.lazy.tsx  < 
|  |  |- routeTree.gen.ts      <  Auto-generated by @tanstack/router-plugin/vite

This layout lets me to co-locate Astro layout and components with the React dashboard. It is super useful because it allows me to import shared React components in both areas.

To generate the routeTree.gen.ts that is required for TanStack Router, the internal Vite bundler of Astro needs to be made aware of the generator plugin. I use the following Astro config:

// astro.config.mjs
import { defineConfig } from 'astro/config';
import react from "@astrojs/react";
import mdx from "@astrojs/mdx";

import { TanStackRouterVite } from '@tanstack/router-plugin/vite'

// https://astro.build/config
export default defineConfig({
  integrations: [react(), mdx()],
  vite: {
    plugins: [TanStackRouterVite({
      routesDirectory: "./src/dashboard/routes",
      generatedRouteTree: "./src/dashboard/routeTree.gen.ts",
      routeFileIgnorePrefix: "-",
      quoteStyle: "double",
    })]
  }
});

Astro makes it possible to append to Vite plugins by exposing the vite key in its configuration. By setting the routerDirectory and generatedRouteTree params in the TanStackRouterVite options it is possible to point the route generation at the correct folder. I had no luck using the tsr.config.json as referenced in the documentation, but passing the options directly like so seems to work fine. Just make sure to npm install @tanstack/router-plugin/vite for this to work.

The following two files are then practically the glue to import the actual dashboard into Astro and render it into the page layout. Astro needs to know which paths exist to generate the proper SSG routes. This can be accomplished by parsing the routeTree as generated by TanStack Router and populating getStaticPaths.

// src/pages/dashboard/[...page].astro
---
import Layout from "../layouts/Layout.astro"

import { Dashboard as ReactDashboard } from "../../dashboard/main.tsx"

import { type Route } from "@tanstack/react-router"
import { routeTree } from "../../dashboard/routeTree.gen.ts"

export async function getStaticPaths() {
  const mapChildren = (route: Route): any => {
    if (!route?.options) {
      return []
    }
    return [
      route.options,
      ...Object.values(route?.children || []).flatMap((child) =>
        mapChildren(child)
      ),
    ]
  }

  const asMapped = mapChildren(routeTree as unknown as Route)
  return asMapped.map((route: { path?: string }) => ({
    params: {
      // splice the prefixed `/dashboard` by the generator
      path: route.path?.split("/")?.splice(2)?.join("/"),
    },
  }))
}
---

<Layout title="">
  <main>
    <ReactDashboard client:only="react" />
  </main>
</Layout>

At this point I want to note that this strategy will generate a new file for EVERY route in your SPA router. In my case this is useful because it still allows me to dump the whole site into a bucket, but if you are running an Astro adapter to serve your site dynamically in hybrid or server mode then you might want to consider using prefetch: false for just this page.

Finally this is the React component which is the entry point for the dashboard:

// src/dashboard/main.tsx
import { RouterProvider, createRouter } from "@tanstack/react-router"

// Import the generated route tree
import { routeTree } from "./routeTree.gen.ts"

// Create a new router instance
const router = createRouter({ routeTree })

// Register the router instance for type safety
declare module "@tanstack/react-router" {
  interface Register {
    router: typeof router
  }
}

// Usually here we would render to the DOM, but since Astro will handle
// this for us we are okay to just return the component
export const Dashboard = () => <RouterProvider router={router} />

As far as I can tell this setup is totally seamless and behaves exactly like you would expect. I am super impressed by both TanStack's Router and Astro to just go hand-in-hand like this without any issues.