A lot of the time, when I am working on applications I find that most of my progress is being made horizontally and not vertically. By that, I mean I largely find myself working through refactors or refining systems instead of releasing new features, expanding upon the apps core, or even completing net new work. While this is not the worst thing, I find it often leads to stagnation and burnout on any given project which atleast gets us close to the worst thing. Of course, this type of horizontal progress is integral to software development, there are always new or better ways to accomplish a task. All of that preamble, brings me to the point of this post... What if, we made horizontal feel like vertical progress? Could that jumpstart a stagnant application? Could a bitten chunk be large enough to where it must be chewed? Boy, lets find out.
I have been planning this set of updates for a while, I would say I am about 3 months into this. That seems worse than it is, I work fulltime, am a dad and husband so time is typically not on my side. In fact, I wrote a lot of this post while waiting in the pick-up line at my son's school. My goal, is to see if I can revive a dormant application by leaning further into horizontal progress so much so that it starts into a vertical ascent. In order to do that, I need to make it feel grandiose in scope so I will be dubbing this release as a major update; v2.
This release focuses on 3 main pillars; data, architecture, and reliability. There’s also a UI refresh to support these broader changes and improve the overall experience.
Within each pillar there are 1 or 2 major improvements that are being implemented. These are largely evolutions of the existing foundations, with some areas, like data, undergoing more substantial changes. The overall goal is to have a more thoughtful, resilient, and holistic system.
Data: Database Redesign
Currently, the app uses two schemas, one for competitors and another for competitions. A two schema approach isn't inherently bad and at the time I felt that only having two would be easier to manage. However, the way in which the documents were structured and the amount of data they needed to keep track of, led to a couple of pitfalls.
- The competitions schema contains a nested snapshot of its two competitors under competitorOne and competitorTwo. This can lead to stale data. For example, if we update a competitor's image, those competition snapshots don't grab that update unless we go through each competition and update it.
- We have instances of redundant data. For example, the competitor specific objects in competitions each have a votes key and the competition schema itself has a totalVotes key.
Redundant or inferable data means more values must stay in sync. For example, when updating a competition we would need to update both the competition document and the competitor document. The same applies for when we complete a competition, we need to update the competition.competitorOne/Two votes and the competition votes key, the competitor votes, the competitor wins/losses, and the competition winner/loser.
A simple update often requires touching multiple documents, which increases complexity and the chance for inconsistencies.
Redesigning the database
I won’t go in-depth on the migration itself here, but it’s worth noting that the application moved from MongoDB to SQL, so you’ll see me refer to tables instead of documents below.
The goal is a more thoughtful design of the database and to follow better design principles, namely not repeating data, having tables easily related to one another, and simplifying queries.
Our new database is composed of 4 tables; competitors, competitions, competition_entries, and votes. Even though the table count doubled, the responsibilities are clearer, the relationships are explicit, and queries are much simpler. Each table owns a single concept instead of mixing concerns across documents.
New schemas visualization
There are a few easy wins to note:
- Competitions now have a closed_at column so we know when a competition was completed.
- Competitions no longer contain snapshots of competitors, removing stale data concerns.
- Competitions now have a winner_id column, making it far easier to query for outcomes.
The two new tables, competition_entries and votes bring the most impactful changes.
The competition_entries table is intended to remove the need of having specific columns for each competitor on the competitions table. This table also brings the added benefit of flexibility in the future should we want to experiment with the number of competitors in a competition.
The votes table allows us to remove all vote columns from competitions and competitors. As needed, we can query those from this table using the respective id. This also gives us the opportunity to refine how we track when a user has voted for a competition with the IP address. This isn’t a perfect system, IP addresses can change, but it’s more than sufficient for a casual, unauthenticated voting app.
This design isn't perfect and some of you may be able to point out flaws. However, looking at where it started, relations are now clearer amongst tables, data ownership is better defined, there is less data to keep in sync, and more directed queries can be made easily.
Architecture: Moving Away From Next.js
Next.js is great when you use its strengths like routing, SSR, and SEO. Since v1 evolved into a pure SPA, those features weren’t needed, and switching to React made the architecture much simpler.
The migration itself was pretty simple. As you likely know, Next.js is built on top of React, so the components and core logic could largely stay the same. Most of the query logic had to change, but migrating to TanStack Query was already part of the v2 plan, so that work was expected.
TanStack Query was a great addition to help enforce querying convention, something we lose when moving from Next.js to React. It really simplifies re-queries, rendering, and updates. If you haven't tried it, I highly recommend it.
Reliability: TypeScript & Testing
TypeScript
When I first built this application, most of the code was written in plain JavaScript. With the v2 release, the entire codebase is being migrated to TypeScript. The transition has been straightforward, and the additional type definitions are a small price to pay for the confidence they provide. Updates feel safer, and the overall codebase is far more resilient.
As I began converting pieces of the system, the work naturally shifted from “adding types” to “re-thinking the design.” Starting with the types encouraged a more holistic view of how data moves through the application. The database structure informed the types, which shaped the functions that interact with them, which in turn influenced how components consume and display that data.
Working in TypeScript makes these relationships clearer. Pitfalls show up earlier, mismatches are caught immediately, and decisions about the data model, queries, and UI become more intentional. For me, this actually speeds up development because the system’s shape is explicit from the start.
Unit Testing
The other major code-focused improvement in the v2 release is the introduction of unit testing. Paired with TypeScript, the goal is to build a more resilient and predictable codebase. With solid test coverage in place, rolling out new features becomes far less risky.
Like TypeScript, adding tests has naturally encouraged better design. When writing tests, function intent needs to be clear: each function should do one thing, accept well-defined inputs, and produce predictable outputs. If a function is difficult to test, it’s usually a sign that its responsibilities aren’t well separated.
I started with utility functions since they are small, pure, and easy to reason about. For example:
function msUntilMidnight This function has a clear intent: return the number of milliseconds until the next UTC midnight. Because the purpose is so explicit, the test cases practically write themselves. It should return:
- a value between 0 and 24 hours,
- exactly 24 hours in milliseconds if the current time is exactly midnight,
- the precise number of milliseconds when given a fixed timestamp.
Writing tests for utilities like this not only validates correctness but also reinforces good design patterns throughout the rest of the codebase.
Together, TypeScript and testing will provide a more solid foundation for reliability, making the system far easier to add features onto.
Looking Forward
Not everything that I wanted to include could make it into this release, otherwise it would never actually be released.
A few items on the roadmap:
- Votes Recasting: With the new `votes` table, we now have the ability to allow a user to change or rescind their vote. Something we were not able to do before.
- Notification Systems: I have been exploring options to allow users to subscribe for competition related notifications such as competition results, when a new competition begins, etc.
- PWA or Dedicated Mobile App: I don’t have much thought on this yet, aside from wanting it down the line.
Overall, v2 represents a meaningful step forward for the app, laying the groundwork for smoother development, clearer data flow, and more flexibility in the future.