Better Blog

Tanstack Start

SSR/SSG with server prefetch and client hydration

This renders all blog routes under /posts/... with server prefetch + client hydration. Provide separate data providers for server and client.

1) src/routes/posts/$.tsx (server)

Prefetch data in the loader, render via BlogPageRouter, and build SEO metadata using the router's meta and extra fields.

import { createFileRoute } from "@tanstack/react-router"
import { BlogPageRouter } from "better-blog/client"
import { getRouteInfo, prefetchRoute, resolveSEO } from "better-blog/router"
import { blogDataProvider } from "../../lib/blog-data-provider"

// Helper to normalize path from TanStack params
function normalizePath(splat?: string): string {
  const pathSegments = splat?.split("/").filter(Boolean) || []
  return pathSegments.length ? `/${pathSegments.join("/")}` : "/"
}

export const Route = createFileRoute("/posts/$")({
  ssr: true,
  component: RouteComponent,
  loader: async ({ params, context }) => {
    const routePath = normalizePath(params._splat)
    await prefetchRoute(routePath, blogDataProvider, context.queryClient)
    return null
  },
  head: async ({ params }) => {
    const routePath = normalizePath(params._splat)
    const routeInfo = getRouteInfo(routePath)
    const seo = await resolveSEO(routeInfo, blogDataProvider)
    
    // Map SEO to TanStack head format
    const meta: Array<{ name?: string; property?: string; content?: string; title?: string }> = []
    
    if (seo.meta.title) meta.push({ title: seo.meta.title })
    if (seo.meta.description) meta.push({ name: "description", content: seo.meta.description })
    if (seo.meta.robots) meta.push({ name: "robots", content: seo.meta.robots })
    
    // Open Graph
    meta.push({
      property: "og:type",
      content: seo.meta.openGraph?.type ?? (seo.meta.openGraph?.title ? "article" : "website")
    })
    if (seo.meta.openGraph?.title ?? seo.meta.title) {
      meta.push({ property: "og:title", content: seo.meta.openGraph?.title ?? seo.meta.title })
    }
    if (seo.meta.openGraph?.description ?? seo.meta.description) {
      meta.push({ property: "og:description", content: seo.meta.openGraph?.description ?? seo.meta.description })
    }
    if (seo.meta.openGraph?.url) meta.push({ property: "og:url", content: seo.meta.openGraph.url })
    
    const ogImage = seo.meta.openGraph?.images?.[0]
    const ogImageUrl = typeof ogImage === "string" ? ogImage : ogImage?.url
    if (ogImageUrl) meta.push({ property: "og:image", content: ogImageUrl })
    
    // Twitter
    meta.push({
      name: "twitter:card",
      content: seo.meta.twitter?.card ?? (ogImageUrl ? "summary_large_image" : "summary")
    })
    if (seo.meta.twitter?.title ?? seo.meta.title) {
      meta.push({ name: "twitter:title", content: seo.meta.twitter?.title ?? seo.meta.title })
    }
    if (seo.meta.twitter?.description ?? seo.meta.description) {
      meta.push({ name: "twitter:description", content: seo.meta.twitter?.description ?? seo.meta.description })
    }
    if (ogImageUrl) meta.push({ name: "twitter:image", content: ogImageUrl })
    
    // Links
    const links: Array<{ rel: string; href: string }> = []
    if (seo.meta.canonicalUrl) links.push({ rel: "canonical", href: seo.meta.canonicalUrl })
    
    return { meta, links }
  },
})

function RouteComponent() {
  const { _splat } = Route.useParams();
  return <BlogPageRouter path={_splat} />
}

2) src/routes/__root.tsx

Define the root document and wrap your app with Provider to supply context. Uses createRootRouteWithContext.

// src/routes/__root.tsx
/// <reference types="vite/client" />
import type { ReactNode } from 'react'
import {
  Outlet,
  createRootRouteWithContext,
  HeadContent,
  Scripts,
} from '@tanstack/react-router'
import type { MyRouterContext } from '@/router'
import { Provider } from '@/providers'

import appCss from "@/styles/app.css?url"

export const Route = createRootRouteWithContext<MyRouterContext>()({
  head: () => ({
    meta: [
      {
        charSet: 'utf-8',
      },
      {
        name: 'viewport',
        content: 'width=device-width, initial-scale=1',
      },
      {
        title: 'TanStack Start Starter',
      },
      { property: 'og:type', content: 'website' },
      { property: 'og:title', content: 'Better Blog' },
      { property: 'og:description', content: 'A modern blog powered by Better Blog + TanStack.' },
      { name: 'twitter:card', content: 'summary_large_image' },
      { name: 'twitter:title', content: 'Better Blog' },
      { name: 'twitter:description', content: 'A modern blog powered by Better Blog + TanStack.' },
    ],
    links: [
        {
          rel: "stylesheet",
          href: appCss,
        },
    ],
  }),
  component: RootComponent,
})

function RootComponent() {
  return (
    <RootDocument>
      <Outlet />
    </RootDocument>
  )
}

function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
  return (
    <html>
      <head>
        <meta name="viewport" content="initial-scale=1, viewport-fit=cover, width=device-width" />
        <meta name="theme-color" media="(prefers-color-scheme: light)" content="oklch(1 0 0)" />
        <meta name="theme-color" media="(prefers-color-scheme: dark)" content="oklch(0.145 0 0)" />
        <HeadContent />
      </head>
      <body>
        <Provider>
            {children}
        </Provider>
        <Scripts />
      </body>
    </html>
  )
}

3) src/providers.tsx (client)

Client-side provider wiring TanStack Router Link, navigation helpers, and optional admin UI.

"use client";

import { ReactNode } from "react";
import { Link, useRouter } from "@tanstack/react-router"
import { type BlogUIComponents, BlogProvider } from "better-blog/context";

const components: BlogUIComponents = {
  Link: ({ href, children, className }) => (
    <Link to={href} className={className}>
      {children}
    </Link>
  ),
  Image: ({ src, alt, className, width, height }) => (
    <img
      src={src}
      alt={alt}
      className={className}
      width={width || 800}
      height={height || 400}
    />
  ),
};


export function Provider({ children }: { children: ReactNode }) {
  const router = useRouter();

  return (
    <BlogProvider
      localization={{
        BLOG_LIST_TITLE: "Blog Posts",
      }}
      components={components}
      adminPermissions={{
        canCreate:true,
        canUpdate:true,
        canDelete:true
      }}
      navigate={(href) => router.navigate({ href })}
      replace={(href) => router.navigate({ href, replace: true })}
      uploadImage={async (file) => {
        console.log("uploadImage", file);
        //fake wait
        await new Promise((resolve) => setTimeout(resolve, 1000));
        return "https://placehold.co/400";
      }}
    >
      {children}
    </BlogProvider>
  );
}

What's next?