In the ever-evolving landscape of web development, the monorepo has emerged as a dominant strategy for managing large, complex codebases. By consolidating multiple projects into a single repository, teams gain streamlined dependency management, atomic cross-project commits, and a unified source of truth. However, this architectural choice introduces its own set of challenges, particularly around sharing business logic. As applications scale, the need to share data-fetching and mutation logic between a web app, an admin panel, and a mobile API becomes critical. This is where the latest evolution in Blitz.js offers a groundbreaking solution, solidifying its position as a top-tier fullstack framework. This development is significant news not just for the Blitz.js community but for anyone following trends in the wider ecosystem, including those keeping up with Next.js News, Node.js News, and TypeScript News.

Blitz.js, renowned for its “Zero-API” data layer that allows developers to import server code directly into their React components, is introducing a powerful new capability: the ability to define and store query and mutation resolvers in a shared, application-agnostic location within a monorepo. This seemingly simple change has profound implications for scalability, maintainability, and developer experience, allowing teams to build more robust and cohesive systems with less boilerplate and duplication. This article provides a comprehensive technical deep dive into this new feature, exploring its core concepts, practical implementation, advanced use cases, and best practices.

The Monorepo Challenge and Blitz.js’s “Zero-API” Solution

To fully appreciate the significance of shared resolvers, we must first understand the problem they solve. In a typical monorepo, different applications often need to perform the same data operations. For example, a user-facing e-commerce site and an internal inventory management tool both need to fetch product details. The traditional approach often leads to duplicated logic or the creation of a separate, versioned internal API package, which adds significant overhead.

Blitz.js’s “Zero-API” Data Layer: A Refresher

Blitz.js elegantly sidesteps the need for traditional REST or GraphQL APIs for internal data communication. Its core innovation is the “Zero-API” layer, where server-side functions, called resolvers, can be directly imported and called from frontend components. Blitz handles the underlying API creation, serialization, and data transport automatically. A standard query resolver is a function that runs on the server, has direct access to your database, and returns data that can be consumed by a React component using the useQuery hook.

Before this update, these resolvers were tightly coupled to the application they were defined in, typically residing in a path like apps/web/src/queries/. While effective for single-app projects, it hindered code reuse in a multi-app monorepo.

Here is an example of a classic Blitz.js query resolver located within a specific application’s directory:

// File: apps/web/src/queries/getProject.ts
import { Ctx } from "blitz";
import db from "db";
import { z } from "zod";

const GetProject = z.object({
  id: z.number(),
});

export default async function getProject(input: z.infer<typeof GetProject>, ctx: Ctx) {
  ctx.session.$authorize(); // Ensure user is logged in

  const project = await db.project.findFirst({
    where: { id: input.id, userId: ctx.session.userId },
  });

  if (!project) throw new Error("Project not found");

  return project;
}

The Breakthrough: Decoupling Resolvers from Applications

The latest update fundamentally changes this structure. It introduces a configuration option that allows Blitz.js to locate resolvers in a shared package outside of any specific application folder. This means the exact same getProject query can now be defined once and used by apps/web, apps/admin, and any other application in the monorepo. This architectural shift promotes the DRY (Don’t Repeat Yourself) principle at the data layer, ensuring consistency, simplifying updates, and dramatically reducing redundant code. This is a significant piece of Blitz.js News that aligns with modern development practices seen across the JavaScript ecosystem, from frameworks like RedwoodJS to backend solutions like NestJS.

Implementing Shared Resolvers: A Practical Guide

monorepo architecture diagram - Monorepo Architecture | Figma
monorepo architecture diagram – Monorepo Architecture | Figma

Adopting this new pattern is straightforward and involves a simple change to your monorepo structure and Blitz configuration. Let’s walk through the process step-by-step.

Step 1: Restructure Your Monorepo

First, create a dedicated package for your shared business logic. A common convention is to place this in a packages/ directory. You will move your existing queries and mutations folders into this new package.

Before Structure:

my-blitz-monorepo/
├── apps/
│   ├── web/
│   │   ├── src/
│   │   │   ├── queries/
│   │   │   │   └── getProject.ts
│   │   │   └── mutations/
│   │   │       └── createProject.ts
│   │   └── blitz.config.ts
│   └── admin/
│       └── ... (potentially with duplicated logic)
└── package.json

