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>
);
}