Project Structure

Project structure

your-universal-app/
 
 └── /apps/
     └── /expo/... # Expo workspace (iOS + Android)
     └── /next/... # Next.js workspace (Web, SSR, API)
 
 └── /features/... # Portable feature workspaces
 
     └── /@app-core/...
     └── /some-other-feature/...
 
 └── /packages/... # Portable package workspaces
 
     └── /@green-stack-core/...
     └── /some-utility-package/...

Why not just a single workspace?

There’s two main reasons why the starterkit is structured this way:

  1. Historically, using a monorepo for combining Expo & Next.js was better as you could keep their configs and dependencies separate. Meaning you can configure and upgrade Expo without upgrading Next.js and vice versa. This still holds true for the most part.

  2. It facilitate architecting for copy-paste. A workspace folder within a monorepo is the ideal unit of work to copy and paste between projects. You define your custom code and the dependencies it needs in a single folder, and can then consume it in another workspace. Colocating the UI / API / models / schemas / utils / constants / resolvers / components / hooks for a single, portable, feature.

Monorepo workspaces

Note on terminology

We tend to mean package workspace when we mention ‘workspace’, as defined by Vercel in their glossary:

“A collection of files and directories that are grouped together based on a common purpose. Types of packages include libraries, applications, services, and development tools.”

“This modular approach is essential to monorepos, a repository structure that houses multiple interconnected packages, facilitating streamlined management and development of large-scale projects.”

“In JavaScript, each ‘package workspace’ has a package.json file at its root, which contains metadata about the package, including its name, version, and any dependencies.”

Feature workspaces

You’ll likely write most of your app code in feature workspaces. These are subfolders of the /features/ folder.

The main one is called @app/core. You could write your entire app in this workspace, but that would defeat the purpose of making your app features modular and portable.

Instead, consider units of work that you’ll likely need in other projects. Some good examples may include:

your-universal-app/
 
 └── /features/...
 
     └── /@app-core/...
     
     └── /admin-panel/...
 
     └── /blog/...
 
     └── /comment-section/...

Each of these might have specific routes or route-segments, data shapes, components and business logic that’s mostly the same across projects.

Think of these as the main sections of your app that eiter would feel like bloating the @app/core workspace, or that you’d like to be able to reuse later.

Package workspaces

Package workspaces are less defined by features and more focused on utility. They’re the place to put code that you’d like to consume across feature workspaces. Kind of like an NPM package, but local.

The most common usecase will likely be driver-like behaviour, e.g.

your-universal-app/
 
 └── /packages/...
     
     └── /@db-driver/... # <- e.g. main db driver, import as '@db/driver'
 
     └── /@db-mongoose/... # <- '@db/mongoose' as a driver option
 
     └── /@storage-driver/... # <- '@storage/driver' (similar)
 
     └── /emails/...
 
     └── /stripe-integration/...

Workspace structure

We recommend sticking to a similar structure for each workspace:

features/some-feature
 
 └── /schemas/... # <- Single sources of truth
 └── /models/... # <- Reuses schemas
 └── /resolvers/... # <- Reuses models & schemas
 
 └── /components/...
 └── /screens/... # <- Reuses components
 └── /routes/... # <- Reuses screens
     └── /api/... # <- Reuses resolvers
 
 └── /assets/...
 └── /icons/... # <- e.g. svg components
 └── /constants/...
 └── /utils/...
 
 └── package.json # <- Name the workspace, manage dependencies

Each folder that follows this structure should have its own package.json file to define the package name and dependencies. This way, you can easily copy-paste a feature or domain from one project to another, and NPM will have it work out of the box.

Here’s what this might look like in the full project.
Don’t hesitate to open the /apps/, /features/ or /packages/ folders:

There are two main reasons for this recommended structure:

  1. Group server and client code together in the same workspace so there’s no big split between front and back-end folders. If we did do a front-end back-end split, you’d have to copy-paste multiple files to different destinations, as well as manage feature dependencies in two different places.

  2. ✅ It’s predictable. Anything that’s predictable can be used in automation, scripts and generators, which will further speed up your development in the long run.

