DEV Community

Cover image for The Ultimate 2024 Tech Stack for Building a Full-Stack MVP and Iterating Quickly
Martin
Martin

Posted on • Edited on

The Ultimate 2024 Tech Stack for Building a Full-Stack MVP and Iterating Quickly

When working on a startup, the speed of iteration is key. The requirements change quickly, features are added daily, and code gets modified rapidly. In those conditions, picking technologies that enable fast iteration, while ensuring your users get the best experience possible is crucial.


A template project with an initial structure using all of the technologies described here is available on GitHub: https://github.com/MartinXPN/nextjs-firebase-mui-starter


During the last two years (almost), we have experimented with many modern technologies while building Profound Academy. So, in this blog post, I'd like to present the whole tech stack that enables building quickly, while having a highly maintainable codebase, scalable infrastructure, and a great user experience. We'll cover everything from Authentication to UI, we'll talk about the backend, hosting, testing, and much more!

Frontend

As the core frontend technology, React stands out as the top pick for many full-stack projects. While there are other options like Vue or Svelte, React shines because of its vast range of libraries. This big ecosystem makes it easier to add new features and change things fast, which is why it's a favorite for many developers.

However, React, as a frontend library, often doesn't get along well with search engines like Google or Bing. These search engines use bots to look through the HTML of a page and understand its content. Yet, pages built with React usually start with an empty HTML body from the server. This means if you want your website to show up in search results on Google, Bing, or other search engines, you need to make sure the first content from the server includes HTML elements filled with the right information about the page.

That's where Next.js shines. It's a React framework that provides server-side rendering and static website generation. So, the pages powered with Next.js get rendered on the server first, after which the HTML is sent to the browser, making sure the initial content of the page has all the necessary information about the page. After the page is rendered, only the necessary JavaScript is sent to the client, making sure that the bundle sent to the user's browser is minimal. This improves both the users's experience and how well search engine bots understand the page.

Starting from Next.js 13, it has a new router, called app router. It drastically improves both the user and developer experience compared to the previous pages router. So, I highly recommend starting the project with the app router.

In our case, we saw a big page-load speedup after switching to the app router from the pages router. It was one of the biggest migrations in Profound Academy. In general, React seems to be moving in the server-side direction (becoming somewhat similar to PHP) and the Next.js app router provides great developer experience for developing "server-first" React components.

UI

When developing web apps, the traditional workflow in companies goes through several stages, which include planning, UI/UX design, implementation, and many iterations of back and forth between designers and developers. Startups require quick iteration, where keeping the development cycle short is crucial. This makes creating and iterating on a website a pretty challenging task, which makes it even harder when new requirements arrive and several things need to be changed.

In our experience, the most efficient way of going about UI/UX was by picking a solid UI framework (MUI in our case) and following a design template instead of asking a designer to create everything in Figma, after which the developers would re-create everything in the React app. We kept a single source of truth - the codebase. This literally saved us months!

MUI has a rich collection of highly customizable components that cover the most common use cases. The great thing is that everything is customizable but because of the great defaults, in most cases, we would end up using the components with all their default behaviors and appearances.

One bonus of using MUI is that it also provides a wide collection of icons that can be used as React components. This makes including icons in the app very straightforward.

There are several alternatives to MUI. shadcn/ui is a modern alternative that is very popular. Ant Design is also a great alternative. Charkra UI can also be used as a UI Framework.
Some people suggest just using styled components. Some use Tailwind CSS. Yet, for both styled components and Tailwind CSS, one still writes a lot of CSS. This might not provide the best developer experience compared to using a UI Framework, especially if we aim to avoid designing all the pages on the website.

Finding visually appealing templates might be challenging but there are several places to browse for high-quality templates. MUI store is one of those - https://mui.com/store. Dribble also has some nice templates - https://dribbble.com/tags/react_template. In general, a quick Google search can lead to pretty decent results.

Forms

