Shape: Full-Stack

A full-stack project has two stacks -- a frontend and a backend -- in the same repository. Each stack has its own language, test runner, build process, and deployment target. The main configuration challenge is making CI smart enough to test only the stack that changed.

When this shape applies

Use the full-stack shape when your project has:

  • A frontend (React, Vue, Angular, Svelte) and a backend (Express, Django, Rails, Spring Boot) in the same repository.
  • Two distinct build and test processes.
  • Two deployment targets (e.g., a static site host for the frontend and a container platform for the backend).

If you have three or more deployable services, see Shape: Microservices. If your application is a single server-rendered framework (Rails with embedded views, Django with templates), see Shape: Single Repo.

Folder layout

The recommended layout puts each stack in its own top-level directory:

my-project/
  .gaia/
    config/
      project-config.yaml
  docs/
    planning-artifacts/
    implementation-artifacts/
  packages/
    web/              # Frontend stack
      src/
      tests/
      package.json
    api/              # Backend stack
      src/
      tests/
      requirements.txt  # (or package.json, go.mod, etc.)
  README.md

Alternative layout names are fine (frontend/ and backend/, client/ and server/, apps/web/ and apps/api/). What matters is that each stack has a distinct path that GAIA can use for path-based CI filtering.

Configuration

The stacks section has two entries, each with its own path:

# .gaia/config/project-config.yaml
project:
  name: my-fullstack-app
  description: A task management application

stacks:
  - name: frontend
    language: typescript
    framework: react
    path: packages/web
    test_command: npm test
    build_command: npm run build
    lint_command: npm run lint
  - name: backend
    language: python
    framework: fastapi
    path: packages/api
    test_command: pytest
    build_command: docker build -t my-api .
    lint_command: ruff check src/

platforms:
  - web

The path field is what makes path-based CI filtering work. When a PR changes only files under packages/web/, GAIA's generated CI workflow runs only the frontend test job.

Path-based CI filters

Path filters prevent unnecessary test runs. Without them, every change triggers both frontend and backend tests, doubling your CI time.

# .gaia/config/project-config.yaml
ci_cd:
  platform: github-actions
  preset: small-team
  promotion_chain:
    - staging
    - production
  path_filters:
    enabled: true
  concurrency:
    group: ci-${{ github.ref }}
    cancel_in_progress: true

With path_filters.enabled: true, running /gaia-ci-setup generates workflow triggers that match each stack's path:

  • Changes to packages/web/** trigger the frontend job.
  • Changes to packages/api/** trigger the backend job.
  • Changes to root-level files (project-config.yaml, README.md) trigger both jobs.

Shared dependencies

If both stacks depend on a shared library (e.g., packages/shared/), changes to the shared directory should trigger both test suites. Path filters include root-level and shared paths in the trigger for both stacks by default.

Per-stack test execution

When /gaia-dev-story implements a story, it determines which stack the story belongs to (based on the files being changed) and runs only that stack's test command.

If a story touches both stacks (e.g., adding a new API endpoint and the frontend component that calls it), both test commands run. This is the expected behavior for cross-stack stories.

Configure per-stack test settings in the test_execution section:

# .gaia/config/project-config.yaml
test_execution:
  default_command: npm test
  timeout_seconds: 300
  coverage:
    enabled: true
    threshold: 80

The default_command is a fallback. Each stack's test_command takes precedence when GAIA knows which stack to test.

Handling shared code

Full-stack projects often have shared code -- TypeScript types used by both frontend and backend, validation schemas, or utility functions. Common patterns:

  • Shared package: A third directory (packages/shared/) that both stacks depend on. Not a separate stack in GAIA terms -- it does not have its own deployment. Its tests run as part of whichever stack imports it.
  • API contract: An OpenAPI spec or GraphQL schema that the backend generates and the frontend consumes. Use /gaia-api-design to manage the contract.
  • Monorepo tooling: Tools like npm workspaces, Yarn workspaces, or Turborepo manage dependencies between packages. GAIA's path-based filtering works with all of these.

Deployment considerations

In a full-stack project, the frontend and backend typically deploy to different targets:

  • Frontend: Static hosting (Vercel, Netlify, S3 + CloudFront, GitHub Pages).
  • Backend: Container platform (AWS ECS, Google Cloud Run, Kubernetes) or serverless (AWS Lambda, Cloud Functions).

Each stack can have its own deployment configuration in the environments section:

# .gaia/config/project-config.yaml
environments:
  staging:
    frontend_url: https://staging.example.com
    backend_url: https://api.staging.example.com
    auto_deploy: true
  production:
    frontend_url: https://example.com
    backend_url: https://api.example.com
    auto_deploy: false
    requires_approval: true

When deploying, consider whether the frontend and backend need to be deployed together (because of breaking API changes) or can be deployed independently. Independent deploys are faster and safer but require backward-compatible APIs.

Complete example

Here is a full project-config.yaml for a React + FastAPI application:

# .gaia/config/project-config.yaml
project:
  name: taskmaster
  description: A task management application with React frontend and FastAPI backend

stacks:
  - name: frontend
    language: typescript
    framework: react
    path: packages/web
    test_command: npm test -- --coverage
    build_command: npm run build
    lint_command: npm run lint
  - name: backend
    language: python
    framework: fastapi
    path: packages/api
    test_command: pytest --cov=src --cov-report=term-missing
    build_command: docker build -t taskmaster-api packages/api
    lint_command: ruff check packages/api/src/

platforms:
  - web

ci_cd:
  platform: github-actions
  preset: small-team
  promotion_chain:
    - staging
    - production
  path_filters:
    enabled: true
  concurrency:
    group: ci-${{ github.ref }}
    cancel_in_progress: true
  triggers:
    pull_request:
      checks:
        - lint
        - unit-tests
        - integration-tests
    push_to_staging:
      checks:
        - smoke-test
    push_to_production:
      checks:
        - smoke-test
        - deploy

environments:
  staging:
    frontend_url: https://staging.taskmaster.example.com
    backend_url: https://api.staging.taskmaster.example.com
    auto_deploy: true
  production:
    frontend_url: https://taskmaster.example.com
    backend_url: https://api.taskmaster.example.com
    auto_deploy: false
    requires_approval: true

test_execution:
  default_command: npm test
  timeout_seconds: 300
  coverage:
    enabled: true
    threshold: 80
  smoke:
    command: npm run test:smoke
    timeout_seconds: 60

What to read next