Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions apps/fumadocs/src/lib/notra-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { createServerFn } from "@tanstack/react-start";

import type { BlogPost } from "./blog-types";
import type { NotraPostInput } from "../../scripts/notra-content";

export type BlogPostDetail = {
post: BlogPost;
html: string;
};

// Server-only: fetch published posts from Notra at request time and map them to
// the blog shape. The blog routes are not prerendered (see vite.config.ts), so
// these run inside the Vercel SSR function and pick up new posts without a
// rebuild. Dynamic imports keep @usenotra/sdk and the Markdown renderer out of
// the client bundle; the API key stays server-side.
async function fetchBlogData(): Promise<{ posts: BlogPost[]; bodies: Record<string, string> }> {
const apiKey = process.env.NOTRA_API_KEY?.trim();
if (!apiKey) return { posts: [], bodies: {} };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Missing Key Empties Blog

When NOTRA_API_KEY is absent in the SSR runtime, this branch returns an empty dataset. Since the blog and OG routes are no longer prerendered, existing posts then disappear from /blog, /blog/$slug returns 404, and OG images return 404 instead of serving the generated snapshot that the old build-time path preserved.

Fix in Claude Code


const [{ Notra }, { mapNotraPost }] = await Promise.all([
import("@usenotra/sdk"),
import("../../scripts/notra-content"),
]);

const notra = new Notra({ bearerAuth: apiKey });
const raw: NotraPostInput[] = [];
let page = 1;

for (;;) {
const response = await notra.content.listPosts({
status: "published",
sort: "desc",
limit: 100,
page,
Comment on lines +29 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Future Posts Become Visible

The new list route asks Notra only for status: "published" and then returns every mapped post, without the old getPublishedBlogPosts() date check. If Notra contains a published post with a future publishedAt, /blog can show it before its scheduled date, even though the previous index route filtered future-dated posts out.

Fix in Claude Code

});
Comment on lines +29 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Notra Failure Returns 500

This request-time Notra call is not caught. A transient Notra outage, auth failure, timeout, or SDK error will throw through the server function and make /blog, /blog/$slug, and /og/blog/*.svg fail at request time, while the old build-time fetch path caught failures and kept serving the last generated content.

Fix in Claude Code

raw.push(...(response.posts as NotraPostInput[]));

const nextPage = response.pagination?.nextPage;
if (!nextPage) break;
page = nextPage;
}

const seenSlugs = new Set<string>();
const posts: BlogPost[] = [];
const bodies: Record<string, string> = {};

for (const item of raw) {
const mapped = mapNotraPost(item, seenSlugs);
if (!mapped) continue;

posts.push(mapped.post);
bodies[mapped.post.slug] = mapped.html;
}

posts.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
return { posts, bodies };
}

export const getBlogPostsServerFn = createServerFn({ method: "GET" }).handler(async () => {
return (await fetchBlogData()).posts;
});

export const getBlogPostServerFn = createServerFn({ method: "GET" })
.inputValidator((slug: string) => slug)
.handler(async ({ data: slug }): Promise<BlogPostDetail | null> => {
const { posts, bodies } = await fetchBlogData();
const post = posts.find((item) => item.slug === slug);
Comment on lines +66 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Slug Lookup Fetches Everything

Each single-post request calls fetchBlogData(), which paginates through all published posts before doing posts.find(...). A cache miss for several slugs or OG images can multiply external Notra calls by the full page count and make single-post rendering slow or rate-limited as the blog grows.

Fix in Claude Code

if (!post) return null;

return { post, html: bodies[post.slug] ?? "" };
});
33 changes: 14 additions & 19 deletions apps/fumadocs/src/routes/blog/$slug.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,9 @@ import { createFileRoute, Link, notFound } from "@tanstack/react-router";
import { HomeLayout } from "fumadocs-ui/layouts/home";
import { ArrowRight } from "lucide-react";

import {
formatBlogDate,
getBlogPost,
getBlogPostMetaTitle,
getBlogPostUrl,
type BlogPost,
} from "@/lib/blog";
import { formatBlogDate, getBlogPostMetaTitle, getBlogPostUrl, type BlogPost } from "@/lib/blog";
import { baseOptions } from "@/lib/layout.shared";
import { notraPostBodies } from "@/lib/notra-posts.generated";
import { getBlogPostServerFn } from "@/lib/notra-runtime";
import { siteUrl } from "@/lib/shared";

type BlogPostLoaderData = {
Expand All @@ -19,8 +13,8 @@ type BlogPostLoaderData = {
};

export const Route = createFileRoute("/blog/$slug")({
head: ({ params }) => {
const post = getBlogPost(params.slug, { includeFuture: true });
head: ({ params, loaderData }) => {
const post = (loaderData as BlogPostLoaderData | undefined)?.post;
const title = post ? getBlogPostMetaTitle(post.title) : "Email SDK Blog";
const description = post?.description ?? "Email SDK blog post.";
const canonicalUrl = `${siteUrl}${getBlogPostUrl(params.slug)}`;
Expand Down Expand Up @@ -110,22 +104,23 @@ export const Route = createFileRoute("/blog/$slug")({
};
},
component: BlogPostPage,
loader: ({ params }) => {
const post = getBlogPost(params.slug, { includeFuture: true });
if (!post) throw notFound();
loader: async ({ params }) => {
const detail = await getBlogPostServerFn({ data: params.slug });
if (!detail) throw notFound();

return {
post,
html: notraPostBodies[post.slug] ?? "",
};
return detail;
},
headers: () => ({
// Edge-cache the SSR'd post; updates and new posts appear within s-maxage, no rebuild.
"Cache-Control": "public, max-age=0, s-maxage=60, stale-while-revalidate=600",
}),
});

function BlogPostPage() {
const { html, post } = Route.useLoaderData() as BlogPostLoaderData;

// `html` is the post body, rendered from the post's Markdown and sanitized at
// build time in scripts/notra-content.ts, so it is safe to inject here.
// `html` is the post body, rendered from the post's Markdown and sanitized
// server-side in scripts/notra-content.ts, so it is safe to inject here.
return (
<HomeLayout {...baseOptions()}>
<main className="border-b border-fd-border bg-fd-background text-fd-foreground">
Expand Down
9 changes: 7 additions & 2 deletions apps/fumadocs/src/routes/blog/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { createFileRoute, Link } from "@tanstack/react-router";
import { HomeLayout } from "fumadocs-ui/layouts/home";
import { ArrowRight } from "lucide-react";

import { formatBlogDate, getPublishedBlogPosts, type BlogPost } from "@/lib/blog";
import { formatBlogDate, type BlogPost } from "@/lib/blog";
import { baseOptions } from "@/lib/layout.shared";
import { getBlogPostsServerFn } from "@/lib/notra-runtime";
import { appName, siteOgImageUrl, siteUrl } from "@/lib/shared";

export const Route = createFileRoute("/blog/")({
Expand Down Expand Up @@ -75,7 +76,11 @@ export const Route = createFileRoute("/blog/")({
links: [{ rel: "canonical", href: `${siteUrl}/blog` }],
};
},
loader: () => getPublishedBlogPosts(),
loader: () => getBlogPostsServerFn(),
headers: () => ({
// Edge-cache the SSR'd page; new Notra posts appear within s-maxage without a rebuild.
"Cache-Control": "public, max-age=0, s-maxage=60, stale-while-revalidate=600",
}),
component: BlogIndex,
});

Expand Down
17 changes: 11 additions & 6 deletions apps/fumadocs/src/routes/og/blog/$.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
import { createFileRoute } from "@tanstack/react-router";

import { getBlogPost } from "@/lib/blog";
import type { BlogPost } from "@/lib/blog";
import { getBlogPostServerFn } from "@/lib/notra-runtime";
import { appName, siteUrl } from "@/lib/shared";

export const Route = createFileRoute("/og/blog/$")({
server: {
handlers: {
GET({ params }) {
async GET({ params }) {
const slug = params._splat?.replace(/\.svg$/, "");
const post = slug ? getBlogPost(slug, { includeFuture: true }) : undefined;

if (!post || params._splat === slug) {
if (!slug || params._splat === slug) {
return new Response("Not found", { status: 404 });
}

return new Response(renderBlogOgImage(post), {
const detail = await getBlogPostServerFn({ data: slug });
if (!detail) {
return new Response("Not found", { status: 404 });
}

return new Response(renderBlogOgImage(detail.post), {
headers: {
"cache-control": "public, max-age=86400, stale-while-revalidate=604800",
"content-type": "image/svg+xml; charset=utf-8",
Expand All @@ -25,7 +30,7 @@ export const Route = createFileRoute("/og/blog/$")({
},
});

type OgPost = NonNullable<ReturnType<typeof getBlogPost>>;
type OgPost = BlogPost;

const palettes = [
["#121214", "#f2d492", "#6ee7b7", "#8aa8ff"],
Expand Down
15 changes: 5 additions & 10 deletions apps/fumadocs/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import mdx from "fumadocs-mdx/vite";
import { nitro } from "nitro/vite";
import { defineConfig, loadEnv } from "vite";

import { getAllBlogPosts, getBlogPostImageUrl, getBlogPostUrl } from "./src/lib/blog";
import { docsVersions, getDocsVersionHref } from "./src/lib/versions";

function collectContentPages(dir: string) {
Expand Down Expand Up @@ -80,6 +79,11 @@ export default defineConfig(({ mode }) => {
prerender: {
enabled: true,
crawlLinks: true,
// Blog routes (/blog, /blog/$slug, /og/blog/*) render on-demand via the
// SSR function so new Notra posts appear without a rebuild; everything
// else stays prerendered to static HTML.
filter: ({ path }: { path: string }) =>
!path.startsWith("/blog") && !path.startsWith("/og/blog"),
},

pages: [
Expand All @@ -89,9 +93,6 @@ export default defineConfig(({ mode }) => {
{
path: "/docs",
},
{
path: "/blog",
},
{
path: "/about",
},
Expand All @@ -104,12 +105,6 @@ export default defineConfig(({ mode }) => {
{
path: "/terms",
},
...getAllBlogPosts().map((post) => ({
path: getBlogPostUrl(post.slug),
})),
...getAllBlogPosts().map((post) => ({
path: getBlogPostImageUrl(post.slug),
})),
...versionedDocsPages,
{
path: "/api/search",
Expand Down
2 changes: 1 addition & 1 deletion vercel.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"outputDirectory": "apps/fumadocs/.vercel/output/static",
"outputDirectory": "apps/fumadocs/.vercel/output",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Output Root May Bypass Functions

This changes outputDirectory from the static assets folder to the Build Output root. If Vercel treats this setting as the static output directory instead of recognizing the Build Output API structure, the generated functions under .vercel/output can be ignored and the newly non-prerendered /blog and /og/blog/* routes will 404 or expose raw output paths.

Fix in Claude Code

"rewrites": [
{
"source": "/",
Expand Down