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:
-
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.
-
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 (opens in a new tab)" 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
There are two main reasons for this recommended structure:
-
β 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.
-
β 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 (opens in a new tab).
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 (opens in a new tab) between teams and projects to speed up CI builds.
Minimum requirements
- Specifying packages in a monorepo (opens in a new tab)
- A package manager lockfile (opens in a new tab)
- Root
package.json
(opens in a new tab) - Root
turbo.json
(opens in a new tab) package.json
in each package (opens in a new tab)
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 (opens in a new tab)
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 (opens in a new tab).
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:
- Collect different exports from separate workspaces
- 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.