I recently built StackSaver, a small content site that compares what AI and ecommerce tools cost. It has a landing page, two audience pages, a savings calculator and an MDX blog with about 20 posts.
The goal was simple: adding a post or changing a price should be a one-file edit, and everything else (sitemap, RSS, category pages, tables) should follow. Here's how it fits together with Next.js 16 (App Router) and Tailwind CSS 4.
1. Facts live in typed data files
Every price shown on the site comes from one array:
// src/data/stacks.ts
export type PricedTool = {
id: string;
name: string;
plan: string;
monthly: number;
group: "AI assistants" | "Ecom research" | "Creative";
};
export const pricesCheckedOn = "October 2026";
export const pricedTools: PricedTool[] = [
{ id: "chatgpt-pro", name: "ChatGPT", plan: "Pro", monthly: 200, group: "AI assistants" },
{ id: "claude-pro", name: "Claude", plan: "Pro", monthly: 20, group: "AI assistants" },
// ...
];
Pages, comparison tables inside blog posts and the calculator all import from here. When a price changes, I edit one line and bump pricesCheckedOn, which is printed next to every table.
2. Blog posts: a typed list plus MDX bodies
Post metadata (title, description, dates, category) lives in a plain TypeScript array, and only the body is MDX:
// src/content/posts.ts
export type Post = {
slug: string;
title: string;
description: string;
category: "ecommerce" | "ai-tools";
published: string; // ISO date
updated?: string;
readingMinutes: number;
};
export const posts: Post[] = [ /* newest first */ ];
I prefer this over frontmatter because TypeScript catches a missing description or a typo in a category before the build does, and listing pages don't need to parse every MDX file.
The post page prerenders every slug and imports the body dynamically:
// src/app/blog/[slug]/page.tsx
export function generateStaticParams() {
return posts.map((p) => ({ slug: p.slug }));
}
export default async function PostPage({ params }: PageProps<"/blog/[slug]">) {
const { slug } = await params;
const post = getPost(slug);
if (!post) notFound();
const { default: Body } = await import(`@/content/blog/${post.slug}.mdx`);
// ...render title, cover, <Body />, related posts
}
MDX is compiled with the Rust compiler, with GitHub-flavored markdown turned on so tables work:
// next.config.ts
experimental: {
mdxRs: { mdxType: "gfm" },
},
Inside posts I can drop in components like <SavingsTable stack="ai" />, which reads from the same data file as everything else, so a blog post never shows a stale price.
3. Sitemap and RSS come from the same list
Because posts are just an array, the sitemap and feed are a map() away:
// src/app/sitemap.ts
const postEntries = posts.map((p) => ({
url: `${site.url}/blog/${p.slug}`,
lastModified: p.updated ?? p.published,
priority: 0.7,
}));
// src/app/blog/rss.xml/route.ts (trimmed)
export function GET() {
const items = posts.map((p) => `<item>
<title>${esc(p.title)}</title>
<link>${site.url}/blog/${p.slug}</link>
<pubDate>${new Date(`${p.published}T12:00:00Z`).toUTCString()}</pubDate>
</item>`).join("\n");
return new Response(xml(items), {
headers: { "Content-Type": "application/rss+xml; charset=utf-8" },
});
}
Add a post to the array and the sitemap, feed, category pages and "related posts" all update on the next deploy.
4. Outbound links go through one redirect route
The site links out to one partner offer from a lot of places. Instead of pasting that URL everywhere, every button points at /go/<placement>, handled by a single optional catch-all route:
// src/app/go/[[...placement]]/route.ts
export function GET() {
return new Response(null, {
status: 307,
headers: {
Location: site.affiliateUrl,
"X-Robots-Tag": "noindex, nofollow",
"Cache-Control": "no-store",
},
});
}
Changing the destination is a one-line edit, the placement in the path shows up in analytics, and robots.ts disallows /go so the redirects never get indexed. The links themselves are marked rel="sponsored nofollow".
5. The calculator is one client component
The savings calculator is the only interactive page. It keeps a row per tool (on/off plus an editable price) and derives the totals with useMemo:
const { current, after, monthlySaving } = useMemo(() => {
const included = pricedTools.reduce(
(n, t) => (rows[t.id].on ? n + toNumber(rows[t.id].price) : n), 0);
const others = custom.reduce((n, c) => n + toNumber(c.price), 0);
const current = included + others;
const after = included > 0 ? MEMBERSHIP + others : current;
return { current, after, monthlySaving: current - after };
}, [rows, custom]);
On mobile a sticky bar shows the running total, and an IntersectionObserver hides it once the full result panel scrolls into view, so the same number is never on screen twice.
What I'd do again
- Data in TypeScript, prose in MDX. Type errors beat broken pages.
- Generate everything derivable. Sitemap, feed, categories and related posts should never be hand-maintained.
- One route for outbound links. It makes changes, tracking and SEO hygiene trivial.
If you want to see how it turned out, the live site is at getstacksaver.com. Happy to answer questions about the setup in the comments.
Disclosure: StackSaver is an affiliate site and earns a commission from the membership it recommends.
Top comments (0)