Building out a monorepo for the first time was a huge “Aha!” moment and complete mental model shift for me. Traditionally monorepos have been inaccessible and reserved for companies like Google who are writing their own build systems and git software. NX has brought monorepos to the mainstream and made maintaining them very easy. On a large scale where cloning a lot of projects at once can effect your system performance, you may have to write up a CLI to implement sparse-checkout with the NX workspace package. But for everything else, NX provides all the tools necessary for efficient linting, building, testing, and managing multiple libraries and apps.
But I want to talk about something more specific. Organizing monorepos with NextJS apps and Prisma to take full advantage of what NX has to offer.
The basic idea of a monorepo is that 80% of your code is in libraries, 20% is in apps. Or as I like to put it, the logic is in the libraries. And the wrappers and layouts are in the apps.
Prisma and the Backend
Let's take a database API for example. If you're using Prisma, you already have prebuilt, typed methods to access your database. However that doesn't complete a REST, GraphQL or RPC API.
The first thing I like to do is setup Prisma to plan for multiple databases. By specifying an output directory for the Prisma client I can change it's import from '@prisma/client' to '@ethang/sterett-data' as a way to differentiate it from other databases. I will typically then create a sterett-client.ts file in the Prisma folder to initialize and export that client.
Second is starting a library to hold the models for this database. Below you'll see I've created a libary called 'sterett-database' to contain model methods. I'm also using prisma-zod-generator to generate Zod validations for incoming arguments. The parse will throw an error if any arguments do not meet validation. Those validations can also be used on the frontend. Also notice I'm using a object here, not a class. This means this will be called with EventModel.findUnique(), rather than having to use the new keyword.
With this library setup you can call this from any server-side function. From within Next.js getServerSideProps(), from a REST API, from a GraphQL resolver, or from an RPC function. If you're familiar with Nest.JS, think of this as your service. It's where you'll put validations and decide on what to return (such as never returning a password column). Authentication and public endpoints are up to the app to create. The point is to have common code you can import and use in any app, no matter its structure.
For me, you might have also noticed another generator I'm using in the Prisma schema. typegraphql-schema. This generates into the node_modules directory as a package labeled '@ethang/sterett-typegraphql'. Basically it generates an entire GraphQL schema and its resolvers from the Prisma schema.
I don't necessarily recommend always generating GraphQL resolvers. A lot of times generation simply can not properly handle the custom logic required to build production apps. If you're putting together a federation, this might be a good option. But I only use it here because it's for a small admin dashboard app that only needs read/write access as defined by the client.
To build a more robust GraphQL API that can take full advantage of something like Prisma, I recommend NestJS and prisma-nestjs-graphql. The reason I recommend NestJS here is because of that generator. Writing out schema by hand to match Prisma arguments is unrealistic. This generator will allow you to do that and still give you the control you likely need.
But with typegraphql-schema, all I have to do to setup a fully functional GraphQL server is import those generated resolvers, then add the previously created client to context as 'prisma'. The only thing the backend is missing at this point is authentication which I'll add later.
We've now got a GraphQL server very quickly up and running that will allow us to flexibly interact with the database. So let's move on to the client.
NextJS already makes it very easy to organize routes out of the box with it's file based routing. Typically it's in the page file where I'll make calls for data and setup a layout. It's important to keep components small, when they start to exceed 100 lines, they're probably too big.
The way I go about this is to write out everything I need on the page, then push view layers down and as small as possible after. For a long time, I always put logic into custom hooks, but I've since changed my position on that and started doing logic components and view components.
My reasoning for this comes from the idea of reducing unnecessary rerenders by making use of props. This is a more comfortable way to me, to write out logic and send individual props to the component to tell it when to rerender.
For example, this Next page has one simple state property and a GraphQL fetch. The Create New button will never rerender as the Route and name props are both static. The ShowEvents component rerenders when the list of events changes. And the Paginate component rerenders with the skip variable changes (on page traversal) or if the total count of events changes.
If we extend that into something a little more robust we get something like this with form and form-view components. This is an upsert (update or insert) form that prefills the form with data if an object is passed to the component, and updates or creates based on that presence.
Logic and view are kept separate, and React is left to handle props as needed. If you're passing context to a view component, you can also wrap the return function in a useMemo hook and provide it with dependencies specific to the item you're pulling off of context.
NextJS Directory Structure
For directory structure I like to start with the Angular approach and move files up in a way similar to Bulletproof React. Taking the below for example, I categorize components by 'feature'. There are common components that can be used anywhere, and there are components specific to features. These feature components setup a layout with grid or flex, make network requests, pull together multiple components to form a larger view, and handle any client side logic.
As for common components, it's always worth considering if those components are only specific to the app. If they can be used by other apps, they should be moved to a monorepo library. In the form example above, all of the Truss components are imported from a 'trussworks-components' library on my NX monorepo.
Unlike what Bulletproof React recommends, I think it's more comfortable to keep styles, tests and types specific to a component within that component directory like Angular does. It is only when you need something general to the entire feature that you should move those up to new style/types/etc. directories. Start small and atomic, and only escalate files when needed.
If you need to escalate state into context, a context wrapper can be created for the parent component of the feature, the page, or wherever the lowest level it's needed at. For anything store related, you're still following the same directory structure. I'm using Apollo Client here and have no need for any other state management.
NextJS GraphQL Structure
Finally, when it comes to organizing GraphQL calls, that can be a little awkward. Traditionally you might put all of your API calls into one directory and map then to an object like Api.GetEvent(). You can still do that, it's absolutely a good choice. Personally, because GraphQL calls are just template strings, I like to just organize constants into separate files along with return types that use TypeScript's Pick utility to state exactly what's returned.
But don't listen to me...
This is only the structure I've come up with that I find to be comfortable in almost all cases. But I always like to say, every project deserves its own opinions. You might find something that works better for you and your app. There is no one, magic way to organize anything. But organization, in general, helps apps scale and organize very quickly. It's important to make sure that you can be comfortable browsing, reading and modifying code without confusion or getting lost.