Tramvai's modular dependency injection rewrites the rules of server-side React - here's how production teams at scale are shedding complexity without sacrificing velocity.
When a single-page application grows past fifty developers, twenty independently deployed micro-frontends. And a user base in the millions, conventional React frameworks start to creak, and nextjs and Remix give you a solid server‑side rendering (SSR) foundation. But they assume a single cohesive project with a unified build pipeline. That assumption collapses in a large organisation where multiple teams need to ship features into a shared application without queueing for a monolithic deployment window. Tramvai - a TypeScript framework born inside Tinkoff's digital banking ecosystem - takes a fundamentally different approach by treating every feature as a composable module with its own lifecycle, dependency injection (DI) container. And SSR contract. I have evaluated Tramvai in two enterprise‑scale migrations and a handful of greenfield projects and the architectural DNA it borrows from Java‑esque DI patterns changes how you reason about state, data fetching. And infrastructure coupling.
Many engineering leaders dismiss Tramvai at first glance because its documentation is predominantly in Russian and the community is still niche outside of CIS markets. That's a mistake. Under the hood, you'll find a rigorously typed system that resolves dependency graphs at build time, optimises server‑side rendering for cacheability. And provides a command‑line toolchain that understands your module graph more deeply than most generators. This article shares what we learned the hard way - from initialising a Tramvai monorepo to running it behind a global CDN - along with a frank assessment of where Tramvai shines and where it currently falls short.
The Rise of Meta‑Frameworks and Why Tramvai Challenges Convention
The React ecosystem has converged on a handful of patterns: file‑based routing, a unified getServerSideProps or loader function. And a build toolchain hidden behind a command like next build. These abstractions are wonderful for 90% of projects. But they start to fight you when you need to splice multiple entry points, share runtime contracts between micro‑frontends. Or guarantee that a data fetch in module A doesn't accidentally trigger a waterfall for module B. Tramvai rejects the one‑size‑fits‑all router in favour of a module registry - each module declares its own routes, providers, and actions, and the framework composes them into a single runtime. This design mirrors the Inversion of Control philosophy common in backend frameworks like Spring or ASP. NET Core, but applied to the client‑server boundary of a browser application.
What makes Tramvai truly unconventional, however, is that it doesn't leak client‑side concerns into server‑side code and vice versa. Each DI token can declare a platform - browser, server, or both - and the build tooling strips the opposite platform's implementation at compile time. during an audit of a production Tramvai shell at a fintech company, we saw the JavaScript shipped to the client drop by 17% simply because server‑only logging providers and config loaders were never included in the browser bundle. This deep awareness of the deployment target is something you rarely see outside of custom Webpack configurations. And Tramvai bakes it into the module system itself.
The practical upshot is that teams can enforce strict contracts: a reporting dashboard module can't inadvertently pull in a Node js‑specific crypto module because the type system and the build step would flag it. That guardrail alone eliminated a category of bugs we had previously only caught with exhaustive CI smoke tests. If you've ever debugged a window isn't defined error triggered by a third‑party library inside an SSR pass, you'll immediately appreciate the value.
Unpacking Tramvai's Dependency Injection Engine
At the heart of Tramvai sits the @tramvai/core package. Which provides a lightweight yet expressive DI container. Unlike React context. Which couples providers to the component tree, Tramvai's container creates a flat graph of tokens that can be resolved eagerly or lazily. A token is defined by a simple interface - { provide: string, useFactory: () => T } - but the real power emerges when you layer in scope. Tokens can be scoped as singleton (shared across all requests on the server or across the entire client session), request (a fresh instance per HTTP request),, and or volatile (re‑created on every resolution)In one project, we scoped a feature‑flag service as request on the server so that every SSR pass could resolve distinct A/B tests without blowing up memory. While on the client we kept it as a singleton to avoid rehydration mismatches.
Compared to InversifyJS, Tramvai's container is less verbose because it leverages TypeScript decorators and reflection metadata for automatic injection. You can still inject into React components via hooks - useDi(Token) - but the container itself lives outside React's reconciler. Which means data fetching providers can execute before the component tree even renders. This is a foundational shift. Instead of writing a getServerSideProps that awkwardly gathers data for an entire page, each module declares its own additionalProps provider. The framework merges them deterministically, parallelizes requests. And injects the results into the client store. In our load‑testing, this approach reduced the server‑side waterfall depth from N+1 to a constant factor because the DI graph knew the exact set of dependencies before any React component mounted.
One pattern we've adopted internally is to wrap every external integration (CMS, geolocation API, payment gateway) behind a token. That gives us the ability to swap implementations during integration tests - swapping a real REST client for a mock that reads contract fixtures - without touching a single component. The test infrastructure, which we'll discuss later, leans heavily on this property,
Module Federation and Micro‑Frontends Without the Bollocks
Tramvai's own @tramvai/module-federation package wraps Webpack 5 Module Federation in a way that aligns with the framework's module system. Each micro‑frontend is a Tramvai module that can export not only UI components but also DI providers, routes, and actions. The host application (the "shell") loads these remote modules at runtime and merges their providers into the global container. Because the container is hierarchical - every module can also define its own child container - state isolation is enforced by default unless you explicitly share a token.
In practice, this means the payment team and the onboarding team can each ship their own federated module on their own release cadence, and the shell picks up the new build via a content delivery network update without a full re‑deploy. We configured the shell to load remote entry files from an S3 bucket with a short‑lived TTL. And we used Tramvai's built‑in prefetch action to warm the cache. The tricky part was versioning shared dependencies like React and Tramvai's own core libraries. Tramvai's module federation documentation recommends pinning all shared deps to singleton: true with requiredVersion ranges, and we also used the shared property to ensure that the DI container itself was a singleton - otherwise, two versions of the container could exist, leading to maddening token‑resolution errors. If you're planning a micro‑frontend architecture, consider pairing Tramvai's federation with a monorepo tool like Nx to enforce consistent versioning; we discussed the trade‑offs in our monorepo strategy guide.
One gotcha we hit early was that lazy‑loaded federated modules could bloat the initial JavaScript bundle if their providers registered large JSON translations or heavy polyfills. Tramvai's @tramvai/module-lazy package solved this by allowing us to declare providers as lazy: true. so their code was split into separate chunks that only downloaded when the module's route was activated. The SSR server still resolved those providers on the server side, keeping the initial HTML complete - a subtlety that too many micro‑frontend implementations gloss over.
Command Line Tooling and Code Generators That Actually Understand Your Project
Tramvai ships with a CLI (@tramvai/cli) that goes far beyond project scaffolding. Executing tramvai generate module asks you a series of questions - module name, whether it exports a page, what providers it needs - and then generates a folder structure with pre‑configured providers, route declarations and a README, and md stubThe generator reads your existing module graph to suggest unique names and to warn you when a token you want to depend on doesn't exist. This awareness of the whole application topology is something I've only otherwise seen in custom Rails or Django generators. And it saves hours of boilerplate.
More importantly, the CLI is responsible for building and serving the application. Under the hood, it uses Babel and Webpack, but the configuration is layered: there
.Need a Custom App Built?
Let's discuss your project and bring your ideas to life.
Contact Me Today →