A Pragmatic React Stack for 2025

When building a React.js-powered web app from the ground up in 2025, the first challenge we face is one of choice. There are a dozen choices we have to make to put some meat on the bones of our React app. To make it an environment where we can quickly and confidently iterate on new features. In this post, I'll summarize the many choices I've made, developed from 10 years of building React apps across a variety of organizations.

Next.js and T3-App

There are lots of meta-frameworks out there, most of which are going to be solid choices. I've been working on Next.js apps for the past 5 years, several of which were bootstrapped with create-t3-app. Next.js is solid, reliable, and optimized for production. It is built by Vercel who is pushing both Next.js and React forward, hand in hand.

T3-App offers to bootstrap your Next.js app with a bunch of full-stack pieces that I recommend throughout the rest of this post.

TypeScript

TypeScript is a fantastic augmentation of JavaScript that allows teams to write JavaScript applications that they can rely on. It helps catch and eliminate all kinds of would-be runtime errors early, makes refactoring a breeze, and, what I find most useful, is it super-charges your tooling with static-typing-powered hints and completions. On a similar note, AI code-completion tools like Copilot and Cursor are even more effective when they have all that rich type information to work with.

Type-Safe Data Layer

By combining tRPC, Zod, and react-query, we can add a strong static typing boundary between the outside world and the app.

With Zod and react-query, we can ensure that arbitrary JSON payloads coming from our own external server or 3rd-party services conforms to what we expect. The type information that we enforce at that API boundary benefits the rest of our app as that data flows through it.

Similarly, we can use tRPC and Zod to build client-server APIs within our Next.js that are end-to-end type-safe. It's an amazing feeling to make a tRPC call in a React component and see the type info from the API layer flow into the frontend.

Depending on how we are architecting our app, we may have the server-layer of our Next.js app talking directly to a database. With tools like Prisma and Drizzle, we can derive static types from our database schema and write ORM-powered queries that return static typed results.

Styling and Core Components

TailwindCSS has become the defacto choice for being able to rapidly style React apps. The utility-first nature of Tailwind allows us to make targetted tweaks to styling without wondering where else those changes will cascade. Tailwind offers enough flexibility with themeing and overrides to fit the needs of most apps. Add to that the robust set of high-level Tailwind UI Components from the same team and we're off to a quick start with a stylish and responsive visual foundation.

Instead of going all in on a UI Kit, I like the trend toward component libraries like shadcn/ui and tremor which encourage us to pull in (essentially copy/paste) accessible pre-baked components and take ownership of them. Maintaining and styling our own set of core components strikes a nice balance of reusability in the styles without the fear of the cascade.

Authentication and User Management

(Next)Auth.js integrates seamlessly with Next.js and provides a ton of options for bring-your-own-auth-backend and OAuth providers. The React context provider then makes it trivial to pass details about the authenticated user to React components that need it.

As for user authorization, I like casl.js for Attribute-Based Access Control. It is much more flexible than role-based access control which I've found often doesn't scale as an app's functionality grows and matures. It has first-class TypeScript support, and since it is isomorphic, it is easy to use as the authorization layer across frontend and backend. I imagine the rules could be exported parity with a separate backend system as well.

Managing Complex State and Workflows

Real-world apps quickly grow in complexity, especially when there are highly interactive user flows and as the app integrates with more and more 3rd-party services, plus our own server(s) and database. To wrangle this complexity—to not hide the complexity or spread it all over the place, but to put it where we know where it is—I like to pull in tools like XState (state machine library) and inngest (workflow management library).

XState is a library for exhaustively encoding app states and valid transitions between those states. Like many of the other tools mentioned already, this has TypeScript support baked in making it a dream to work with as we handle the different UI scenarios. A tool like this gives a lot of confidence that there isn't some unexpected blank page, loading state, or, worse, a scenario where invalid data can be presented to or submitted by the user.

There is no getting around that modern web apps need to talk to many if not dozens of 3rd-party services, sending and receiving data. What happens when one of those requests fails or we don't get back the data we expected or those servers are overloaded and they took a little longer to respond than usual? Moving these kinds of interactions and workflows into a tool like Inngest helps us handle the common failure scenarios, gives us observability into those workflows, and encourages patterns that make the code easier to reason about and test.

Testing

What I like about a lot of these tools, from XState and Inngest to tRPC+Zod, is that they lead to moving most of your logic out of the React lifecycle and into separate "pure" functions where unit testing is much easier. This has the added benefit of simplifying the React components and leaning on good abstractions rather than half-baked ones. I use Jest, though I've been hearing great things about Vitest.

In addition to this kind of unit testing, I like to use react-testing-library and playwright to do end-to-end testing of core user flows. The other kind of testing that is popular in these kinds of apps is component testing, but I've found those require a lot more effort to write and maintain than they provide long-term value.

Deployment and Operations

I like to deploy to an environment like Vercel that deeply integrates with Next.js. They offer some additional tooling for environment variable management, k/v store (Upstash) for rate limiting and other per-user metadata, and though I haven't tried it yet, a feature flags offering which can help separate deployments from releases.

It is essential to have insight into how complex web apps that are prone to runtime failures are actually running in production. In my experience, Sentry is best-in-class for monitoring JavaScript applications.

Conclusion

There are many more tools that I could go into that I've found useful in this and that application, but the ones I've mentioned above are the essentials and have done well to help with building scalable and maintainable React applications.

Tell us about your project

We build good software through good partnerships. Reach out and we can discuss your business, your goals, and how VisualMode can help.