Deno Fresh Open Graph Meta Tags: Add OG to Your Fresh App
How to add og:title, og:image, og:description, and Twitter card tags to Deno Fresh apps using Head component and route-level meta configuration.
Fresh and OG tags: a natural fit
Deno Fresh is a full-stack web framework that renders pages server-side by default. Unlike React SPAs, Fresh sends complete HTML from the server on every request — which means social crawlers receive the full <head> with all your OG meta tags immediately. No SSR configuration needed, no hydration workarounds.
Fresh uses Preact under the hood and exposes a <Head> component (from $fresh/runtime.ts) that injects elements into the document <head> during server rendering.
Basic OG tags in a Fresh route
Import Head from $fresh/runtime.ts and place your <meta> tags inside it:
// routes/index.tsx
import { Head } from "$fresh/runtime.ts";
export default function Home() {
return (
<>
<Head>
<title>My Fresh App — Build Fast Sites with Deno</title>
<meta property="og:type" content="website" />
<meta property="og:title" content="My Fresh App — Build Fast Sites with Deno" />
<meta property="og:description" content="A full-stack web framework built on Deno and Preact." />
<meta property="og:image" content="https://yourdomain.com/og/home.png" />
<meta property="og:url" content="https://yourdomain.com/" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="My Fresh App" />
<meta name="twitter:description"content="A full-stack web framework built on Deno and Preact." />
<meta name="twitter:image" content="https://yourdomain.com/og/home.png" />
</Head>
<main>
<h1>Welcome to my Fresh app</h1>
</main>
</>
);
}Dynamic OG tags from route data
For dynamic routes like blog posts, fetch data in your route's handler and pass it to the page component via props. The handler runs server-side, so data is available during the initial render.
// routes/blog/[slug].tsx
import { Head } from "$fresh/runtime.ts";
import { Handlers, PageProps } from "$fresh/server.ts";
interface Post {
title: string;
excerpt: string;
slug: string;
coverImage: string;
publishedAt: string;
}
export const handler: Handlers<Post> = {
async GET(_req, ctx) {
const post = await fetchPost(ctx.params.slug);
if (!post) return ctx.renderNotFound();
return ctx.render(post);
},
};
export default function BlogPost({ data: post }: PageProps<Post>) {
const ogImage = `https://yourdomain.com/og/${post.slug}.png`;
const canonical = `https://yourdomain.com/blog/${post.slug}`;
return (
<>
<Head>
<title>{post.title}</title>
<meta property="og:type" content="article" />
<meta property="og:title" content={post.title} />
<meta property="og:description" content={post.excerpt} />
<meta property="og:image" content={ogImage} />
<meta property="og:url" content={canonical} />
<meta property="article:published_time" content={post.publishedAt} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={post.title} />
<meta name="twitter:description"content={post.excerpt} />
<meta name="twitter:image" content={ogImage} />
<link rel="canonical" href={canonical} />
</Head>
<article>
<h1>{post.title}</h1>
{/* post content */}
</article>
</>
);
}Reusable OG head component
Create a shared component to avoid repeating the same meta tag structure across routes:
// components/SeoHead.tsx
import { Head } from "$fresh/runtime.ts";
interface SeoHeadProps {
title: string;
description: string;
image: string;
url: string;
type?: "website" | "article";
publishedTime?: string;
}
export function SeoHead({
title,
description,
image,
url,
type = "website",
publishedTime,
}: SeoHeadProps) {
return (
<Head>
<title>{title}</title>
<meta name="description" content={description} />
<meta property="og:type" content={type} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={image} />
<meta property="og:url" content={url} />
{publishedTime && (
<meta property="article:published_time" content={publishedTime} />
)}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={image} />
<link rel="canonical" href={url} />
</Head>
);
}
// Usage in any route:
// <SeoHead
// title={post.title}
// description={post.excerpt}
// image={`https://yourdomain.com/og/${post.slug}.png`}
// url={`https://yourdomain.com/blog/${post.slug}`}
// type="article"
// publishedTime={post.publishedAt}
// />Generating OG images in Fresh
Fresh runs on Deno, which has excellent support for Satori-based OG image generation. Create an API route that generates PNG images on demand:
// routes/og/[slug].tsx — OG image generation endpoint
import satori from "npm:satori";
import { Resvg, initWasm } from "npm:@resvg/resvg-wasm";
import resvgWasm from "npm:@resvg/resvg-wasm/index_bg.wasm" assert { type: "wasm" };
import type { Handlers } from "$fresh/server.ts";
let wasmReady = false;
export const handler: Handlers = {
async GET(req, ctx) {
if (!wasmReady) {
await initWasm(resvgWasm);
wasmReady = true;
}
const post = await fetchPost(ctx.params.slug);
const title = post?.title ?? "Untitled";
const fontData = await fetch(
new URL("../../static/fonts/Inter-Bold.ttf", import.meta.url)
).then((r) => r.arrayBuffer());
const svg = await satori(
{ type: "div", props: { style: { width: "100%", height: "100%", display: "flex", background: "#0a0a0a", padding: "64px", alignItems: "flex-end" },
children: { type: "h1", props: { style: { color: "#fff", fontSize: 52, margin: 0 }, children: title } } } },
{ width: 1200, height: 630, fonts: [{ name: "Inter", data: fontData, weight: 700 }] }
);
const resvg = new Resvg(svg);
const png = resvg.render().asPng();
return new Response(png, {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
},
};Fresh 2.0 and the App Wrapper pattern
In Fresh 2.0+, you can use _app.tsx as a layout wrapper. Set default OG tags there and override per-route:
// routes/_app.tsx
import { Head } from "$fresh/runtime.ts";
import type { AppProps } from "$fresh/server.ts";
export default function App({ Component, state }: AppProps) {
return (
<>
<Head>
{/* Default tags — routes can override these */}
<meta property="og:site_name" content="My Fresh Site" />
<meta name="twitter:site" content="@yourhandle" />
</Head>
<body>
<Component />
</body>
</>
);
}Test your OG tags free
Paste any URL into OGFixer to see exactly how your link previews look on Twitter, LinkedIn, Discord, and Slack.