🪐 Next.js Full Stack App Architecture Guide

February 2, 2024 (8mo ago)

Arno write his BestPractice while design next.js powered Apps in Alibaba's DingTalk and other larger projects.

Server

General API & Architecture Style

  • architecture design should be varied, evolved with different stages of life cycle of the project, do not always use the same architecture style such as micro-services first, they are not always the best choice for starter or small project.
  • unleash the power of Restful API Design keep and follow strict API design principle, or other formal API design principle or RPC framework such as GraphQL or gRPC, carefully choose to apply different communication protocols in one project, do not add complexity until it is really necessary.
  • Stateless and Serverless design first for service to be easily scaled and managed.
  • for public API or internal API management, we should add version control in pathname such as /api/v1/* to make sure the backward compatibility, follow a standard API Design spec such as OpenAPI or Swagger to make sure the API is well documented and easy to use. API is complicated by the time the complexity of the project grows, there are many methodology to control such complexity, we need to pick a suitable one for the project.

Application Layer (Controller)

Controller / Routes: for next.js is api-routes use Function programming style, traditional Application Layer.

  • handle the request and response in Web and Application layer
  • deal with the error for web or other protocol response, handle error codes and error message -> see the following error handling patterns section
  • return unified response or service result object to be handled by the client
  • controller can be write in global middleware function or wrapped by high-order function to handle the common logic
  • better with 0 business logic in this layer

Service Layer

Service: module to handle the business logic with encapsulated domain service & managers in OO style

  • Domain Driven Design provide domain service to handle the business logic
  • can not throw error and should deal with the error
  • return unified response or service result object to be handled by the controller
  • use Swagger or other formal API Specs to integrate with third-party services vendor, make sure the API is well documented in code and use tool to generate those API client code
  • try Interface Oriented design for various kinds of similar services, e.g. *.service.impl.ts
  • obey the Rules of OO BP, such as SOLID, DRY, KISS, etc.
    • use DI style to inject the dependency and build DI driven service dependency and instance management

Manager Layer

Manager: module to manage the business logic as the reusable code, in OO style

  • can throw error
  • optional DI provided for service layer
  • make it more atomic and modular to encapsulate the business logic
  • utils and helper functions can be put here as a sub-category of manager

Data Persistence Layer

  • precisely choose SQL or NoSQL DB type for your business features, don't forget to add redundancy and backup for the data.
  • pick ORM or other DB service vendor to handle the data persistence like Prisma or TypeORM.
  • always start design your db considering the data scaling and data consistency, distributed db and sharding, etc, when you truly need it. e.g. your data-records is over 1M or 10M or 100M in 3 year, etc, they should plan to use different type of db technology and design the data model and data structure in the different ways.
    • consider use Serverless DB to handle the data scaling and management
  • carefully design the data model and data structure in the DB, make full use of the DB index and query optimization, but do not over optimize the db query.
  • follow the Community's SQL, table-creating, index-creating, query-optimization, etc, best practice guide. -> e.g. Alibaba's Java Developer Guide show some best practice guide for MySQL and DB.
  • use transaction and isolation to handle the data consistency and integrity for sensitive data and operations

Next.js Best Practice Guide

  • use turbo-pack + git-submodules + npm packages to handle different level of code share and the monorepo and multi-repo
  • make full use of Next.js' layered cache system in page / routes, React.cache, fetch cache, client cache, more with external redis-cache, etc.
  • try to use EdgeRuntime for handling the static content and fetch related simple tasks
  • use Rust + WASM for high-performance computing tasks, e.g. calculate tokens of large data via tiktoken
  • separate the ServerComponentData / ClientComponentData, use it to handle the server-side rendering and client-side rendering cleverly
  • use CDN for static files and contents, use next/image etc. for resource optimizations
  • use next/script for script optimizations
  • make use add metadata ability inset next.js pages for SEO and SNS sharing
  • boost performance with Next.js dynamic/import with Suspense and React.lazy mechanism
  • use server / client / shared folder to separate the code for different runtime
  • check every possible config in next.config.js to optimize the performance and security before you go to production

Make full use of tools / libs of Node / JavaScript Ecology

Avoid reinventing the wheel, use the best tools and libs in the Node / JavaScript Ecology.

  • DB layer can be presented by Prisma ORM or other db service vendor
  • error / request trace with trace API & tools, such as Sentry / Raygun
  • logs with log API & tools including SLS / ELK for logging and debugging
  • consider use GlobalRef for long-time existed node-runtime instead of serverless runtime
  • add unit tests to cover basic lib / tools and key business logic
  • use config middleware such as Diamond or Vercel Configs for app configs
  • use Redis or other memory shared to handle the cache and session shared by different server instances
  • consider use the AB Test service or Gray Release service to handle the feature release
  • use Kafka or other message queue to handle the async tasks and event-driven tasks
  • use K8S or other container service to handle the deployment and scaling
  • use Prometheus or other monitoring service to handle the monitoring and alerting
  • use Grafana or other dashboard service to handle the dashboard and visualization
  • use Jenkins or other CI/CD service (Github Actions or Vercel) to handle the CI/CD pipeline and production process
  • use Jest or other testing service to handle the unit test and integration test
  • ...

lib and directory structure guide

Recommend directory structure for the next.js full stack app:

.
├── app
├── components
├── configs
├── data
├── tools
├── i18n
├── lib
├── prisma
├── public
├── services
├── tailwind.config.ts
├── tsconfig.json
└── types

The dependency of the directory structure:

  • base functionality
    • public: the public static files
    • prisma: the prisma db schema and model
    • ...
  • global shared
    • tools: global tools for the app (scripts are also included here)
    • types: global type use for external lib or low-level common shared types
    • configs: global configs for the app
    • i18n: global i18n for the app
  • share logic
    • lib: the shared lib and tools, is the basic blocks shared for upper layers, such as utils, helpers, constants, bizTypes, manager, etc.
    • services: the shared services
    • components: the shared components
  • application logic
    • app: the main app entry of next.js app

more detail:

- `app`: the main app entry of next.js app
  - `[bizRoute]`: the business route
    - `page.tsx`: the main page of the route
    - other nextjs conventions named files
    - `*.server.tsx`: biz server component
    - `*.client.tsx`: biz client component
  - `api`: the api routes
    - `/v1`: the versioned api routes for public use
      - `*.ts`: the api routes
- `components`: the shared components
  - `server`: the server-side components
  - `client`: the client-side components
  - `shared`: the shared components
- `configs`: the app shared configs
- `lib`: the shared lib and tools
  - `server`: the server-side lib and tools
  - `client`: the client-side lib and tools
  - `shared`: the shared lib and tools
    - `*.manager.ts`: the biz manager that can be shared
- `services`: the shared services
  - `server`: the server-side services
  - `client`: the client-side services
  - `shared`: the shared services
    - `*.service.ts`: the biz service that can be shared
- `public`: the public static files

Or for unified business logic module, you can group file within a module folder for more clear and easy to maintain:

- `module-name`
  - `*.client.ts(x)`
  - `*.server.ts(x)`
  - `*.shared.ts(x)`

Full example could be:

├── elaborator
│   ├── client
│   │   ├── flow.manager.ts
│   │   └── position.manager.tsx
│   ├── server
│   │   └── elaborator.base.task.ts
│   ├── shared
│   │   ├── elaborator.diagram.manager.ts
│   │   ├── elaborator.enum.ts
│   │   └── elaborator.type.ts
│   └── tasks
│       ├── edoc-generator
│       │   └── doc-generator.server.ts
│       ├── mental-framework-generator
│       │   └── mental-framework-generator.server.ts
│       └── problem-outline-generator
│           ├── problem-outline-generator.server.ts
│           └── problem-outline.node.client.tsx
  • control the naming length of your file, use path as the context, do not make file name too long

My Practicing:

Web Client

React Best Practice Guide

  • separate the Logic and View in different layers
    • JSX / Component should be purely use Functional way
    • DO NOT write chunks of business logic such as handler / callbacks / hooks in the JSX / Component use a function from a client manager or service
    • try Server Actions if possible to handle the server-side logic and client-side logic together in a more simple and clear way
  • separate the State of client in different layers
    • ServerState: state managed by server
      • use hooks for location / url pathname / search-params / next-params, etc, as state of server ...
      • use SWR or ReactQuery to handle the server state and cache or directly handle it by page in server component to reduce the complexity in client
    • SharedContext usually do not mutate that often, use React.Context to handle the shared state and context, such as Theme, Locale, User, etc as Global shared context.
    • SharedState use redux, zustand or recoil with logger and middleware to handle the shared better immutable state among components which can be easily visualized, traced, logged and debugged.
    • ReactiveState consider to use RxJS or Mobx to handle the reactive state and event-driven state
    • LocalComponentState try to use useState and useReducer to handle the local state of the component
  • Hook-Style First to encapsulate the logic and make it more reusable among components and reduce the code in JSX component body, which make the code more clear and easy to maintain

Logic Encapsulation

  • Service liked to the server service, client service and encapsulated apis to invoke by useSwr or useQuery or other API client.
    • some typical examples are:
      • the db-service of local
      • the local-data-cache service
      • the Restful API service
    • service can encapsulate the logic and handle the error and response elegantly, which should not emit exceptions or errors
    • service can coordinate the different services or manager to handle the complex logic
    • ...
  • Manager liked to the server manager, client manager and encapsulated business logic to be invoked by the service or component
    • some typical examples are:
      • the business logic manager
      • the logs manager
      • the event-pub-sub manager
      • ...
    • manager are the basic building blocks of the business logic and can be reused by different services
    • manager can throw error without extra handling process which shall delegate to the service to handle it properly

Tips for Performance & Storage

  • use web worker to handle the heavy computing tasks
  • use Rust / WASM to handle the heavy computing tasks
  • use Service Worker to handle the offline and cache data and requests
  • use local-db such as IndexedDB to handle the local storage and cache
  • use localStorage / sessionStorage to handle the local storage and cache for use preference and temporary data cache

Network and Communication

  • use WebRTC / Websocket for more real-time and interactive tasks
  • use native fetch API first other than ajax or other network communication mechanism
  • use WebStream with Fetch API to handle the large data transfer and stream data

Tips for UI & UX

  • use TailwindCSS or ChakraUI to handle the UI and layout
  • use AntD or MaterialUI with highly customizable theme and complex logic and functionality encapsulated
  • use Storybook or other UI testing service to handle the UI testing and visual testing
  • consider compatibility in different viewport, size, versions and more
  • follow the BP of Mobile Web App development principles
  • follow and apply some web dev guides of Google / Apple / Microsoft / Mozilla / W3C ...
  • consider obey the A11Y and I18N principles
  • add some reasonable animation and transition to make the UI more vivid and interactive
  • Responsive Design FIRST
  • Dark Scheme Compatibility
  • use SSG / SSR / ISR / CSR / Reactive / Hybrid to handle the different rendering strategy to boost the performance and user experience
  • ...

Domain Specified Technology

Do not create the wheel again, use the domain specified technology to handle the domain specified tasks.

  • TextEditor / IDE / Code Formatter / Linter ...
  • Chart / Graph / Data Visualization ...
  • Map / Location / Geo ...
  • Real-time Collaboration ...
  • 3D Rendering / 2D Rendering / Animation ...
  • Audio / Video / Media ...
  • Web XR / VR / AR ...
  • ...

Native Client

can wait to provide the BP for the native client, such as iOS / Android / Windows / Mac / Linux / WebOS / Tizen / HarmonyOS / ... in actions.

Arno is still exploring 🧭

Common Patterns and Conventions

Provide some common patterns and conventions for the next.js full stack app development that developers can follow and use.

Error Handling Patterns

0 error handling is the best error handling, but we can not avoid the error in the real world, so we need to handle the error elegantly and properly.

  • low-level API should throw less error and handle the error in the high-level API
  • use one place to handle the error and log the error, do not handle the error in different places with duplicated code
  • use Native Error object both in Server and Client, and do not extend it to add complexity, use unified error msg format like:
[2024-03-12 12:00:00] [ERROR level] [ServiceName] [ErrorTypeCode] [ErrorMsg] 
[?ErrorStack] if necessary
  • for server response in HTTP Protocol in common, the error should make full use of HTTP status, code, message to describe the error in the response, other RPC protocol should follow their own error handling format pattern
  • use the warn / error and fatal to handle the different level of error, for level of error and fatal, we should use log service to report for stability and reliability
  • use try / catch to handle the error in the service / manager layer, and try-catch phrase block scope should be as less as possible to avoid the performance and ambiguity of the code
  • use ErrorBoundary to handle the error in the component client layer for display fallback info and error info both from client and server

File Types and Conventions

  • *.type.ts: the typescript language type that can be shared
  • *.constant.ts: the constant that can be shared
  • *.util.ts: the utility functions that can be shared
  • *.manager.ts: the biz manager that can be shared
  • *.manager.impl.ts: the implementation of an abstract manager
  • *.service.ts: the biz service that can be shared
  • *.service.impl.ts the implementation of an abstract service
  • *.store.ts: the biz store of zustand or any other react-state manage units that can be shared
  • *.hook.ts: the biz hook that can be shared
  • *.client.tsx: the client-side component that can be shared
  • *.server.tsx: the server-side component that can be shared
  • *.shared.tsx: the shared component that can be shared
  • *.[designPatterName].*.ts: the design pattern implementation in the code
    • account.adapter.impl.ts the adapter pattern implementation of specific service

Class methods naming conventions

  • use verb first and noun second for the method name, e.g. createUser, getUser, updateUser, deleteUser
  • for CRUD operations of a service, use create, get / list, update, delete as the method name prefix
  • for common operations like search fetch count aggregate ... as the method name prefix
  • for common state-trans operations like init start stop pause resume ... as the method name prefix
  • use batch bulk multi as the method name prefix for batch operations

Reference

My Related Articles:

notes on this article

  • 2024-03-12: bugfix and add more details for some topics, add db related topics guide
  • 2024-03-29: add directory naming conventions for components