DEV Community

Cover image for The ENT Stack: 13 Key Decisions for Building a Full-Stack Web App in 2025
Ironex
Ironex

Posted on

The ENT Stack: 13 Key Decisions for Building a Full-Stack Web App in 2025

Building a scalable full-stack web app isn't just about choosing the hottest framework or writing clean code - it's about making deliberate, strategic decisions from day one.

Every decision you make - whether it's selecting the framework, defining authentication methods, or adding translations can have a significant ripple effect on your application's cost, ability to grow, adapt, and perform under pressure.

A few months ago, I found myself facing those decisions.

Selecting JavaScript Libraries

My goal was to develop a full-stack web app as a single project, share code between the backend and frontend while still maintaining the flexibility to deploy them separately. I also wanted it to be able to run on a dirt-cheap VPS in the beginning to keep the costs low, but still be scalable enough to possibly handle high traffic demands in the future.

To achieve that goal and avoid interrupting development later with major redesigns, I had to make key decisions to address critical areas upfront. This included selecting the backend and frontend frameworks, designing the project structure, defining the API layer, configuring the environment, setting up the database, implementing authentication and authorization, handling validation and errors, managing state, enabling i18n, setting up logging, mailing, testing, and planning for DevOps.

While analyzing these key areas, I realized I didn't want to start from scratch or repeat the same process for my future projects - this realization ultimately led me to create my own ♻️ reusable solution:

ENT Stack

The ENT Stack is a full-stack monorepo built with Express, Next.js, and TRPC, designed to address all 13 crucial areas right out of the box. To make it even more convenient, I created an NPM package that allows developers to scaffold a new project with a single command.

And YES, it's basically another JavaScript library - deal with it πŸ€“. It's 2025, and tossing last year's libraries into the dumpster fire has practically become a JavaScript tradition πŸ₯². Starting fresh isn't just a choice; it's a way of life.

Below, I'll give a brief overview of how I made the key decisions while developing the ENT Stack and the logic behind them. Each of these topics could easily be expanded into its own article, but I'll keep it short and to the point here.

1/ Framework Selection

Backend: I picked Express because it's minimal, widely adopted, stable and let's me do whatever I want - and that's what I want from a backend framework. Alternatives like NestJS offer a more opinionated structure, but that also adds overhead and lock-in to Nest's approach. Another alternative - Fastify is known for being lightweight and highly performant, but I wanted an uncomplicated, battle-tested foundation, and Express still delivers that best.
Frontend: Next.js was chosen because I mostly like React and it is currently the most popular framework. It works well with a ton of libraries out of the box, and its popularity and backing by Vercel mean it is here to stay. This also makes it easy to find developers who know their stuff.

2/ Project Structure

Since sharing code between backend and frontend was essential, I decided on a monorepo setup. For managing the monorepo, I chose PNPM Workspace over Yarn and NPM because it is fast(er), simple to use, and just works.

apps
- backend
  - ...
- frontend
  - ...
packages
- shared
  - config
  - enums
  - i18n
  - schemas
  - scripts
  - services
  - types
Enter fullscreen mode Exit fullscreen mode

3/ API Layer

I chose tRPC over REST and GraphQL to prioritize developer experience and simplicity. Unlike mentioned alternatives, which often require additional boilerplate, schema generation, and type synchronization tools, tRPC provides end-to-end type safety and type inference out of the box. Getting it up and running with Next.js and integrating it with TanStack Query took some effort, but it was absolutely worth it. Now, I can just write TypeScript functions to handle all my backend and frontend communication. My IDE provides built-in type hints and autocompletion, and I don't have to maintain separate types or run any code generators. It's a huge timesaver and keeps everything type-safe from end to end.

4/ Environment and Configuration

I decided to use T3 Env for static validation of environment variables. Both the backend and frontend have .env files for local development, with types defined in env.ts to make sure that variables are properly validated. For Next.js, public environment variables are injected at build time, whereas sensitive secrets like API keys are handled at runtime. In Express, all environment variables are resolved at runtime, as the application doesn't embed them during the build process.

To manage static settings, I use custom config classes: backend-config.ts, frontend-config.ts, and shared-config.ts. These handle constants like API URLs and defaults, keeping environment variables limited to deployment-specific values.

