TanStack Query Open Graph Tags: Dynamic OG Meta from Server State
Use TanStack Query (React Query) data to generate dynamic Open Graph meta tags — covers dehydration, SSR prefetch, and Helmet/Next.js Head integration.
The OG tag problem with client-side fetching
TanStack Query (formerly React Query) is the go-to library for server state management in React. But by default, useQuery fetches data client-side after the component mounts. Social crawlers — Twitterbot, LinkedInBot, Discordbot — never execute JavaScript. They parse raw HTML.
If your OG tags depend on data fetched by useQuery and you render them like <meta property="og:title" content={data?.title} />, crawlers will see an empty string (or nothing) because data is undefined in the server-rendered HTML.
The solution: prefetch on the server and dehydrate the query cache into the HTML. Then your OG tags have real values during SSR.
SSR prefetch with Next.js App Router (recommended)
In Next.js App Router, the simplest approach is to skip TanStack Query for initial data fetching of OG-critical data and instead use generateMetadata:
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
async function getPost(slug: string) {
const res = await fetch(`https://api.yourdomain.com/posts/${slug}`, {
next: { revalidate: 3600 },
});
if (!res.ok) return null;
return res.json() as Promise<{ title: string; excerpt: string; ogImage: string }>;
}
// generateMetadata runs on the server — OG tags in HTML before JS runs
export async function generateMetadata(
{ params }: { params: { slug: string } }
): Promise<Metadata> {
const post = await getPost(params.slug);
if (!post) return { title: "Post Not Found" };
return {
title: `${post.title} | My Blog`,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [{ url: post.ogImage }],
type: "article",
},
twitter: {
card: "summary_large_image",
title: post.title,
images: [post.ogImage],
},
};
}
// Client component can still use TanStack Query for interactive UX
export default async function BlogPostPage({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
return (
<article>
<h1>{post?.title}</h1>
<p>{post?.excerpt}</p>
</article>
);
}Server prefetch with dehydration (Pages Router)
If you're using Next.js Pages Router or another SSR framework, use TanStack Query's prefetchQuery + dehydrate pattern to populate the cache server-side:
// pages/blog/[slug].tsx (Next.js Pages Router)
import { dehydrate, HydrationBoundary, QueryClient, useQuery } from "@tanstack/react-query";
import type { GetServerSideProps } from "next";
import Head from "next/head";
interface Post {
title: string;
excerpt: string;
ogImage: string;
slug: string;
}
async function fetchPost(slug: string): Promise<Post> {
const res = await fetch(`https://api.yourdomain.com/posts/${slug}`);
if (!res.ok) throw new Error("Not found");
return res.json();
}
export const getServerSideProps: GetServerSideProps = async ({ params }) => {
const slug = params?.slug as string;
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ["post", slug],
queryFn: () => fetchPost(slug),
});
// Get data from cache for SSR OG tags
const post = queryClient.getQueryData<Post>(["post", slug]);
return {
props: {
dehydratedState: dehydrate(queryClient),
// Pass OG data as props for server-rendered <Head>
ogTitle: post?.title ?? "",
ogDescription: post?.excerpt ?? "",
ogImage: post?.ogImage ?? "",
slug,
},
};
};
interface PageProps {
dehydratedState: unknown;
ogTitle: string;
ogDescription: string;
ogImage: string;
slug: string;
}
function BlogPostContent() {
const slug = useRouter().query.slug as string;
const { data: post } = useQuery({
queryKey: ["post", slug],
queryFn: () => fetchPost(slug),
});
return (
<article>
<h1>{post?.title}</h1>
<p>{post?.excerpt}</p>
</article>
);
}
export default function BlogPostPage({ dehydratedState, ogTitle, ogDescription, ogImage, slug }: PageProps) {
return (
<>
{/* OG tags rendered in SSR HTML — crawlers see these */}
<Head>
<title>{ogTitle} | My Blog</title>
<meta name="description" content={ogDescription} />
<meta property="og:type" content="article" />
<meta property="og:title" content={ogTitle} />
<meta property="og:description" content={ogDescription} />
<meta property="og:image" content={ogImage} />
<meta property="og:url" content={`https://yourdomain.com/blog/${slug}`} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content={ogImage} />
</Head>
<HydrationBoundary state={dehydratedState}>
<BlogPostContent />
</HydrationBoundary>
</>
);
}Remix + TanStack Query
In Remix, the loader function fetches data on the server. You can use TanStack Query as a client-side cache on top of Remix loader data, while setting OG tags from the loader in the meta() export — which runs server-side:
// routes/blog.$slug.tsx
import { json, type LoaderFunctionArgs, type MetaFunction } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
export async function loader({ params }: LoaderFunctionArgs) {
const post = await fetchPost(params.slug!);
return json(post);
}
export const meta: MetaFunction<typeof loader> = ({ data }) => [
{ title: `${data?.title} | My Blog` },
{ property: "og:title", content: data?.title ?? "" },
{ property: "og:description", content: data?.excerpt ?? "" },
{ property: "og:image", content: data?.ogImage ?? "" },
{ name: "twitter:card", content: "summary_large_image" },
];
export default function BlogPost() {
const post = useLoaderData<typeof loader>();
// Optionally use TanStack Query for mutations / real-time updates
return <article><h1>{post.title}</h1></article>;
}Check your OG tags are server-rendered
Paste any page URL into OGFixer to confirm OG tags are present in the SSR HTML — not just after JavaScript runs.