Forms are a big part of full-stack projects. In our experience, Zod and React Hook Form work great together and provide all the functionality one might need to create forms. In this combo, Zod provides schema validation (types, min/max bounds, lengths, enums, etc.) while React Hook Form gives a flexible API to interact with the form. With these two libraries, one can nest components that interact with the same form, perform different complex validations, and update the data on the fly. They make working with forms seem too easy.

Backend, Authentication, and Database

Platforms like Firebase and Supabase provide backend as a service helping you scale to millions of users without maintaining your own infrastructure. We currently use Firebase for authentication, storage, database (Firestore), and serverless functions to handle custom backend logic.

Authentication

One great product that can be used for authentication is Clerk but we currently use the default Firebase authentication system as it works pretty well. Firebase even has a separate package called firebaseui-web that helps one set up Firebase authentication without writing any UI code.

For Authentication to work properly, we also deploy the Next.js application to auth.profound.academy so that Firebase is able to handle authentication without issues.

Database

When developing large projects in a traditional way (separate frontend + separate backend with a database), one usually ends up having many API calls, triggered from the frontend, that modify the database entries. For many projects, 80% of those calls are simple CRUD (Create, Read, Update, Delete) operations. The other 20% involve more complex operations (like updating several entries or reading and aggregating data from several sources). One thing that Firebase does that's really smart is optimize for the 80% making it very easy to manipulate data from the frontend without redundant API endpoints (the other 20% which require more complex logic can be handled with Firebase Functions).
This is achieved with 2 components:

  1. Firestore Rules: Everything related to access management and permissions is isolated into a single file: firestore.rules. This makes sure that API Endpoints don't need to check for user permissions in every function as Firebase does that automatically - it checks if the user is allowed to read the given piece of data or create/update/delete it. This isolation is invaluable when writing code as it helps drastically reduce redundant boilerplate code.
  2. Firestore Client SDK: Creating, reading, updating, or deleting data is handled through the Firebase SDK. It provides many useful features like listening for realtime updates, incrementing counters, and batching updates together. All of which gets checked against firestore.rules automatically!

One caveat of using Firestore is the mental model for storing the data. As Firestore is optimized for read operations (which are usually the majority), it encourages keeping the data denormalized. This can be counterintuitive, especially for people coming from an SQL background. Here is a great talk explaining the details of data modeling in Firestore: link.

Overall, Firestore provides great user experience (especially through real-time change listeners) and developer experience making it easy to move fast and adapt the whole application to changing needs.

Serverless Functions

We usually like to keep the client code very simple, while isolating the more involved parts into the backend code - inside serverless functions. Keeping the frontend code "dumb", while the backend "smart" has worked pretty well. Firebase provides a service called Functions which can contain any backend code and are triggered in several ways. They can be invoked with a regular HTTP request, get triggered as soon as an entry is created or updated in Firestore, or run periodically as a cron job with Pub/Sub. This way we can have all the functionality we need on the "server" implemented in separate serverless functions.
The best part is that the serverless functions scale up and down really well. So, big spikes in traffic are handled automatically by Google and you get charged for only the resources used - idle time is free.


In general, BaaS technologies like Firebase save a lot of time. In most cases, they just work! I very rarely find myself debugging an issue caused by some irregularity in a database, a failed migration, or something similar.

Tests

Testing is probably one of the most controversial topics, especially for startups that have constantly changing requirements. In the early days, we didn't have tests. Yet, as the product grew, it became almost impossible to make changes to the existing codebase, while being confident that everything would work fine after the changes were pushed to production. So, we started introducing tests for the "backend". More specifically, the service layer of Firebase Functions. The frontend is not tested as we keep it "dumb", while the backend code, which is "smart", is tested against edge cases. Most tests are integration tests. We very rarely do unit tests. As most services touch several other parts, it's more reasonable to perform integration tests to make sure all the parts work fine together - without mocking too much.

To test the backend, we use jest. One library that helps a lot in automatically generating fake data inside tests is faker.js.

To execute the tests, a separate Firebase project is created - to make sure the production project is intact. Having a separate "test" Firebase project helps in iterating quickly and not worrying about affecting the users. After all the tests are passed, the new version of Firebase Functions can be pushed to production.

