SoloDesk, the Invoice App I Built Because One PDF Wasn't Enough

Brad Malgas
Author
Most people who need an invoice would open Canva, use a prebuilt template, and move on with their lives. I didn't do that.
I built SoloDesk instead, because apparently I'm now the guy who looks at a simple invoice and thinks, "what if this became a whole app?"
That's how they get me. Not with some huge technical problem. Just one tiny admin task that looks easy enough to automate, and suddenly I'm working with PDF layouts, validation schemas, and pretending this is a normal use of my time.
The inspiration for this is simple: I don't have a villain origin story where some dude didn't pay me because he said my invoice looked like a scam. I wasn't escaping some subscription I hated. I didn't pay for accounting software and got bamboozled hard enough to swear revenge. I literally just needed a nice invoice generator, then I saw a thing I could probably build myself, and now we're here.
Cracked under zero pressure.
Invoices are such a weird part of solo work. They aren't the work, but they can end up taking up so much time just to look professional. Somehow that professionalism turns into its own little admin job: invoice design, numbering, VAT wording, payment terms, banking details, email draft, filenames, exports, reminders. Like, am I an accountant now?
SoloDesk is my attempt to simplify all of that.
At this point, it's just an invoice generation app for solo freelancers and small operators who need professional invoices without turning invoicing into a whole ceremony. The long-term idea is lightweight business admin suite, but the first problem is very specific: I want to enter invoice data once and get the useful outputs back - a PDF, an email draft, consistent filenames, and eventually a web preview. Very ambitious. Please respect the vision.
No spreadsheet gymnastics. No copy-pasting the same client details into five places. No opening a design tool every time I need to look professional enough to ask for money.
Why SoloDesk Exists
The pain point isn't that invoice software is bad. Or Canva templates, for that matter. Canva is probably the correct answer for a normal person.
The pain point is that most invoice software becomes a whole system, and most templates still leave you doing the same little bits by hand. I don't need auth, billing, dashboards, reminders, analytics, and an onboarding process just to generate an invoice for a client. I also don't want to manually duplicate client details, totals, payment notes, filenames, and email drafts every time. Imagine they don't even pay me after all of that.
What I need is the smallest reliable setup. Capture the invoice details once. Make sure the important fields are there. Calculate totals correctly. Generate a clean PDF. Generate the email that goes with it. Name the output consistently so I don't end up with final-final-invoice-please-pay-me.pdf.
Could I have done this in Canva? Yes.
Did I instead build an app before paying for one? Also yes.
That is the core of SoloDesk.
The first version started even smaller than that. Before it had a name, it was just a tiny invoicing tool: Markdown and HTML templates, a naming scheme, payment and email notes, and a Node script that could point local Chrome at an invoice file and print a PDF.
const chromePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const source = readFileSync(inputPath, "utf8");
const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<base href="${baseHref}">
<style>
@page { size: A4; margin: 13mm; }
html, body { margin: 0; padding: 0; background: #ffffff; }
</style>
</head>
<body>${source}</body>
</html>`;
That was useful. It was even kind of cool using Chrome this way.
But it had a real issue. Local Chrome is fine for a personal script. It's not a great foundation for something I want to use like an actual deployable app. And no, I don't mean "deployable" as in a public app with pricing tiers and a logo. I mean: how cool would it be to log into a small web portal and generate an invoice on the fly in front of a client?
I can see myself actually using it, which is kind of my whole 2026 project strategy. Build things that I would use.
That is where SoloDesk started becoming an app instead of a PDF script.
The First Real Issue Was The Data
The Zod schema is where the product started to become real.
If you've never used Zod before, the idea is simple: you describe what valid data should look like, and Zod checks whether the data you have actually matches that shape. In this case, I had to think through which fields belong on an invoice. For me, that was easier because I already had a template, so the question became: what information do I need to output everything on this page without needing any manual entry later?
Instead of hoping the user entered everything the invoice needs, the app can validate the invoice before it ever becomes a PDF.
This is the difference between a pretty document and an app that can eventually do useful admin work.
The schema knows that invoices have statuses. It knows VAT has rules. It knows line items need quantities and prices. It knows the difference between a supplier, a client, bank details, payment terms, and email details.
That matters because the PDF shouldn't be smart. The PDF should only render a valid invoice. The useful thinking belongs in the data model, the validation, and the accounting rules. And they said you can't learn from side projects.
The pdfmake Struggles
A big part of the app is obviously generating PDFs, so I needed a PDF library.
The first script used Chrome because HTML-to-PDF was familiar. Once SoloDesk started becoming more like an app, I wanted a cleaner way to create the PDF directly from data. That is where pdfmake came in.
On paper, pdfmake looked friendly enough. It uses a document definition object, which is basically a big JavaScript object that describes the PDF. There are tables, columns, stacks, margins, fonts, and styling. If you squint at it after three Monsters, it almost feels like HTML.
The second big lesson was that pdfmake is not HTML.
It looks familiar enough to trick you. The mental model isn't DOM layout. A document definition is a tree of layout pieces, and every piece has to be the right kind of thing in the right place. I tried nesting columns into stacks, stacks into content, and honestly it got messy for a bit.
That is why the helper layer became important.
function text(
value: string | number,
style: string,
overrides: Omit<ContentText, "text" | "style"> = {},
): ContentText {
return {
text: String(value),
style,
...overrides,
};
}
function compactContent(items: Array<Content | null | undefined>): Content[] {
return items.filter(
(item): item is Content => item !== null && item !== undefined,
);
}
These are small functions, but they represent the actual lesson.
A PDF invoice isn't a page of random strings. It's a structured layout. Optional content needs to disappear cleanly. Text needs to carry style names - still not HTML. Tables need predefined widths. Totals need to fill the space they're supposed to fill instead of shrinking into a weird little corner like they're shy.
The Unit Conversion Problem
The weirdest visual bug wasn't a dramatic rendering failure.
It was a sizing mismatch.
My styles from the Markdown template looked good, so I copied the values across like-for-like into the new app. Then the generated PDF came out too large and cramped. I fought those numbers for longer than I want to admit, only to realize I had copied values from a template that used CSS pixels, while pdfmake uses points for its numbers. Did I not tell you this isn't HTML?
The fix was tiny, but it changed the whole feel of the document:
const PX_TO_PT = 0.75;
const px = (value: number): number => value * PX_TO_PT;
The 0.75 wasn't a random "seems fine" number. pdfmake treats its numeric measurements as points, and in CSS land, one pixel is treated as 1/96 of an inch while one point is 1/72 of an inch. So the bridge between them is 72 / 96, which gives you 0.75 (lol maybe I am an accountant now?).
Once that conversion existed, the formatting issues finally made sense.
invoiceTitle: {
font: FONT_EXTRA_BOLD,
fontSize: px(28),
alignment: "right",
lineHeight: 1,
color: COLORS.ink,
},
sectionLabel: {
font: FONT_EXTRA_BOLD,
fontSize: px(10),
characterSpacing: px(1),
lineHeight: 1,
color: COLORS.accent,
},
That was a good reminder that copying values from one system into another is a very reliable way to give yourself unnecessary headaches.
And that wasn't even the only small thing that mattered. Units mattered. Fonts mattered. Transparency mattered too, because apparently having a transparent PDF background makes it black in some previews. That was a big issue until I gave the document an actual white background. It's the little things, man.
The Refactor
The best refactor came near the end of the week.
I did the typical prototype thing where one big block of code did everything in one place. It was called the "PDF service", but it had font adding, document definition creation, VAT and non-VAT content checks, and export logic sitting way too close together. I believe the professional term is a sh**t show.
The better plan was to make an invoice helper responsible for building the invoice document definition:
export const invoicePdfHelper = {
createDocumentDefinition(invoice: Invoice): TDocumentDefinitions {
return {
content: createDocumentContent(invoice),
info: {
title: `Invoice ${invoice.invoiceNumber}`,
author: invoice.supplier.name,
},
pageSize: "A4",
pageMargins: [PAGE_MARGIN, PAGE_MARGIN, PAGE_MARGIN, PAGE_MARGIN],
styles: getStyles(),
};
},
};
Then the PDF service could become cleaner:
export const pdfService = {
async exportPDF(
docDefinition: TDocumentDefinitions,
outputPath: string,
): Promise<void> {
pdfmake.addFonts(fonts);
await pdfmake.createPdf(docDefinition).write(outputPath);
},
};
Invoice logic belongs with invoices. PDF mechanics belong with PDFs. The service layer can connect them, but it shouldn't mix every concern together. Plus, if SoloDesk ever grows beyond invoices, the PDF service doesn't need to change. Only the input does.
This is product thinking, my friend. Don't build for a narrow use case.
What Broke
A lot of things broke during dev.
The first pdfmake layout was too much like HTML in my head. Some nodes were nested in the wrong place. SVGs needed to be treated as SVGs, not normal images. Empty strings created odd blank rows. Totals became smaller when they should have used the available width. Placeholder data made it hard to tell whether the layout was genuinely complete or just hiding missing pieces.
It was a lot to get through, but the useful part is that every bug taught me where the code wanted clearer lines.
That is the quiet win in a build like this. The PDF looked wrong, but the fix was usually not "move this thing five pixels to the left and pray". The fix was usually "make the data clearer", "make the helper more explicit", or "stop pretending this library works like the last thing I used".
Painful? Yes.
Useful? Also yes, which is irritating.
Current Situation
SoloDesk is now a modular Next.js monolith, for now. I hate those words, but basically a monolith means the app still lives as one main codebase instead of a bunch of separate services. "Modular" just means I've split the inside of that codebase into clearer parts, so invoices, PDFs, validation, and accounting helpers aren't all fighting in the same file.
In short - the app is still one codebase, but it already has a cleaner internal shape. It can take local JSON invoice data and, through a small route handler, export an invoice. Not a billion-dollar platform yet. I checked š„².
It has structured invoice data, Zod validation, accounting helpers, reusable pdfmake layout helpers, packaged fonts, template-inspired styling, a generic PDF export service, and an invoice-specific document builder.
Not overbuilt. Not thinking about productization too early. Just enough structure to make the next steps obvious: real database, working email generation, a web preview, and then the part I "love", the UI.
The bigger lesson from this project is that SoloDesk shouldn't be built around a PDF. It should be built around the different business objects that can produce useful outputs.
The PDF is just one of the outputs.
The PDF output from SoloDesk
Remember this quote when you use my app to charge your next client:
Cash rules everything around me C.R.E.A.M., get the money Dollar, dollar bill, y'all
You can watch the full video demo here: Dev Diaries 01: SoloDesk, I Built an Invoice App Because One PDF Wasn't Enough
Loading commentsā¦