After Structure:

my-blitz-monorepo/
├── apps/
│   ├── web/
│   │   └── blitz.config.ts
│   └── admin/
│       └── blitz.config.ts
├── packages/
│   ├── shared-resolvers/
│   │   ├── src/
│   │   │   ├── queries/
│   │   │   │   └── getProject.ts
│   │   │   └── mutations/
│   │   │       └── createProject.ts
│   │   └── package.json
└── package.json

Step 2: Configure `blitz.config.ts`

Next, you need to tell each Blitz application where to find these shared resolvers. This is done by adding a new `resolverPath` option to your `blitz.config.ts` file. This option accepts a path or an array of paths, allowing Blitz’s compiler to correctly locate and wire up the resolvers during development and build steps. This configuration-driven approach is a hallmark of modern tools often discussed in Vite News and Webpack News.

// File: apps/web/blitz.config.ts
const { sessionMiddleware, simpleRolesIsAuthorized } = require("blitz");
const path = require("path");

module.exports = {
  middleware: [
    sessionMiddleware({
      cookiePrefix: "my-web-app",
      isAuthorized: simpleRolesIsAuthorized,
    }),
  ],
  resolverPath: path.join(__dirname, "../../packages/shared-resolvers/src"),
  // You can also use an array to include local resolvers
  // resolverPath: [
  //   path.join(__dirname, "./src"), // For app-specific resolvers
  //   path.join(__dirname, "../../packages/shared-resolvers/src"), // For shared resolvers
  // ],
};

Step 3: Consume Shared Resolvers in Your Components

The most beautiful part of this feature is that nothing changes on the client side. Your React components continue to import and use queries and mutations as if they were local. Blitz’s build process handles the resolution, so your developer experience remains seamless. The framework’s tooling, powered by technologies often covered in Babel News and SWC News, abstracts away the complexity.

// File: apps/web/src/pages/projects/[projectId].tsx
import { useQuery } from "blitz";
import { Suspense } from "react";
import getProject from "packages/shared-resolvers/src/queries/getProject";
// Note: The import path points directly to the shared package.
// Your monorepo tooling (e.g., TypeScript paths, Yarn/NPM workspaces)
// should be configured to resolve this.

const ProjectDetails = ({ projectId }) => {
  const [project] = useQuery(getProject, { id: projectId });

  return (
    <div>
      <h1>{project.name}</h1>
      <p>{project.description}</p>
    </div>
  );
};

const ShowProjectPage = () => {
  // ... get projectId from router
  const projectId = 1;

  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <ProjectDetails projectId={projectId} />
      </Suspense>
    </div>
  );
};

export default ShowProjectPage;

Advanced Patterns and Use Cases

Beyond simple code sharing, this architecture unlocks more sophisticated patterns essential for building enterprise-grade applications.

Context-Aware Logic and Authorization

monorepo architecture diagram - Monorepo architecture, CI/CD and Build pipeline -
monorepo architecture diagram – Monorepo architecture, CI/CD and Build pipeline –

A common requirement is to have slightly different behavior or permissions depending on which application is calling the resolver. For instance, an admin user might be able to update any project, while a regular user can only update their own. This can be handled by inspecting the `ctx` (context) object passed to every resolver.

You can configure a different session middleware for each app (e.g., `cookiePrefix: “admin-app”` vs. `cookiePrefix: “web-app”`), allowing you to add app-specific metadata to the session context. The shared resolver can then use this metadata to enforce rules.

// File: packages/shared-resolvers/src/mutations/updateProject.ts
import { Ctx } from "blitz";
import db from "db";
import { z } from "zod";

const UpdateProject = z.object({
  id: z.number(),
  name: z.string().optional(),
  // ... other fields
});

export default async function updateProject(input: z.infer<typeof UpdateProject>, ctx: Ctx) {
  ctx.session.$authorize();

  const { id, ...data } = input;

  // Admin-specific logic
  if (ctx.session.role === 'ADMIN') {
    return await db.project.update({
      where: { id },
      data,
    });
  }

  // Regular user logic
  if (ctx.session.role === 'USER') {
    return await db.project.updateMany({
      where: { id, userId: ctx.session.userId }, // Enforce ownership
      data,
    });
  }

  throw new Error("Unauthorized");
}