The stack uses ESM for both the backend and frontend. This approach allows code to be shared and imported directly from the shared package in both the backend and frontend. The setup is managed through a main tsconfig.json in the project root, extended by three other tsconfig.json files located in the backend, frontend, and shared directories.

Code uniformity is maintained with shared eslint-config.js and .prettierrc files, with Tailwind formatting support added for the frontend.

5/ Database

I chose an ACID-compliant database for its reliability and strong transaction guarantees. While NoSQL gained popularity because it lets you skip the data modeling step, allowing you to toss data in and figure it out later, that lack of purposeful design catches up with you over time. ACID databases are more general-purpose due to their predictability and structured approach to data.

Engine: I selected MySQL because I have the most experience with it. While arguments can be made for Postgres offering better performance in certain scenarios, I believe those differences are negligible compared to the impact of proper optimization, like making sure indexes are in place.

ORM: for working with the database, I selected Drizzle ORM because it's lightweight, type-safe, and avoids unnecessary complexity. Drizzle translates TypeScript directly into SQL and sends it to the database efficiently, giving me exactly what I need: simple, type-safe SQL with IntelliSense.

6/ Authentication and Authorization

Authentication: I opted for a custom, minimal passwordless authentication solution. This approach provides full control, flexibility and the ability to modify, extend or replace it as needed. Authentication requirements are often project-specific, and this flexibility lets you adapt the solution based on your project's specific needs.

Authorization: The stack contains basic frontend route-level protection. In the routes.ts definition file, each route includes a protected: true/false flag. This flag is then evaluated in Next.js middleware.

7/ Validation and Error Handling

I chose Zod for validation because it works well with TypeScript and tRPC, avoids boilerplate, and validates inputs before they hit the business logic.

For error handling, I use tRPC's errorFormatter to standardize errors into three categories: server-side, client-side and userland. This setup makes sure each error is logged with the appropriate severity and returned to the frontend in a predictable format. On the client side, I leverage TanStack Query's QueryCache and MutationCache to intercept these errors. Error messages are displayed to users using Sonner toasts.

I also use a custom ErrorService with catchAsyncError and handleAsyncErrors methods because JavaScript lacks error propagation hints in IDEs. This makes it easy to overlook potentially thrown errors when using functions across files. By avoiding throw and instead returning [error, data] tuples, I make error states explicit, making sure that errors are always handled intentionally and predictably.

8/ State Management

I chose Zustand for simple, synchronous global state because it's minimal and straightforward to work with - no need for complex action types or reducers like you would have in Redux. For async state management, TanStack Query was the clear choice over something like SWR or custom fetch logic. It offers robust features (caching, invalidation, SSR support) without the complexity of Redux Toolkit or other large-scale solutions.

9/ Internationalization (i18n) and Localization

Message Translation: for message translation, I implemented an unconventional custom solution using standalone TypeScript functions located in a shared package. Each function represents a single message, formatted with ICU syntax using the intl-messageformat library. This approach avoids relying on YAML imports or external translation files. It eliminates the need to think about where a translation is used in Next.js, whether in RSC or client component. Since the messages are pure TypeScript, tree-shaking automatically removes unused translations during the build process. The IDE provides full type support, autocompletion, and usage tracking directly in the codebase. However, it has a downside: managing import/export workflows for localization can be complicated, as it requires generating code (translation functions) from YAML files. To address this, I added both export and import scripts. Export script generates YAML files that can be used for translating messages and import script reads those YAML files and generates TS functions from them.

Frontend Route Translation: routes are fully translatable and defined in a standalone routes.ts file. Translated routes are dynamically evaluated at runtime in Next.js middleware.ts.

10/ Logging

I wanted one logging library for both backend and frontend - eventually choosing Pino because it works in both environments, provides structured YAML output and it is also very fast.

Backend: pino-http handles HTTP request logging, with a custom genReqId function for unique request IDs. Logs are serialized using LoggerService for detailed request and response data. Unhandled errors and system events are captured with relevant metadata, including request headers and error details.

Frontend: a simple Pino setup logs client-side events with configurable log levels, offering flexibility based on the environment.

11/ Mailing

Mailer: when it comes to sending emails, there are a lot of options, each offering a ton of features and varying pricing. I chose Resend because it stands out for its simplicity. It has a free tier and is very easy to setup.

