Skip to main content

Forge app development: project structure, database & core components

Posted By

Sudarshan More

Date Posted
02-Jul-2026

In our first Atlassian Forge tutorial blog, we covered what Atlassian Forge is and how it works at a conceptual level. Now we build. This Forge app development reference covers every core component of a production-grade Forge app: project structure, the fully annotated manifest.yml, Forge SQL schema design with migrations, triggers, resolvers, webtriggers, and the orchestrator pattern that ties it together.

Scaffolding a Forge app

Every Forge app starts with four Forge CLI commands. Run these to scaffold, deploy, and install your app into a Jira Cloud instance:

bash
forge create         # Follow prompts — select Jira → Custom UI
forge tunnel         # Hot reload during development
forge deploy -e development
forge install --site yoursite.atlassian.net --product jira -e development

The default scaffold gives you a minimal starting point. For any production Forge app, you will restructure it immediately.

Recommended project structure

A scalable Forge app needs strong separation of concerns from day one. Here is the structure that holds up as complexity grows:

my-forge-app/
├── manifest.yml                 # Declares everything — modules, functions, permissions
├── src/
│   ├── index.ts                 # Re-exports all handlers cleanly
│   ├── config.ts                # External API endpoints and constants
│   ├── db/
│   │   ├── schema.sql           # DDL definitions
│   │   ├── sql-queries.ts       # All CRUD operations
│   │   └── setup-schema.ts      # migrationRunner configuration
│   ├── resolvers/
│   │   └── index.ts             # Frontend ↔ backend bridge (thin layer only)
│   ├── services/
│   │   ├── external-client.ts   # Third-party API calls
│   │   └── jira-client.ts       # Jira REST API helpers
│   ├── triggers/
│   │   ├── orchestrator.ts      # Central coordinator
│   │   ├── sync.ts              # Data sync logic
│   │   ├── create-tickets.ts    # Ticket creation
│   │   └── jira-events.ts       # Jira event handlers
│   └── webhooks/
│       └── handler.ts           # Webtrigger endpoints
└── static/my-app/               # React + Vite frontend

Rules that keep this maintainable

Four rules govern every file in this structure:

  • Triggers owns all scheduled and event-driven logic
  • Resolvers is a thin bridge — validate, delegate, return
  • Services has zero Forge-specific imports — pure business logic
  • Db owns every SQL query and schema definition

Breaking these boundaries is the fastest way to create a Forge app that becomes impossible to debug at scale.

The manifest.yml — fully annotated

The manifest.yml is the single source of truth for your entire Forge app. Every module, function, permission, and resource must be declared here. Nothing runs without it.

yaml
app:
  id: ari:cloud:ecosystem::app/your-app-uuid
  runtime:
    name: nodejs22.x               # Node.js 22.x — use latest supported runtime

modules:
  sql:
    - key: main
      engine: mysql                # Enables Forge SQL for this app

  trigger:
    - key: issue-updated
      function: issueUpdatedHandler
      events:
        - avi:jira:updated:issue
    - key: app-installed
      function: setupDbSchema
      events:
        - avi:ecosystem:installed:app

  scheduledTrigger:
    - key: orchestrator
      function: orchestratorFunction
      interval: fiveMinute         # minute | hour | day | week | fiveMinute
    - key: schema-maintenance
      function: setupDbSchema
      interval: hour

  webtrigger:
    - key: external-webhook
      function: webhookHandler

  jira:adminPage:
    - key: admin-config
      resource: main-ui
      render: native
      resolver:
        function: resolver
      title: App Configuration

  function:
    - key: resolver
      handler: resolvers/index.handler
    - key: orchestratorFunction
      handler: index.orchestratorFunction
      timeoutSeconds: 900          # Required for long-running scheduled work
    - key: setupDbSchema
      handler: index.setupDbSchema
      timeoutSeconds: 900
    - key: issueUpdatedHandler
      handler: index.issueUpdatedHandler
    - key: webhookHandler
      handler: index.webhookHandler

resources:
  - key: main-ui
    path: src/frontend/index.tsx

permissions:
  scopes:
    - read:jira-work
    - write:jira-work
    - read:jira-user
    - manage:jira-configuration
    - storage:app
  external:
    fetch:
      backend:
        - address: api.external.com

Important: Adding the sql module to an existing Forge app triggers a major version upgrade. Existing customers' admins must consent before updating. Plan Forge SQL adoption early — retrofitting it post-launch creates unnecessary friction for every installed customer.

Forge SQL — schema design & migrations

