Skip to content

How I Planned My Full-Stack Invoice App (and What I'd Do Differently)

What began as a Frontend Mentor challenge became a deep dive into full-stack architecture. This post unpacks my design decisions, lessons learned, and why overengineering can be a powerful way to learn simplicity.

• 10 min read

Introduction

When I started building my invoice app, I had no intention of turning it into a full-stack project. It began as a Frontend Mentor challenge—a visually intricate design that seemed like the perfect way to stretch my React and CSS muscles. I wanted to prove to myself that I could rebuild a complex layout from a Figma file using React, TypeScript, and Styled-Components, without relying on shortcuts or component libraries.

Once the UI was polished and responsive, I added . The fake data was too static, the state too temporary. I wanted persistence, user accounts, and real logic behind the “Mark as Paid” button. That’s when the scope expanded: I needed a backend.

I didn’t set out to architect anything grand, but one decision led to another—Node.js for the backend, Express as the framework, GraphQL for the API, Prisma for database access, and PostgreSQL for persistence. Before long, I realized I wasn’t just building a frontend demo; I was building a small-scale SaaS system. And through a few rounds of redesign and refactoring, I discovered which choices held up and which ones only made sense in theory.

This article walks through how the project evolved—why I chose the technologies I did, what constraints shaped them, and what I’d change if I were building the same app for a real production environment today.

From Frontend Challenge to Full Stack

The design that started it all came from Frontend Mentor, a platform that gives you pixel-perfect mockups to reproduce in code. The challenge was labeled advanced, and for good reason: intricate grids, dynamic form states, modals, animations, and a dark-mode toggle—all requiring precise CSS work and thoughtful component structure.

At first, my goal was purely aesthetic and educational. I wanted to master Styled-Components for scoped styles and practice TypeScript for prop safety and reusable patterns. The early app was entirely client-side, with static JSON pretending to be invoices. It looked the part but lacked depth.

After a few weeks of polish, it became obvious that the app needed real data flow—not just fake invoices rendered from a file. I wanted to create, update, and delete invoices, track their status transitions, and persist those changes across sessions. That meant introducing an actual backend and database.

At that point, I faced a choice: either bolt on a simple REST API and call it done, or treat the project as an opportunity to explore a full modern stack. I chose the latter, knowing it would slow me down but teach me far more in the process.

The Stack I Chose — and Why

The architecture came together gradually but with a kind of pedagogical purpose. I was enrolled in the University of Helsinki’s Full Stack Open course, which emphasizes full-stack JavaScript, GraphQL, and relational databases. If I wanted the project to count toward course credit, I needed to meet their stack requirements. So, part of my decision-making was driven by academic alignment rather than pure pragmatism.

I built the backend in Node.js with Express as the underlying HTTP layer. On top of that, I introduced Apollo Server to provide a GraphQL API, which allowed the frontend to query and mutate data in a flexible, strongly-typed way. It also gave me end-to-end type safety—from database schema to GraphQL types to frontend TypeScript interfaces—using Prisma’s code generation.

For the database, I chose PostgreSQL, not because the app needed relational joins, but because it’s dependable, well-documented, and pairs beautifully with Prisma. I wrapped everything in Docker Compose for local development and used GitHub Actions for CI/CD. Deployment happened on Fly.io, which proved surprisingly smooth for both the Node server and the Postgres database.

In hindsight, that stack was both ideal for learning and mildly overbuilt for the problem domain. The app didn’t require fine-grained query control or deeply relational data. Most requests simply needed “give me all the invoices and their client info.” At the time, though, using GraphQL and Postgres forced me to think carefully about schema design, type generation, and API evolution—lessons that have paid off in every project since.

Iterating Toward the Final Architecture

Once the basic stack was chosen, I went through several architectural experiments before finding something stable. Each version taught me something about how systems evolve when you’re learning by doing.

The first backend iteration was minimal: a few Express routes serving JSON responses. It worked, but every time the frontend needed a new data shape, I had to create yet another endpoint or transform the response. It was quick to build but slow to extend—classic endpoint sprawl.

That frustration is what drew me to GraphQL. It promised exactly what I was struggling with: flexible querying and a single entry point for all data needs. I could ask for only what the UI required—no overfetching or underfetching—and maintain a clean type contract between client and server. Once I added Prisma, things really started to click. The schema became the backbone of the project: database models, TypeScript types, and GraphQL definitions all derived from the same source of truth.

After that, I focused on developer experience. Docker Compose made it easy to spin up Postgres locally without external dependencies. A seed script filled the database with realistic invoices for demo purposes. On the frontend, I could query the server using Apollo Client, run type generation scripts, and trust that everything lined up.

By the time I reached the final version, the project felt cohesive—clean architecture, type safety, and a unified schema language tying everything together. But it also carried a kind of complexity that only became apparent when I stepped back: every small change meant updating types, resolvers, and schema files in three different layers. I had built something powerful, but not necessarily simple.

What I’d Do Differently

GraphQL: Beautiful Overkill

I have mixed feelings about GraphQL. It delivered exactly what it promised—elegant type safety, predictable data fetching, and flexible queries—but for this particular app, it was like using a Swiss Army knife to slice a sandwich.

In practice, the invoice data model was simple. Each invoice had a handful of related objects (client info, address, line items), and the UI almost always needed the entire object. There were no nested filtering needs, no conditional queries, no partial fetches. Most API calls boiled down to “get all invoices with all their data.”

GraphQL introduced a lot of machinery to solve problems I didn’t have. I spent more time maintaining resolvers and type mappings than writing application logic. For a production system with complex relationships or multiple clients (e.g., web, mobile, analytics dashboard), GraphQL would have been the right choice. But for a single-client app with straightforward data access, a REST API would have been faster to build, easier to debug, and far simpler to extend.

Still, it wasn’t wasted effort. Working with GraphQL taught me how schema design influences architecture, how to think in terms of contracts rather than endpoints, and how to keep the frontend and backend truly synchronized. In hindsight, it was an educational luxury—one that paid off intellectually even if it didn’t optimize developer time.

PostgreSQL vs. a Document Store

Choosing PostgreSQL was partly pragmatic and partly academic. It’s stable, well-supported, and pairs beautifully with Prisma. But the app’s data access patterns were a better fit for a document database like MongoDB.

Each invoice in the system is essentially a self-contained document: a title, a client, a list of line items, totals, and status flags. When the app requests an invoice, it needs the entire object—not a few columns or a filtered subset. There’s very little relational logic, and almost no aggregation. In a document store, each invoice could have lived as a single JSON object, retrievable in one query.

Postgres, by contrast, forced me to maintain multiple tables and relationships, even though joins added no real value. It made sense from a data-integrity perspective, but it introduced extra layers of mapping in both the ORM and GraphQL resolvers.

In a real production environment, I’d almost certainly choose a REST API + MongoDB stack for this kind of project. It would align better with the natural shape of the data, simplify CRUD operations, and reduce both code volume and mental overhead. But again, the “wrong” choice turned out to be the right teacher—relational schemas taught me to think about data normalization, constraints, and migrations in a way that document stores often obscure.

Lessons on Architecture and Pedagogy

The biggest thing I learned from this project wasn’t about React hooks or database schemas—it was about how constraints shape creativity.

Some of those constraints were self-imposed, like choosing a difficult Figma design because I wanted to challenge my CSS and layout skills. Others were external: aligning my stack with the University of Helsinki’s Full Stack Open requirements meant committing to GraphQL and a relational database, even if they weren’t the most natural fit. That decision forced me to work within a particular ecosystem, and in doing so, I learned far more about backend architecture than I otherwise would have.

What surprised me most was how productive overengineering can be in a learning environment. GraphQL and PostgreSQL made the project heavier than necessary, but they also made me think about separation of concerns, data modeling, and long-term maintainability. By treating a portfolio project as if it were a production system, I accidentally gave myself a full-stack apprenticeship.

This experience also reminded me that architecture isn’t just about technology—it’s about clarity of purpose. A stack is only “good” relative to what you’re optimizing for. In this case, I was optimizing for education, not performance or simplicity. Had I been building a product for real users, my decisions would have looked very different. But as a learning journey, the trade-offs were worthwhile.

The other key lesson was about planning for change. I didn’t know it at the time, but the way I modularized the backend, typed the frontend, and Dockerized the environment made later refactoring remarkably easy. When I inevitably rewrote parts of the API, the surrounding systems held steady. That’s one of those quiet lessons that only appear in hindsight: the architecture may evolve, but sound abstractions buy you room to grow.

Looking Ahead

If I were starting this project today, I’d probably design it differently: a RESTful API, a document-based database, and fewer moving parts overall. But I don’t regret the detours. Every “unnecessary” decision turned into a kind of simulation—a way to practice solving problems I hadn’t yet faced in real work.

Looking ahead, there are a few directions I’d like to explore:

  • Simplifying the backend into a REST API with clear routes and request validation.
  • Experimenting with MongoDB or another document store to see how it changes both data modeling and frontend queries.
  • Adding automated tests (integration and end-to-end) to push the project closer to production quality.
  • Writing this tutorial series—starting with how I approached UI composition, then moving through database design, schema management, and deployment pipelines.

Each part of the app tells a different story about design trade-offs. The frontend is about precision and state management; the backend is about structure and data flow; the DevOps setup is about consistency and reproducibility. Together, they form a complete case study in how a learning project matures into a professional one.

If there’s a single takeaway from this build, it’s this: overbuilding a project is one of the best ways to learn how to simplify. When you’ve wrestled with complexity, you start to recognize elegance not as the absence of detail, but as the mastery of it.

Share Twitter LinkedIn
Also on
Thanks for reading.
← All posts