I Built a Media Kit Generator Because a Creator Asked Me To
A content creator needed a media kit PDF. Every tool I found wanted a monthly subscription. So I built one that's free, no signup, and generates a polished one-pager in under 60 seconds.
A creator I know needed a media kit to send to brands. The kind of one-pager with your stats, your audience, your collab rates. Every tool she found either wanted $15/month or required signing up and handing over her social accounts.
I said I could probably build one over a weekend. That turned out to be half true.
What CreatorKit actually does
You fill out a six-step form: basic info, platform stats, audience demographics, content highlights, collaboration types, and template/color preferences. Then you hit download and get an A4 PDF that looks like something a designer made.
No signup. No account. No monthly fee. You get five downloads per day and that’s it.
The YouTube integration is the one clever bit. Paste your @handle and it auto-fetches your subscriber count, total views, and video count through the YouTube Data API. Everything else is manual entry, which honestly is fine for something you update once a quarter.
The stack
Next.js 16 on Cloudflare Workers via OpenNext. I went with Next because the Vientapps ecosystem already runs on it, and Cloudflare because KV namespaces give me rate limiting and YouTube API caching for free (well, pennies).
jsPDF for client-side PDF generation. This was the big decision. I could have used Puppeteer on a server to render HTML to PDF, but that means spinning up a headless browser per request. Client-side means zero server cost for the actual PDF rendering. The user’s browser does all the work.
Tailwind v4 for styling, Zod for validation schemas (more on that later), and Cloudflare KV for rate limiting with daily resets.
The hard part: drawing a PDF by hand
jsPDF gives you a blank canvas and a coordinate system measured in millimeters. That’s it. No flexbox. No grid. No text-align: center that actually works how you’d expect. You’re calling doc.text() with exact x/y coordinates and hoping things line up.
Every element on the page needs a helper function that returns the Y position after it renders, so the next element knows where to start:
export function drawText(
doc: jsPDF,
text: string,
x: number,
y: number,
options: DrawTextOptions = {}
): number {
const { fontSize = 10, fontStyle = "normal", color = "#000000", align = "left", maxWidth } = options;
const [r, g, b] = hexToRgb(color);
doc.setTextColor(r, g, b);
doc.setFontSize(fontSize);
doc.setFont("helvetica", fontStyle);
if (maxWidth) {
const lines = doc.splitTextToSize(text, maxWidth);
doc.text(lines, x, y, { align });
return y + lines.length * fontSize * 0.4;
}
doc.text(text, x, y, { align });
return y + fontSize * 0.4;
}
That fontSize * 0.4 magic number? That’s the approximate line height in millimeters. I arrived at it through trial and error, printing PDFs and holding them up to a ruler. There is no getLineHeight() in jsPDF. You just guess until it looks right.
The platform stats section was especially painful because it needs to handle 1 to 6+ platforms in a responsive grid:
const colCount = Math.min(data.platforms.length, 3);
const colWidth = CONTENT_WIDTH / colCount;
const boxHeight = 28;
data.platforms.forEach((platform, i) => {
const col = i % colCount;
const row = Math.floor(i / colCount);
const bx = MARGIN + col * colWidth + 1;
const by = y + row * (boxHeight + 3);
// ... draw each stat box
});
Three columns max, overflow to new rows. It’s the kind of layout that CSS grid handles in one line. In jsPDF, you’re computing column offsets and row indices by hand.
Images had their own problems
Every image the user uploads gets resized client-side before it touches the PDF. I used OffscreenCanvas to keep the main thread clear, then compress to JPEG at 85% quality:
export async function processImageFile(file: File): Promise<ImageProcessResult> {
const bitmap = await createImageBitmap(file);
const { width, height } = getScaledDimensions(bitmap.width, bitmap.height, MAX_DIMENSION);
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Failed to get canvas context");
ctx.drawImage(bitmap, 0, 0, width, height);
bitmap.close();
const blob = await canvas.convertToBlob({ type: "image/jpeg", quality: 0.85 });
const base64 = await blobToBase64(blob);
return { base64, width, height };
}
Without this step, a 4MB profile photo would bloat the PDF to unusable sizes. Capping at 400x400 and 85% JPEG keeps the final PDF under a megabyte in most cases.
What went wrong
I planned three templates: minimal, bold, and pastel. I finished one.
The minimal template is about 290 lines of coordinate math. Every section (header, bio, stats, audience, highlights, collaboration types, partners, contact footer) needs its own layout logic. And every template needs to reimplement all of that with different spacing, colors, backgrounds, and typography.
The bold template has a dark background, which means every text color needs to flip. The pastel template uses rounded containers everywhere, which means more coordinate math for border radii. I got about 60% through bold before I realized I was going to spend more time on templates than I had on the entire rest of the app.
So right now, only minimal is unlocked. Bold and pastel are visible in the template picker but greyed out.
The other thing that went sideways: I built full Zod validation schemas for the form data and then never wired them into the UI. The schemas exist in validation.ts, fully typed, fully correct, completely unused. The form just dispatches raw field updates through a reducer. It works fine, but there’s zero client-side validation beyond “is this field empty.”
Where it is now
Live at vientapps.com/tools/media-kit. The YouTube auto-fetch works well. The minimal template produces a genuinely nice-looking PDF. Rate limiting keeps costs near zero, since the YouTube API calls get cached in KV for 24 hours and each IP gets 10 lookups per day.
The live preview panel updates in real-time as you fill out the form, scaled to 47% of A4 size. It’s built with React components that mirror the PDF layout, which is its own maintenance headache since any PDF change needs a matching preview change.
What I’d do differently
Talk to more creators first. I built this because one person asked for it. I should have talked to ten more before writing a line of code. Does the six-step form collect too much? Too little? Are the collaboration type categories even right? I don’t actually know if this tool solves a real pain point at scale, or just for one person.
Use HTML-to-PDF instead of jsPDF. Something like Puppeteer or Playwright rendering a styled HTML page would let me write templates in CSS instead of coordinate math. Yes, it needs a server. But the developer experience of drawText(doc, text, 43.5, 127, { fontSize: 7 }) is genuinely bad, and it would have saved days on the template work.
Ship one template and move on faster. I spent time building the template picker UI, the color customization, the locked template states, all before I had even one template fully working. Should have shipped minimal alone, without the template system, and added the abstraction only when template #2 was actually ready.
Share this post
Stay in the loop
Get notified when I publish new posts. No spam, unsubscribe anytime.
Built as part of
View the project →