Forge SQL is a hosted, MySQL-compatible storage layer built on TiDB. It provisions a dedicated database instance per app installation — each customer's data lives in a completely isolated environment. You do not need to manually partition data with an installation_id column in your SQL tables for Forge-managed data. Forge handles that boundary automatically at the infrastructure level.

Forge SQL is optimised for transactional, operational data — not analytics or OLAP workloads.

The DDL rule nobody warns you about

Schema operations — CREATE TABLE, ALTER TABLE — must run inside scheduled trigger functions. Calling DDL from a resolver or webtrigger will fail unexpectedly. Always wire your schema setup to both the install event and a scheduled trigger:

yaml
scheduledTrigger:
  - key: schema-maintenance
    function: setupDbSchema
    interval: hour

trigger:
  - key: app-installed
    function: setupDbSchema
    events:
      - avi:ecosystem:installed:app

Migrations with migrationRunner

The migrationRunner from @forge/sql is how you manage schema evolution. It tracks which versions have already executed and skips them automatically:

typescript
import { migrationRunner } from '@forge/sql';

export async function setupDbSchema() {
  await migrationRunner
    .enqueue('v001_create_config_table', CREATE_CONFIG_TABLE_SQL)
    .enqueue('v002_create_config_index', CREATE_CONFIG_INDEX_SQL)
    .enqueue('v003_create_sample_table', CREATE_SAMPLE_TABLE_SQL)
    .enqueue('v004_add_status_column', `
      ALTER TABLE details ADD COLUMN status VARCHAR(50)
    `)
    .run();
}

Critical rule: Never modify a past migration. Only ever append new ones. Modifying an already-executed migration has no effect and creates confusion about the true state of your schema.

Schema recommendations

Avoid AUTO_INCREMENT on primary keys — it can cause hotspot issues on large datasets in TiDB. Use AUTO_RANDOM instead, or store UUIDs as BINARY(16):