Aside from /schemas/, /models/, /routes/ and /icons/, we have no recommendations or automations related to the other folders. The names are just suggestions, and you can rename, add or split as you see fit.

Turborepo Basics

The tool we use to manage the monorepo is called Turborepo.

It’s a wrapper around package manager workspaces (like npm / yarn / pnpm) that adds some extra features.

Turborepo goes out of its way to get out of your way:

  • Does the least amount of work possible
  • Tries to never redo work that’s already been done before
  • Caches tasks and results to speed up subsequent runs on your local machine

You can also share your cache between teams and projects to speed up CI builds.

Minimum requirements

All of this is already set up in the starterkit. However, you might want to keep this in mind when creating new feature or package workspaces.

Configure tasks with turbo.json

The root turbo.json file is where you’ll register the tasks that Turborepo will run. Once you have your tasks defined, you’ll be able to run one or more tasks, e.g.

turbo run lint test build workspace#specific-script-name

Which would run the lint, test and then build tasks in all workspaces, as well as run the “specific-script-name” task in the “workspace” workspace. All in parallel, unless we define otherwise:

https://turbo.build/repo/docs/crafting-your-repository/configuring-tasks

Have a look at the turbo.json in the root of the starterkit to see how we’ve set up the tasks for this project.

Speed up development with Turborepo generators

Turborepo comes out of the box with a code generator system.

For exmaple, to add a new workspace interactively, we’ve added a generator:

npx turbo gen add-workspace
# or `npm run gen add-workspace`

⬇⬇⬇

>>> Modify "project name" using custom generators?
 
? what type of workspace would you like to generate?
 
 feature 
  package 

Depending on which other plugins you’ve merged, there may be other generators available to you.

To check which generators are available:

npx turbo gen # interactive list to pick from

⬇⬇⬇

>>> Modify "project name" using custom generators
 
? Select generator to run (Use arrow keys)
 
 add-dependencies: Install Expo SDK compatible deps
  add-workspace: Create new feature or package workspace 

The registry pattern

Ofcourse, any portable code you write in an isolated workspace is useless if you ultimately can’t tie it all together in your apps or core features.

That’s where the registry pattern comes in:

  1. Collect different exports from separate workspaces
  2. Re-export the most important parts in a single place.

This is done in the @app/registries workspace:

your-universal-app/
 
 └── /packages/...
 
     └── /@green-stack-core/...
 
         └── /scripts/... # <- Scripts to collect files from workspaces
 
     └── /@registries/... # ⬇⬇⬇ Collections from other workspaces
 
         # Drivers - e.g. result of `npm run collect:drivers`
         └── drivers.config.ts # driver enums & types
         └── drivers.generated.ts # barrel of drivers
 
         # Barrel file of DB models - `collect:models`
         └── models.generated.ts
         
         # Turborepo generators - `collect:generators`
         └── generators.generated.ts
         
         # GraphQL resolvers - `collect:resolvers`
         └── resolvers.generated.ts
 
         # Next.js route list - `link:routes`
         └── routeManifest.generated.ts # types for Link component
 
         # Workspace helpers - `check:workspaces`
         └── workspaceResolutions.generated.js
         └── transpiledWorkspaces.generated.js # used in next.config

The npm run dev script will run all the necessary scripts to collect to rebuild these files automatically.

The glue when designing features for copy-paste

This pattern is what facilitates the copy-paste design of workspaces in this starterkit. It allows you to build a feature in isolation, defining its own routes, UI and logic. Then you use the registry to plug it into your app to make it work, and be typesafe.

For example, how our Link component knows about all the routes in the app:

your-universal-app/
 
 └── /packages/...
 
     └── /@registries/...
 
         # Types for Link component
         └── routeManifest.generated.ts # list of possible routes
 
     └── /@green-stack-core/... # ⬇⬇⬇ 
 
         └── /navigation/...
             └── /Link.ts # <- Used here to provide editor hints

In the other docs, we’ll dive deeper into what each of these registries is used for.

Further reading