Centralized Testing Strategy

Decoupling resolvers into their own package makes them significantly easier to test in isolation. You can set up a dedicated testing environment for the `shared-resolvers` package using tools like Jest or Vitest, a topic of frequent discussion in Jest News and Vitest News. This allows you to write unit tests for your core business logic without needing to boot up an entire Blitz application, leading to faster and more reliable test suites.

// File: packages/shared-resolvers/src/queries/getProject.test.ts
import getProject from './getProject';
import { Ctx } from 'blitz';
import db from 'db';

// Mock the database
jest.mock('db', () => ({
  project: {
    findFirst: jest.fn(),
  },
}));

describe('getProject resolver', () => {
  it('should retrieve a project for an authorized user', async () => {
    const mockProject = { id: 1, name: 'Test Project', userId: 123 };
    (db.project.findFirst as jest.Mock).mockResolvedValue(mockProject);

    const mockCtx = {
      session: {
        $authorize: jest.fn(),
        userId: 123,
      },
    } as unknown as Ctx;

    const result = await getProject({ id: 1 }, mockCtx);

    expect(mockCtx.session.$authorize).toHaveBeenCalled();
    expect(db.project.findFirst).toHaveBeenCalledWith({
      where: { id: 1, userId: 123 },
    });
    expect(result).toEqual(mockProject);
  });
});

Best Practices, Pitfalls, and Performance

To make the most of this powerful feature, it’s important to follow best practices and be aware of potential pitfalls.

monorepo architecture diagram - AWS App Runner adds support for monorepos | Containers
monorepo architecture diagram – AWS App Runner adds support for monorepos | Containers

Best Practices for Organization

  • Structure by Domain: Organize your resolvers within the shared package by business domain (e.g., users/, projects/, billing/). This keeps the codebase manageable as it grows.
  • Clear Naming Conventions: Use descriptive names for your resolvers (e.g., getProjectsForUser, chargeCreditCard) to make their purpose immediately clear.
  • Strict Typing with Zod: Continue to leverage Zod for input validation. This is even more critical in a shared context to ensure a stable contract between the client and server across all applications.

Common Pitfalls to Avoid

  • Over-Sharing: Not all logic belongs in the shared package. If a query is truly unique to one application and will never be used elsewhere, it may be better to keep it local. This avoids polluting the shared space.
  • Tight Coupling: Avoid writing resolvers that implicitly depend on the context of a single application. Logic should be as generic as possible, relying on parameters and the `ctx` object for any variations.
  • Configuration Drift: Ensure that all relevant applications in the monorepo are updated to point to the new shared resolver path. Inconsistent configurations can lead to hard-to-debug errors.

Performance and Build Tooling

From a performance standpoint, this change has minimal impact on the runtime execution of your application. The primary consideration is at build time. Modern monorepo tooling, such as Turborepo or Nx, combined with fast bundlers discussed in Turbopack News and Vite News, are highly optimized for this kind of cross-package dependency and can cache build artifacts effectively, ensuring that your development and CI/CD pipelines remain fast.

Conclusion: A New Era for Enterprise Blitz.js

The introduction of shareable resolvers is a landmark update for Blitz.js, directly addressing a critical need for teams building large-scale applications in a monorepo. By allowing developers to centralize, reuse, and independently test their core business logic, Blitz.js significantly reduces code duplication, enhances consistency, and improves overall maintainability. This feature elevates the framework’s capabilities, making it an even more compelling choice for complex projects that demand a high degree of code sharing and architectural integrity.

As the JavaScript world continues to evolve, with constant innovation seen in React News, Vue.js News, and Svelte News, fullstack frameworks like Blitz.js are proving that a tightly integrated, convention-over-configuration approach can solve real-world development challenges. This update is a testament to the Blitz.js team’s commitment to developer experience and enterprise readiness. For any team managing a multi-app architecture, this is the time to explore how Blitz.js can streamline your workflow and help you build better, more scalable applications.