sql
CREATE TABLE sample (
  id         BIGINT AUTO_RANDOM PRIMARY KEY,
  issue_key  VARCHAR(255) NOT NULL,
  status     VARCHAR(50),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Forge SQL limits

Understanding these limits before you design your schema prevents painful architectural rework later.

Per-install storage:

Environment Storage limit
Production 1 GiB
Staging 256 MiB
Development 128 MiB

Query timeouts per connection:

Operation Timeout
SELECT 5 seconds
INSERT, UPDATE, DELETE 10 seconds
DDL (CREATE, ALTER) 20 seconds

Other limits:

Resource Limit
Tables per installation 200
DML requests per second 150
DDL requests per minute 25
Max row size 6 MiB
Memory per query 16 MiB
Request size 1 MiB
Response size 4 MiB

Not supported: foreign keys, stored procedures, direct connection strings. All Forge SQL access must go through the @forge/sql SDK.

Batch deletes to avoid timeouts

With a 10-second DML timeout, large unbounded deletes will fail. Always chunk them:

typescript
let deleted: number;
do {
  const result = await sql
    .prepare('DELETE FROM details LIMIT 10000')
    .execute();
  deleted = result.rowsAffected;
} while (deleted > 0);

Paginating large syncs

Never process massive datasets in a single execution. Persist page state between trigger runs:

typescript
const progress = await getSyncProgress();
const page = progress?.current_page ?? 0;

const result = await fetchExternalData({ page, pageSize: 100 });
await processBatch(result.items);
await updateSyncProgress(page + 1, result.totalPages);

The orchestrator pattern

For any non-trivial Forge app, the orchestrator pattern is the architecture that scales. Instead of multiple independent scheduled triggers, one central function reads customer configuration and decides what runs:

The orchestrator pattern

One entry point. Config-driven intervals. Centralised error handling. Easy to debug and extend.

typescript
export async function orchestratorFunction() {
  await setupDbSchema(); // Ensure schema exists on every run

  const config = await getCustomerConfiguration();
  if (!config) return;

  if (config.sync_enabled && intervalElapsed(config.last_sync_at, config.sync_interval)) {
    await syncData();
  }
  if (config.create_tickets_enabled && intervalElapsed(config.last_ticket_at, config.create_tickets_interval)) {
    await createJiraTickets();
  }
  if (config.update_enabled && intervalElapsed(config.last_update_at, config.update_interval)) {
    await updateExternalRecords();
  }
}

Event triggers — reacting to Jira in real time

Event triggers fire in response to Jira Software activity. Declare them in manifest.yml and wire them to handler functions:

yaml
trigger:
  - key: issue-updated
    function: issueUpdatedHandler
    events:
      - avi:jira:updated:issue

typescript
export async function issueUpdatedHandler(event: any) {
  try {
    const issueKey = event.issue.key;
    const newStatus = event.issue.fields.status.name;

    await updateLocalRecord(issueKey, newStatus);
    await syncToExternalSystem(issueKey, newStatus);
  } catch (err) {
    // Never re-throw — Jira retries endlessly on failure,
    // causing duplicate processing and retry storms
    console.error(JSON.stringify({ event: 'sync_failed', error: String(err) }));
  }
}

Rule: Jira event handlers must never throw unhandled errors. Always catch, log, and persist failure state internally.

Timeout limits

Every Forge function context has a hard execution ceiling. Design around these — they are not negotiable:

Context Limit
Resolver 25 seconds
Webtrigger 55 seconds
Scheduled trigger 900 seconds (set timeoutSeconds: 900 in manifest)

Resolvers should orchestrate, not execute. For anything slow, fire async and return immediately:

typescript
resolver.define('startBulkSync', async () => {
  runBulkSync().catch(console.error); // Non-blocking
  return { status: 'started' };
});

Webtriggers — inbound HTTP endpoints

Webtriggers are public HTTPS endpoints with no built-in authentication. You are fully responsible for validation. Always check timestamps to prevent replay attacks and verify HMAC signatures before processing:

typescript
export async function webhookHandler(req: WebTriggerRequest) {
  // Validate timestamp to prevent replay attacks
  const timestamp = req.headers['x-timestamp'];
  if (Math.abs(Date.now() - Number(timestamp)) > 300000) {
    return { statusCode: 401, body: 'Expired request' };
  }

  // Validate HMAC signature
  const signature = req.headers['x-signature-256'];
  if (!isValidHmac(req.body, signature, process.env.WEBHOOK_SECRET)) {
    return { statusCode: 401, body: 'Unauthorized' };
  }

  // Offload heavy work — respond within the timeout window
  processEvent(JSON.parse(req.body)).catch(console.error);

  return { statusCode: 200, body: JSON.stringify({ received: true }) };
}

Resolvers — the frontend/backend bridge

Resolvers are the only sanctioned way for your Custom UI to communicate with Jira APIs, your Forge SQL database, and external services. Keep them thin — validate, delegate, return:

typescript
const resolver = new Resolver();

resolver.define('getConfiguration', async ({ context }) => {
  const config = await getConfig();
  return {
    success: true,
    config: config ? maskSensitiveFields(config) : null
  };
});

resolver.define('saveConfiguration', async ({ payload }) => {
  if (!payload.api_key) return { success: false, error: 'API key required' };
  await saveConfig(payload);
  syncData().catch(console.error); // Kick off sync, don't await
  return { success: true };
});

resolver.define('startBulkSync', async () => {
  runBulkSync().catch(console.error);
  return { status: 'started' };
});

export const handler = resolver.getDefinitions();

Frontend calling resolvers via @forge/bridge:

typescript
import { invoke } from '@forge/bridge';

const { config } = await invoke('getConfiguration');
await invoke('saveConfiguration', { api_key: formValues.key });
await invoke('startBulkSync')

Multi-tenancy — what Forge handles vs what you handle

Forge automatically isolates all hosted storage per installation — Forge SQL, Key-Value Store, Custom Entity Store, and Object Store. This is enforced at the infrastructure level, not in your Node.js function code. Each app installation gets its own dedicated Forge SQL database instance. You do not need installation_id columns in your SQL tables for Forge-managed data to be isolated.

sql
-- Clean schema — no installation_id needed for Forge SQL
CREATE TABLE sample (
  id         BIGINT AUTO_RANDOM PRIMARY KEY,
  issue_key  VARCHAR(255) NOT NULL,
  status     VARCHAR(50),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

You can still read context.installationId in resolvers for logging, debugging, or audit trails — but it is not required for data isolation within Forge SQL.

External systems are your responsibility

If your Forge app writes to external systems — your own database, Elasticsearch, S3, or a third-party API — you are responsible for tenant isolation there:

typescript
// External system — you must scope by tenant
await externalApi.createRecord({
  installationId: context.installationId,
  issueKey,
  status
});

This is one of the most common gaps in Forge app development. Forge SQL isolation is automatic; everything outside Forge's managed storage is your boundary to enforce.

In Blog 3, we take this app to production — covering security deep dives, real-world challenges, deployment, distribution, and everything that breaks if you don't plan for it.

Forge app development gets significantly harder between "working locally" and "shipped to real customers." Opcito's engineers have built and shipped production Forge apps and are ready to help you navigate the architecture decisions, edge cases, or migration challenges that come with the territory. Talk to the team.

Subscribe to our feed

select webform