Blitz.js News: Unlocking Monorepo Superpowers with Shared Resolvers

Introduction: The Monorepo Challenge and the “Zero-API” Evolution

In the modern web development landscape, managing large, complex codebases has become a central challenge for growing teams. Monorepos have emerged as a powerful solution, allowing organizations to house multiple applications and libraries within a single repository. This approach streamlines dependency management, simplifies code sharing, and enhances collaboration. However, even within a monorepo, sharing server-side business logic—especially data-fetching logic—can be a cumbersome task. This is a common pain point discussed in the latest Next.js News and a challenge that frameworks are actively trying to solve.

Enter Blitz.js, the “Zero-API” data layer framework for React. Built on top of Next.js, Blitz has always championed a full-stack development experience by allowing developers to import server-side code directly into their React components. This eliminates the need for manually writing API endpoints and data-fetching boilerplate, a concept that resonates strongly with developers following React Query News. Now, Blitz is taking this philosophy a step further. A groundbreaking new feature allows developers to centralize their query and mutation resolvers in a shared monorepo package, making them consumable by any Blitz application within that repository. This update is significant Blitz.js News, offering a robust solution for building scalable, maintainable, and truly integrated multi-app systems.

Section 1: Understanding Shared Resolvers and Their Core Benefits

At its core, a Blitz.js resolver is a simple async function that runs on the server and can be called from the client. It’s where your business logic, database interactions, and authentication checks live. Traditionally, these resolvers were co-located within the specific Blitz application that used them. While effective for single-app projects, this model introduces code duplication in a monorepo environment.

The Problem: Logic Duplication in Multi-App Architectures

Imagine a common monorepo setup: a customer-facing e-commerce store and a separate internal admin dashboard. Both applications need to perform similar actions, such as fetching product lists, updating user profiles, or creating orders. Without a sharing mechanism, you would need to duplicate the resolvers for these actions in each application.

# Before: Duplicated Logic
/monorepo
├── apps
│   ├── store-app
│   │   ├── src
│   │   │   ├── products
│   │   │   │   └── queries
│   │   │   │       └── getProducts.ts  // Logic to fetch products
│   │   │   └── users
│   │   │       └── mutations
│   │   │           └── updateUser.ts   // Logic to update a user
│   └── admin-app
│       ├── src
│       │   ├── products
│       │   │   └── queries
│       │   │       └── getProducts.ts  // DUPLICATED logic
│       │   └── users
│       │       └── mutations
│       │           └── updateUser.ts   // DUPLICATED logic
└── packages
    └── ...

This duplication leads to several problems:

  • Increased Maintenance Overhead: A bug fix or feature update must be applied in multiple places, increasing the risk of inconsistencies.
  • Diverging Business Logic: Over time, the duplicated logic can drift apart, leading to subtle and hard-to-debug bugs where the admin app and store app behave differently.
  • Slower Development: Developers waste time copying, pasting, and adapting existing code instead of writing new features.

The Solution: A Single Source of Truth

The new shared resolver capability directly addresses these issues by allowing you to create a dedicated package for all your data-layer logic. This package becomes the single source of truth for how your applications interact with the database and other backend services.

# After: Centralized Logic
/monorepo
├── apps
│   ├── store-app
│   │   └── ... (consumes shared resolvers)
│   └── admin-app
│       └── ... (consumes shared resolvers)
└── packages
    ├── shared-resolvers
    │   ├── src
    │   │   ├── products
    │   │   │   └── queries
    │   │   │       └── getProducts.ts  // SINGLE SOURCE OF TRUTH
    │   │   └── users
    │   │       └── mutations
    │   │           └── updateUser.ts   // SINGLE SOURCE OF TRUTH
    └── ...

This architectural shift provides immense benefits, including improved code reuse, enhanced maintainability, and stronger consistency across your entire application suite. It’s a pattern that enterprise teams using tools from the Redux News and Apollo Client News ecosystems have long sought for better state and data management at scale.

Section 2: Practical Implementation: Setting Up Shared Resolvers

Implementing shared resolvers in your Blitz.js monorepo is a straightforward process that involves creating a new package and updating your application configuration. Let’s walk through the steps with a practical example using Prisma for database access.

Step 1: Create a Shared Package

