This project, which began as the practical component of my bachelor's thesis, is my first proper attempt at making a full-stack application, drawing heavily on the principles of content management systems I grew familiar with during an internship. While the thesis itself delved into a specific analysis of headless CMS performance, here I'll focus on the broader architectural decisions, the data flowing through the system, and the features and key learnings that came out of building it. Source code is available on GitHub.
Based on the thesis goals, I had to design the application with a decoupled architecture. In practice that meant a monorepo with two independently deployed halves. A frontend/, running Next.js on Vercel, and backend/, running Strapi on Railway with a managed PostgreSQL instance. The two sides only ever talk to each other over Strapi's REST API, with no shared code.
The foundation of this project's technology stack was partially predetermined by the goals outlined in the thesis. For the application layer I used Next.js, specifically the App Router on the 15.0.3 release. I enjoy working with the App Router and Server Actions paradigm, and its support for ISR turned out to be a genuine win for CMS-driven pages. For the content management system, the idea to use Strapi came from an internship.
Later in development, to address latency issues I observed after deploying Strapi to Heroku, particularly on the book browse page, I retroactively integrated TanStack Query. It wasn't part of the original plan, but it ended up doing more for the app, background refetching, optimistic updates, request deduping, than I expected from something I'd first treated as just a cache. For styling, I used Tailwind CSS with shadcn/ui's Radix-based components, which made it fairly easy for me to build a decent-looking and consistent UI. Forms went through react-hook-form and Zod, and environment variables were validated with @t3-oss/env-nextjs so a missing API key fails at build time instead of quietly breaking something in production.
Strapi doesn't have an ORM schema file the way something like Prisma does. Each content type is just a schema.json under backend/src/api/*. The core entities were User, Book, and Comment, with users able to favorite books and leave comments on them.
A couple of the modeling choices only became clear once I was deep into the implementation. Tag and Link, for instance, aren't collection types at all, they're Strapi components, meaning they live as embedded JSON on the Book schema rather than as rows with their own relations. That made the tag list trivially editable from the admin panel, but it also means filtering by tag is a $containsi match against embedded data rather than a join against a real Tag table.
The Book-Comment relation turned out to be asymmetric in a way I hadn't planned for either. A Comment only stores a relation to its user, not to the book it belongs to, the link back to the book exists solely as the inverse comments field on Book. So creating a comment is actually a two-step dance: POST /api/comments to create it, then PUT /api/books/:id to append the new comment's ID onto the book's comments array. It works, but if I revisited this project it's the first schema smell I'd fix.
Here are some of the core features of the Virtual Library application:
User Accounts: The app uses a JWT issued by Strapi for authentication, stored directly in an httpOnly cookie, with Next.js middleware protecting both the dashboard and the entire book catalog. Coincidentally, around the time I was presenting this project, a critical vulnerability was discovered in Next.js middleware that temporarily rendered this whole approach obsolete.
User Dashboard: Once logged in, users can manage their profile, including changing their profile picture, where Strapi handles the image uploads and optimization. They can also manage their "favorites" list here, stored as a favoriteBooks array on the Strapi user, and see a stats page charting their book, author, and tag counts over time.
Book Catalog: Users can browse a paginated, sortable catalog of books, filterable by tag and title. Each book has its own page at /books/[slug] with more detail, description, and user comments.
Dynamic Content: The landing page, along with the global header, footer, and SEO metadata, is fully managed through Strapi as dynamic zones. This means an admin can update or completely change large parts of the site's content without needing to touch the code.
A notable challenge was authentication. Due to the complexity of validating Strapi's JWT tokens directly in Next.js middleware, I implemented a custom authentication bridge using dedicated API routes and then issuing a separate, session-based token that the middleware could securely manage.
The Strapi JWT itself gets set as the httpOnly cookie, and middleware.ts validates it directly. The tradeoff is that every protected navigation, which includes the entire /books catalog and not just the dashboard, triggers a live GET /api/users/me round trip from the middleware to revalidate the session.
The deployment journey was a tough lesson in infrastructure. I initially chose Heroku for the Strapi instance, but its lack of native monorepo support and noticeable latency issues prompted me to search for an alternative. After a brief and challenging stint with Strapi Cloud, which proved cost-prohibitive for a personal project, I discovered Railway. Its seamless Git integration, generous free tier, and overall developer experience felt like a generational leap, making the final deployment smooth and efficient.
I severely underestimated and misunderstood caching going in. Given that caching was a major focus of my thesis and underwent significant changes during development, my attempts to optimize performance through it became quite the learning experience.
My previous understanding of caching was rather linear, I thought of it as a single layer. In this project I realized it's a far messier topic than that, and ended up with three layers stacked on top of each other. Next.js's fetch revalidation handles the server side, with different tiers, five minutes, one hour, a day, three days, depending on how volatile the underlying data is.
React Query sits on top of that with its own five-minute staleTime for the client, and ordinary browser caching handles static assets underneath everything. Each layer is simple on its own, the hard part was navigating it all, and remembering which one needed an explicit revalidatePath call after a mutation, since a stale layer anywhere in that stack means someone's looking at data that's an hour old.
Initially, I managed all state with React's useState and useContext. As you can imagine, this became complex. Introducing React Query was a life saver, and I'd use it from the start on a similar project. Even in my limited experience it simplified server state management significantly.
I didn't prioritize testing in this project, which I regret. In future projects, I would like to implement unit tests for critical components and integration tests for API endpoints to ensure reliability. In retrospect I would have definitely set up some unit tests, even if it was just for the dopamine hit of watching them pass. My thesis focus on headless CMS performance also pulled me into over-engineering some performance aspects that weren't critical to the core application, and the results weren't exactly meaningful.
Perhaps the most significant lesson was to think ahead, and to diagram ideas and architecture before starting to code. I didn't do that here, for the most part, and it led to some messy schema and auth decisions that I only untangled after the fact, and some code I had to refactor later.
Overall, I have found building the Virtual Library to be a very enjoyable and formative experience. Thanks to it, I was able to combine my understanding of different parts of web development I have learned in the past two years, into a complete application. It also unveiled my fundamental lack of understanding of certain areas in web development and strengthened for myself the importance of planning and architecture in software development.