Email Templates: I selected Handlebars for coding templates and I also added /email/:wildcard backend endpoint for previewing templates in the browser.

Email Testing: for testing emails, I used MailSlurp. It's easy to integrate, comes with a free tier, and allows end-to-end email testing. This makes it perfect for verifying emails during development.

12/ Testing

I chose Playwright because it is very simple to use and setup and allows testing both the backend and frontend with a single tool, simplifying the stack and avoiding the need for multiple testing frameworks.

Frontend: Playwright is used for end-to-end (E2E) tests to validate user flows and interactions in a browser environment.

Backend: Playwright integrates with Supertest for API testing. It handles sending HTTP requests, validating response data and status codes, and verifying database operations.

13/ DevOps - Infrastructure and CI/CD

If you've read the sections above, then you probably noticed that I prefer simple and straightforward solutions. However, in this case, I decided to try something a bit different than just deploying everything to Vercel, Render, or a custom VPS. And since I wanted to try out AWS ECS for a while I decided to host everything on AWS ECS and provision it with Terraform (IaC).

And since Infrastructure and CI/CD tend to be project-specific, I decided not to include them in the stack and instead set them up as a standalone repository to serve mainly as an example of how to build, test, deploy and host the app and media.

⭐ ENT Stack DevOPS

Infrastructure: is provided by AWS ECS cluster running on a single EC2 instance. It contains three ECS placeholder tasks that are used to host backend, frontend an database. There is no load balancer or http server and TLS termination is handled by CloudFront. This setup is supposed to serve as a UAT environment and is not designed for production use.

CI/CD: GitHub Actions are used to automate the CI/CD pipeline for the UAT environment. The pipeline builds Docker images for the backend, frontend, and database. The database is recreated from scratch during each deployment. Backend and frontend tests are executed in isolated Docker containers, and once they pass, the updated images are deployed to the AWS ECS cluster.

Media Hosting: is provided by S3, with CloudFront providing caching and serving capabilities. It supports both public and protected media (via signed URLs).

Final Thoughts

I hope this breakdown was helpful, and even if you don't end up using the ENT Stack, you can explore the GitHub repository to see how it tackles some of the more challenging parts of setup that you might face in your own projects.

If you decide to give the ENT Stack a try and run into any problems, feel free to report them in the Issues section. If you have questions, you can add them to the Discussions - I'd be happy to answer and help you out.

Top comments (1)

Collapse
 
daniyal_khan_25a2cb898f9a profile image
DANIYAL KHAN

The ENT Stack is a well-thought-out approach to building a full-stack web app while addressing many critical decisions upfront. Your breakdown of framework selection, project structure, API layer, and DevOps considerations is incredibly valuable for developers looking to build scalable applications. πŸš€

Why This Approach Stands Out

  1. Monorepo Efficiency – The decision to use PNPM Workspaces for managing a shared codebase across backend and frontend is a smart choice. It simplifies dependency management and ensures consistency between services.

  2. tRPC Over REST/GraphQL – Choosing tRPC is a forward-thinking move. Its end-to-end type safety removes the need for redundant API contracts, making development faster and more robust. Type inference directly in the IDE is a game-changer! πŸ”₯

  3. Zod for Validation & Zustand for State Management– Keeping things simple with Zod and Zustand ensures that validation and state management remain lightweight yet powerful. Unlike Redux, Zustand avoids unnecessary boilerplate while still offering great flexibility.

Future Considerations
While the ENT Stack provides a solid foundation, there are a few additional enhancements that might further improve the development experience:

  • Edge Computing with Cloudflare Worker – Offloading some processing to the edge could significantly improve global performance.
  • Hybrid Database Approach– Using MySQL is great for structured data, but a hybrid approach with Redis or FaunaDB could help in caching and handling real-time interactions efficiently.
  • serverless Deployment – While AWS ECS is a strong choice, exploring Vercel Functions or AWS Lambda might optimize costs for certain workloads.

For those who are looking for AI-powered tools to enhance full-stack development, Deepseek-r1 offers intelligent assistance for debugging, optimizing, and generating code. If you're interested in integrating AI-powered coding assistants into your development workflow, check out this guide: .. πŸ€–βœ¨

Would love to hear more about real-world applications of ENT Stack! Have you deployed any production apps using this setup yet? πŸš€