Blitz.js logo - What is Blitz.js? A Full-Stack Zero-API JavaScript Framework.
Blitz.js logo – What is Blitz.js? A Full-Stack Zero-API JavaScript Framework.

First, within your monorepo’s `packages` directory, create a new package to house your resolvers. We’ll call it `shared-resolvers`. This package should have its own `package.json` and `tsconfig.json`.

Inside this package, create your first shared resolver. For instance, a query to fetch a list of projects. This file will look exactly like a regular Blitz resolver.

// packages/shared-resolvers/src/projects/queries/getProjects.ts
import { Ctx } from "blitz";
import db from "db"; // Assuming a shared Prisma client instance
import { z } from "zod";

const GetProjectsInput = z.object({
  limit: z.number().min(1).max(100).optional(),
});

export default async function getProjects(
  input: z.infer<typeof GetProjectsInput>,
  ctx: Ctx
) {
  // Ensure the user is authenticated to view projects
  ctx.session.$authorize();

  const projects = await db.project.findMany({
    where: {
      userId: ctx.session.userId,
    },
    take: input.limit || 10,
    orderBy: { createdAt: "desc" },
  });

  return projects;
}

Notice that we can still use `Ctx` for session management and authorization, just as we would in a standard Blitz app. This seamless context propagation is a key feature.

Step 2: Configure Your Blitz Application

Next, you need to tell your consuming Blitz applications where to find these new shared resolvers. This is done in the `blitz.config.js` file of each application (e.g., `apps/store-app/blitz.config.js`).

You’ll use two new configuration options: `resolverPath` and `resolverUrl`.

  • resolverPath: An array of file paths or globs pointing to where your resolvers are located. This tells Blitz’s build process where to look for resolver files.
  • resolverUrl: A function that maps a resolver’s file path to the URL it will be available at. This is crucial for Blitz’s RPC mechanism to work correctly.
// apps/store-app/blitz.config.js
const { sessionMiddleware, simpleRolesIsAuthorized } = require("blitz");
const path = require("path");

module.exports = {
  middleware: [
    sessionMiddleware({
      cookiePrefix: "store-app",
      isAuthorized: simpleRolesIsAuthorized,
    }),
  ],
  resolverPath: [
    // Include the app's own resolvers
    "src/**/queries",
    "src/**/mutations",
    // Include the shared resolvers from the package
    path.resolve(__dirname, "../../packages/shared-resolvers/src/**/queries"),
    path.resolve(__dirname, "../../packages/shared-resolvers/src/**/mutations"),
  ],
  resolverUrl: (filePath) => {
    // Map the shared package path to a clean URL
    const sharedPath = path.resolve(__dirname, "../../packages/shared-resolvers/src/");
    if (filePath.startsWith(sharedPath)) {
      return filePath.replace(sharedPath, "").replace(/\\/g, "/");
    }
    // Default behavior for app-specific resolvers
    return filePath.replace(path.resolve("src"), "").replace(/\\/g, "/");
  },
};

Step 3: Use the Shared Resolver in Your Component

With the configuration in place, you can now import and use the shared resolver in any of your React components just as you would with a local resolver. The `useQuery` hook from Blitz (which is a wrapper around `react-query`) works seamlessly.

// apps/store-app/src/pages/projects/index.tsx
import { useQuery } from "blitz";
import getProjects from "shared-resolvers/projects/queries/getProjects";

const ProjectList = () => {
  const [projects] = useQuery(getProjects, { limit: 20 });

  return (
    <div>
      <h1>My Projects</h1>
      <ul>
        {projects.map((project) => (
          <li key={project.id}>{project.name}</li>
        ))}
      </ul>
    </div>
  );
};

export default ProjectList;

And that’s it! Your `store-app` is now fetching data using logic defined in a completely separate, reusable package. The same process can be repeated for your `admin-app`, allowing both to share the exact same `getProjects` implementation.

Section 3: Advanced Techniques and Architectural Patterns

Beyond basic setup, shared resolvers open the door to more sophisticated architectural patterns that are essential for large-scale applications. This is where the true power of this feature shines, providing solutions that developers using frameworks from Remix News to RedwoodJS News are keenly interested in.

Handling App-Specific Logic within Shared Resolvers

