How the Resumer blog is built
Resumer has a public blog at /blog and /blog/[slug]. Posts are markdown files in the repo. There is no CMS, no admin UI, and no database of articles.
This post is the implementation behind that. It describes the code on main after PR #14 (Add an in-app blog for product notes), merged 1 Oct 2026 at 22:01 UTC (2 Oct 00:01 CEST) as ab5b621896bc00c706ab0e500cc591ca7c6f60ad.
What it actually is
A post is content/blog/<slug>.md with YAML frontmatter and a markdown body. At request / build time, gray-matter splits the frontmatter, marked turns the body into HTML on the server, and the App Router pages render that HTML. Publishing means merging a markdown file. Unpublishing a draft means keeping draft: true (or deleting the file).
What it does:
- Lists published posts at
/blog(title, date, description). - Renders one post at
/blog/[slug]with Article JSON-LD and Open Grapharticlemetadata. - Puts
/,/blog, and each published post into the sitemap. - Shares the landing marketing chrome (Resumer, Blog, Launch Editor).
What it does not do:
- It does not talk to a CMS or headless API.
- It does not serve draft posts on the index, in the sitemap, or via
generateStaticParams. A direct URL for a draft (or unknown slug) 404s. - It does not change
/build. The editor stays noindex and disallowed inrobots.
The stack is Next.js 15 App Router (next ^15.5.12). Blog deps in package.json are gray-matter ^4.0.3 and marked ^18.0.14. Dates on the page use the existing dayjs helper.
Where the modules live
| Piece | Path | Role |
|---|---|---|
| Loader | src/lib/blog/posts.ts |
Read, validate, list, render. |
| Types | src/lib/blog/types.ts |
PostMeta and Post. |
| Date display | src/lib/blog/format.ts |
formatPostDate → MMMM D, YYYY. |
| Index | src/app/blog/page.tsx |
Published list. |
| Post | src/app/blog/[slug]/page.tsx |
SSG params, metadata, HTML body. |
| Layout | src/app/blog/layout.tsx |
MarketingNav + MarketingFooter. |
| Sitemap | src/app/sitemap.ts |
/, /blog, published slugs. |
| Metadata | src/lib/seo/metadata.ts |
blogIndexMetadata, blogPostMetadata. |
| JSON-LD | src/lib/seo/json-ld.ts |
articleLd. |
| Content | content/blog/*.md |
The posts. |
Frontmatter and loading
Required fields are title, description, and date as YYYY-MM-DD. draft is optional and must be a boolean when present.
// src/lib/blog/types.ts
export type PostMeta = {
slug: string;
title: string;
description: string;
/** ISO date `YYYY-MM-DD`. */
date: string;
draft: boolean;
};
export type Post = PostMeta & {
html: string;
};
readSource is the single entry for a slug. The slug must match a tight pattern before any file is opened. The path is resolved under content/blog and checked so it cannot escape that directory.
// src/lib/blog/posts.ts
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
function assertInsideBlog(filePath: string): void {
const resolved = path.resolve(filePath);
const root = blogDirectory();
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
throw new Error(`Blog path escapes content directory: ${filePath}`);
}
}
function readSource(slug: string): { meta: PostMeta; markdown: string } | null {
if (!SLUG_PATTERN.test(slug)) return null;
const filePath = path.join(blogDirectory(), `${slug}.md`);
assertInsideBlog(filePath);
if (!fs.existsSync(filePath)) return null;
const raw = fs.readFileSync(filePath, "utf8");
const parsed = matter(raw);
return {
meta: parseMeta(slug, parsed.data),
markdown: parsed.content,
};
}
Bad dates fail loudly. date must look like YYYY-MM-DD and be a real calendar day (or a Date that serializes to one). A missing title or description throws. A non-boolean draft throws. That keeps broken frontmatter out of the build instead of shipping empty cards.
The body is rendered with marked.parse(..., { async: false }) so the loader stays synchronous and fits SSG.
Drafts stay offline from discovery
listPosts walks content/blog, skips anything that is not *.md, and drops drafts. Sort is date descending, then slug ascending.
// src/lib/blog/posts.ts
export function listPosts(): PostMeta[] {
// ...
if (source.meta.draft) continue;
posts.push(source.meta);
// ...
posts.sort((a, b) => {
const byDate = b.date.localeCompare(a.date);
if (byDate !== 0) return byDate;
return a.slug.localeCompare(b.slug);
});
return posts;
}
export function getPostBySlug(slug: string): Post | null {
const source = readSource(slug);
if (!source || source.meta.draft) return null;
return {
...source.meta,
html: renderMarkdown(source.markdown),
};
}
Everything that discovers posts goes through listPosts:
- The index page.
generateStaticParamson the post route.src/app/sitemap.ts.
So a draft is not linked, not prebuilt as a static path, and not in sitemap.xml. getPostBySlug also returns null for drafts, so a guessed URL hits notFound() the same way an unknown slug does.
That is intentional. You can merge a post with draft: true, keep it in git, and leave public discovery empty until you flip the flag and redeploy.
App Router: index and post
The index is a server component. It calls listPosts(), prints the lede shared with metadata, and links each card to /blog/${slug}.
// src/app/blog/page.tsx
export const metadata: Metadata = blogIndexMetadata(site);
export default function BlogIndexPage() {
const posts = listPosts();
// ...
}
The post page is where App Router specifics show up: static params, async params, metadata, and notFound.
// src/app/blog/[slug]/page.tsx
export function generateStaticParams(): BlogPostParams[] {
return listPosts().map((post) => ({ slug: post.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<BlogPostParams>;
}): Promise<Metadata> {
const { slug } = await params;
const post = getPostBySlug(slug);
if (!post) notFound();
return blogPostMetadata(site, post);
}
export default async function BlogPostPage({
params,
}: {
params: Promise<BlogPostParams>;
}) {
const { slug } = await params;
const post = getPostBySlug(slug);
if (!post) notFound();
return (
<article>
<JsonLd data={articleLd(site, post)} />
{/* title + time from post meta; body from post.html */}
<div
className={styles.prose}
dangerouslySetInnerHTML={{ __html: post.html }}
/>
</article>
);
}
The page template owns the <h1> from post.title. Markdown bodies should not repeat that title as a leading # heading — existing posts start with a lede paragraph instead.
params is a Promise (Next 15). Both generateMetadata and the page await it, then load the post once each. Missing or draft → notFound(), which uses src/app/blog/not-found.tsx under the blog layout.
Metadata, Open Graph, JSON-LD
Index metadata is a normal website share card. Post metadata sets Open Graph type to article and includes publishedTime from the frontmatter date at midnight UTC.
// src/lib/seo/metadata.ts
openGraph: {
type: "article",
// ...
publishedTime: `${post.date}T00:00:00.000Z`,
},
JSON-LD is an Article document with headline, description, datePublished / dateModified (both the post date today), canonical URL, and Resumer as author/publisher organization.
// src/lib/seo/json-ld.ts
export function articleLd(
site: Site,
post: { title: string; description: string; date: string; slug: string },
): JsonLdDocument {
const url = absolute(site, `/blog/${post.slug}`);
return {
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
description: post.description,
datePublished: post.date,
dateModified: post.date,
mainEntityOfPage: url,
url,
// author + publisher → Organization (site.name)
};
}
The sitemap lists the landing page, the blog index (lastModified from the newest post date), and each published post. /build is not in the sitemap. robots allows / and disallows /build and /api/. The editor layout still exports buildMetadata() with robots: { index: false, follow: false }.
Marketing chrome
src/app/blog/layout.tsx wraps every blog page with the same nav and footer as the landing page: logo → /, Blog → /blog, Launch Editor → /build. MarketingNav takes current="blog" so the Blog link gets aria-current="page". That keeps product notes inside the Resumer shell instead of a separate docs theme.
How to add a post
- Add
content/blog/<slug>.md. Slug must match^[a-z0-9]+(?:-[a-z0-9]+)*$. - Frontmatter:
title,description,date(YYYY-MM-DD), and optionaldraft. - Write the body without duplicating the title as an H1.
- Merge.
listPosts/ build / sitemap pick it up ifdraftis nottrue.
To keep a post in the repo but off the site, set draft: true. The file stays; discovery does not.
Why this shape
Resumer's product notes are infrequent and owned by the same repo as the app. A CMS would add accounts, previews, and another place for truth. Markdown-in-repo means review happens in the PR, drafts are a boolean, and SEO (canonical, OG article, JSON-LD, sitemap) lives next to the routes that need it.
The cost is obvious: you need a deploy to publish, and there is no in-browser editor. For a CV builder that already treats /build as a private surface and the marketing site as static-ish App Router pages, that trade is fine.
Until a post is merged with draft: false, the honest description of this feature is: the plumbing is on main, the index lists whatever is published under content/blog, and anything marked draft stays offline from the index, the sitemap, and static params.