Refactoring

As the project grows, features are added or modified, requirements change, and things get "old", refactoring becomes inevitable. The two largest contributors to a confident code modification process are TypesSript and robust Integration tests. Typescript takes care of the type safety after changes, while the integration tests make sure that the logic hasn't been broken during those changes.

Project refactoring actually happens more often than expected. For instance, we haven't had any 6-month period where we haven't done any major refactoring in the project. As the platform constantly changes, the addition of some technologies requires some major code modifications. Two of our largest refactoring milestones were switching from pure Rect to Next.js and switching from the Next.js pages router to the app router.

Hosting

As the whole backend code sits on Firebase (Google Cloud), the frontend code is deployed to Vercel. It provides the most convenient infrastructure to ship Next.js projects. Vercel makes it very straightforward to deploy Next.js applications. It has a GitHub integration, which works really well with branches (without any manual setup):

  • As soon as something gets pushed to the main branch, Vercel automatically deploys it to production.
  • As soon as something gets pushed to some other branch, Vercel automatically creates a "Preview" deployment and generates a separate link for that deployment. After which the link contains the deployed version of that branch. So, iterating is very convenient and fast.

Analytics and Monitoring

Knowing your users and how they use your product is invaluable. It can save weeks or sometimes even months of development time helping prioritize the right things. The right monitoring can help improve the user experience by fixing common bugs and issues.

  • For general analytics, we use Firebase Analytics, which is a wrapper around Google Analytics.
  • For error reporting and monitoring, we use Sentry which integrates well with Next.js.
  • For usage insights, we use Microsoft Clarity, which provides session replays, heatmaps, and aggregate user activity statistics.

All of these help a lot in improving the product and really knowing your users. Although there is nothing like directly talking to them.

Emails

Emails are one of the most reliable ways of staying in touch with users, sending updates, and engaging them.
We've recently switched from sending emails with Sendgrid to sending them with react-email and Resend. react-email makes it very easy to have highly customizable emails - it basically turns writing an email into writing a react component. Resend, on the other hand, integrates well with react-email and has great pricing. The good part about having emails as react components, is they get checked in with the rest of the code to version control and one is able to keep track of all the changes in one place.

Bringing everything together

So, to visually see all of these components together, here is a sample project structure one might have if developing an app with this stack:

project
|
|-> firebase (includes Firebase-specific files like Storage and Firestore rules and Firestore indexes)
|-> functions (firebase serverless functions)
    |-> emails (react-email templates)
    |-> generators (generators for models to speed up testing)
    |-> models (all the models for both the front-end and the backend)
    |-> services (the main functionality of each endpoint)
    |-> test (includes tests for the serverless functions)
    |-> index.ts (all the Firebase functions endpoints)
|
|-> public (static assets, etc)
|-> src (the main Rect app source code)
    |-> app (Next.js app router)
    |-> server (Next.js layer that works on the server side - service layer which only works on the server-side)
    |-> components (React components)
    |-> ...
    |-> ...
    |-> services (includes logic for reading and writing to firestore, connecting to firebase functions, uploading files, etc)
Enter fullscreen mode Exit fullscreen mode

Yet, just the illustration is not enough. To help you experiment with this stack, I've put together a GitHub Template using everything we discussed above: https://github.com/MartinXPN/nextjs-firebase-mui-starter. It uses the Next.js app router, MUI with CSS variables (for seamless light/dark mode experience), next-firebase-auth-edge as a helper library for authentication, and one of my favorites - use-async-effect for having async calls inside a useEffect. It has all the initial setup necessary for server-side rendering with MUI, full Sentry setup, and Firebase Functions along with test cases.

People to Follow

In this rapidly changing field of full-stack development, there are several bright minds I'd recommend following, who share great insights and news:


I would love to know your thoughts, ideas, and suggestions on great technologies to use. What have been your experiences with different tools? What would you suggest we use to improve our product? Let me know in the comments.

Top comments (1)

Collapse
 
jaywilkinson profile image
James Wilkinson

fantastic post.