What if a shared resolver needs to behave slightly differently depending on which app is calling it? You can pass an identifier from the client or, more robustly, leverage the `Ctx` object. You could, for example, inject an `appName` property into the session context during the authentication process.

In your `blitz-server.ts`, you could extend the session’s public data:

monorepo architecture diagram - Monorepo Architecture | Figma
monorepo architecture diagram – Monorepo Architecture | Figma
// apps/admin-app/src/blitz-server.ts
// ...
export const { gSSP, gSP, api } = setupBlitzServerForNext({
  plugins: [
    AuthServerPlugin({
      // ...
      session: {
        // ...
        PublicData: {
          appName: "string", // Add to the public data type
        },
      },
    }),
  ],
});

// Then, in your login mutation, you can set it:
// await ctx.session.$create({ userId: user.id, role: user.role, appName: 'admin-app' });

Your shared resolver can then access this property to implement conditional logic:

// packages/shared-resolvers/src/users/mutations/deleteUser.ts
import { Ctx } from "blitz";
import db from "db";

export default async function deleteUser({ id }: { id: number }, ctx: Ctx) {
  ctx.session.$authorize();

  // Critical action: only allow deletion from the admin app
  if (ctx.session.appName !== 'admin-app') {
    throw new Error("This action can only be performed from the admin dashboard.");
  }

  const user = await db.user.delete({ where: { id } });
  return user;
}

Type Safety and Input Validation with Zod

When logic is shared, maintaining a clear and stable contract between the server and client is paramount. Blitz’s first-class support for Zod is invaluable here. By defining Zod schemas for your resolver inputs, you get automatic, end-to-end type safety. Your frontend code (in TypeScript) will know the exact shape of the data the resolver expects, and the server will validate any incoming requests against that schema, preventing invalid data from ever hitting your business logic. This is a best practice often highlighted in discussions around React Hook Form News and data validation.

Section 4: Best Practices and Optimization

To make the most of shared resolvers, it’s important to follow some best practices for organization, testing, and performance.

Organizing Your Shared Resolvers

As your `shared-resolvers` package grows, structure becomes critical. A domain-driven approach is often best. Organize your resolvers into folders based on the business domain they relate to (e.g., `products`, `users`, `orders`, `analytics`). This makes the logic easy to find and reason about.

Testing is Non-Negotiable

Next.js logo - Next.js Logo - PNG Logo Vector Brand Downloads (SVG, EPS)
Next.js logo – Next.js Logo – PNG Logo Vector Brand Downloads (SVG, EPS)

Since this code is a critical dependency for multiple applications, it must be thoroughly tested. Write unit and integration tests for every resolver in the shared package. Tools like Jest are perfect for this. You can mock the database and `Ctx` object to test the business logic in isolation. For end-to-end confidence, tools like Cypress or Playwright can be used to test the full flow from the React component down to the database in each consuming application.

Performance and Bundling

The Blitz compiler is intelligent and will only include the resolvers you actually import into your client-side code in the final JavaScript bundle. This means you don’t need to worry about the `shared-resolvers` package bloating your applications with unused code. However, be mindful of the dependencies you add to the shared package itself. Keep it lean and focused on data-layer logic to ensure optimal performance.

Managing Dependencies

Ensure that dependencies like your Prisma client or other utility libraries are also structured as shared packages within the monorepo. Your `shared-resolvers` package should list these as dependencies, and the consuming applications will get them transitively. This prevents version mismatches and ensures a consistent runtime environment.

Conclusion: A New Era for Scalable Blitz.js Applications

The introduction of shared resolvers is a landmark event in the latest Blitz.js News. It represents a significant maturation of the framework, providing a clean, elegant, and powerful solution to one of the most common challenges in large-scale application development. By enabling a single source of truth for business logic, this feature empowers teams to build more robust, maintainable, and consistent multi-app systems on the Blitz.js and Next.js stack.

For development teams managing large codebases or building out a suite of related applications, this is the time to explore Blitz.js. This feature not only improves the developer experience but also provides a solid architectural foundation for future growth. As the React ecosystem continues to evolve, with constant updates covered in React News and Vite News, Blitz.js solidifies its position as a forward-thinking framework that understands and solves the real-world problems faced by modern development teams. Dive into the documentation, experiment with a shared package, and unlock the full potential of your monorepo today.