Moving Beyond ‘Hello World’: A Scalable Project Structure for Forge
Why should you even build an Atlassian App?
Moving Beyond ‘Hello World’: A Scalable Project Structure for Forge

Why should you even build an Atlassian App?
To answer that I’ll tell you a little about myself and my journey. Ever since I remember myself, I wanted to create my own business, that’s why in every job that I’ve had, I dedicated myself to an extreme level in order to learn as much as I can. Another important thing is that I truly enjoy coding and building application/solutions to problems I faced.
To keep things short and not go off topic, I’ll say that on my journey me and a couple of partners tried to get fundings from VC in order to start a startup in the mobile-gaming industry, we did it for a long time and threw a lot of POCs in the trash.
At that point I wanted something different, something I can develop without a need for funding, reach a “Production Level” app in less than a month, and start selling fast.
That exact desire — to build fast and sell without VCs — is precisely why the Atlassian Marketplace is such a compelling opportunity. You aren’t spending a fortune to find users, you are tapping directly into an established ecosystem of over 300,000 customers. The most critical fact is its acceleration: the marketplace generated its most recent $1 billion in just over a single year (As of April 2024).
Why forge?
Atlassian Forge platform is a massive strategic advantage, especially for a solo developer. Forge is a serverless platform, which means Atlassian handles all the infrastructure, hosting, security, and scaling for you. This provides incredible peace of mind, allowing you to focus purely on coding your solution instead of managing servers, databases, and compliance.
Another important part is the “Runs on Atlassian” badge — a powerful signal to enterprise customers that your app meets their strict data residency and security standards because it runs entirely within Atlassian’s trusted cloud. The financial incentive is the real game-changer: Atlassian is heavily promoting Forge by allowing developers to keep 100% of their app revenue until they reach their first $1 million in lifetime earnings, making it the most profitable and secure way to get your side project to market.
Then what’s the problem?
When I built my first app, I had a hard time with the documentation, most of it is in javascript, mostly building an extra field on the issue panel, or other very small features that do not have the complexity of a full app.
So what I’m going to do now, is build with you a very simple app, but build it with an architecture that scales and clean code, and of course build it in typescript and show you all the needed configuration.
What are we building?
in order to keep things simple, we will build a To-Do list app, put it both on the “globalPage” module for convenience (for more modules/placement possibilities you can look at this link Modules). and save the result using forge storage.
We would create the app using custom UI, why custom UI and not the Atlassian Ui kit? In my opinion, when building an entire app, you’ll need the ability to fully custom your app, weather you want a drag and drop element, file input and other things that are not available via the UI kit (written in Oct 2025).
What will be our tech stack?
We will create the UI using react + vite, perform the server calls using rtk-query (this is only a personal preferance, you can use react-query if you’d like). and of course write all in typescript.
Important Note: if you like SSR (server side rendering) frameworks like Remix or Next.js (to some degree). it would not work for you, that’s why we are using vite, I already had to switch so you wouldn’t have to.
why is that?
When you build an app with Forge Custom UI, you are essentially building a client-side Single Page Application (SPA).
You build: You use a framework like React and a bundler like Webpack.
You deploy: You run forge deploy, which bundles all your code into a static folder containing files like index.html, main.js, and style.css.
Forge hosts: Atlassian takes this static folder and hosts it for you on its own secure infrastructure.
It runs: When a user opens your app, Atlassian serves that index.html file into an iframe in Jira or Confluence. All your app's logic (like routing and rendering) then runs inside the user's browser.
Okay Let’s Start Building
- Install forge and log in:
use the instructions here: Getting started with Forge
but TL;DR for you, you can basically run
npm install -g @forge/cliand then runforge login(if it doesn’t work you should probably close the terminal and open a new one) - Creating the project:
a very simple step, all you need to do is go to the desired location where you want to create your project and write in the command line
forge createenter your project’s name, then select the Atlassian app on which our app will run (in our case Jira), the category of Custom-UI, and the module ofjira-global-page.

creating demonstration
- Remove the example custom UI and add your own
I like to do it so I could use the latest features and decouple myself from the forge CLI example. I remove the example in the static/hello-world folder (including the hello world folder itself) open the command line again remove the directory using
rm -rf static/hello-worldthencd staticand create the new project usingnpm create vite@latest

- React Project Guideline this article is more about the forge framework than a react project guideline (there are a lot more of those, and certianly better than what I will write). but I will give some guidelines in short points that in my opinion keeps a react project clean and scalable.
- directory structure: I normally go with a “page based” structure which is:
├── src/
│ ├── assets/ # Static assets like images and fonts (related to vite more than react)
| ├── hooks/ # Globally used custom hooks
│ ├── common/ # Reusable common components across the application
│ ├── pages/ # Page components, representing different routes
│ │ └── [your_page_name]/
│ │ ├── hooks/ # Hooks specific to the page
│ │ ├── components/ # Components specific to the page
│ │ └── context.tsx # Optional, but usually I like to use context to control the page's state and keep the logic of handling it in a dedicated place
│ │ └── page.tsx # The main component for the page
│ ├── types/ # TypeScript type definitions
| ├── store/
| | └── api/ # Here we'll place our rtk-query related files
│ │ └── dtos/ # Server responses and requests (dtos are a common practice that helps decouple ui logic from the servers logic)
│ │ └── mappers/ # Helper files to help map client type to dtos
│ ├── App.tsx # Root application component
│ ├── index.css # Global CSS styles
│ └── main.tsx
this gives you great context finding what you need and also making sure you “have” to move a component to common if you need it in more than one page, addressing it differently and with more caution.
-
components logic: for the page’s components I like to keep the logic as UI related as possible, meaning that in the case of our to do list, the logic of the crud operations would be placed elsewhere.
-
using context: don’t look at it as “I should always use context” this is more of a: if you have a lot of logic regarding the manipulation of the state of the page, and find yourself do a lot of prop drilling. that’s when I would use context. example:
import { createContext, useContext } from "react";
import type { Todo, TodoPriority } from "../../../types/todo";
interface TodoContextType {
todos: Todo[];
addTodo: (text: string, priority: TodoPriority) => void;
toggleTodo: (id: number) => void;
deleteTodo: (id: number) => void;
editTodo: (id: number, text: string, priority: TodoPriority) => void;
}
export const TodoContext = createContext<TodoContextType | undefined>(
undefined
);
export const useTodos = () => {
const context = useContext(TodoContext);
if (context === undefined) {
throw new Error("useTodos must be used within a TodoProvider");
}
return context;
};
import { useState, useCallback } from "react";
import type { Todo, TodoPriority } from "../../types/todo";
import { TodoContext } from "./hooks/useTodosContext";
export const TodoProvider = ({ children }: { children: React.ReactNode }) => {
const [todos, setTodos] = useState<Todo[]>([]);
const addTodo = useCallback((text: string, priority: TodoPriority) => {
const newTodo: Todo = {
id: Date.now(), // just for the example, using Date.now() for Id is a very bad practice, we'll address it when we'll build the server
text,
completed: false,
priority,
};
setTodos((prevTodos) => [newTodo, ...prevTodos]);
}, []);
const toggleTodo = useCallback((id: number) => {
setTodos((prevTodos) =>
prevTodos.map((todo) => {
if (todo.id === id) {
return { ...todo, completed: !todo.completed };
}
return todo;
})
);
}, []);
const deleteTodo = useCallback((id: number) => {
setTodos((prevTodos) => prevTodos.filter((todo) => todo.id !== id));
}, []);
const editTodo = useCallback(
(id: number, text: string, priority: TodoPriority) => {
setTodos((prevTodos) =>
prevTodos.map((todo) =>
todo.id === id ? { ...todo, text, priority } : todo
)
);
},
[]
);
return (
<TodoContext.Provider
value={{ todos, addTodo, toggleTodo, deleteTodo, editTodo }}
>
{children}
</TodoContext.Provider>
);
};
you can now go ahead and build your own to do list UI. when you’re done with the basic logic come back and then we’ll do the server and connect it all with rtk-query
Building the server
right now our “server” or forge resolver, is a folder containing only index.js file which is the hello world resolver.
In order to make it a server that is built to last, with clean and maintainable code, we should use typescript and eslint. you can choose which configuration you want but I usually stick with the standard. so here are how my files look:
first install the dependencies by running npm i -D eslint@^9 @eslint/js@^9 typescript-eslint@^8 typescript@^5 globals@^16 you can also initialize the eslint by running npm init @eslint/config@latest
// tsconfig.json
{
"compilerOptions": {
"target": "ESNext",
"module": "CommonJS",
"moduleResolution": "Node",
"lib": ["ESNext"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}
// eslint.config.js
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import { defineConfig } from "eslint/config";
export default defineConfig([
{
ignores: [
"static/**",
"**/node_modules/**",
"**/dist/**",
"**/build/**",
],
},
js.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
{
files: ["src/**/*.ts"],
languageOptions: {
parser: tseslint.parser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
globals: globals.node,
},
rules: {},
},
]);
add the scripts lint and lint:fix to the package.json
"scripts": {
"lint": "eslint \"src/**/*.ts\"",
"lint:fix": "eslint \"src/**/*.ts\" --fix"
}
It’s Time To Move Forward To The Server Code
For our todo app, I want us to start working on the server. the server is located in the src, it should be divided to routers, services, repositories and mappers.
the routes logic should be to extract the necessary data from the request, do a schematic validation call the right function in the related service and return a successfulResponse or an errorResponse (our BaseDtos).
the repositories are responsible to talking with the data layer, in our case talking with the forge key value storage for saving removing updating and reading from it the todo items
the mappers are responsible for converting the request dtos coming from the client to entities or other object that the services want to work with
the services layer is where the business logic is made — like saving a todo item, in that example the service will be responsible for converting the request to the Todo entity, pass it to the repository to save it, convert the result into a proper response and returning it. this layer is in fact the orchestrator.
Why Do we have to do it?
well, we don’t really “have” to do it. but, by doing it we divide responsibilities, making our code easier to maintain, more testable, and by that simply better. writing code that is true to the S.O.L.I.D principles (see here: SOLID — Wikipedia) often takes more time, and more writing. but the more your system advances and the more features you have to support, the less bugs you have, and it will eventually save you a lot more time then you spent on it.

So, to give you an idea on how the server should look, I’ll show you the directory structure and the files themselves:
directory structure:
src/
├── index.ts
├── mappers/
│ └── todo.mapper.ts
├── repositories/
│ └── todo.repository.ts
├── routers/
│ └── todo.router.ts
├── services/
│ └── todo.service.ts
└── types/
├── dtos/
│ ├── baseDto.ts
│ └── todo.dto.ts
└── entities/
└── todo.entity.ts
// todo.mapper.ts
import { v4 as uuidv4 } from "uuid";
import { CreateTodoDto, TodoDto } from "../types/dtos/todo.dto";
import { Todo } from "../types/entities/todo.entity";
export const toTodo = (todoDto: CreateTodoDto): Todo => {
const now = new Date().toISOString();
return {
title: todoDto.title,
priority: todoDto.priority,
id: uuidv4(),
completed: false,
createdAt: now,
updatedAt: now,
};
};
export const toTodoDto = (todo: Todo): TodoDto => ({
id: todo.id,
title: todo.title,
completed: todo.completed,
priority: todo.priority,
createdAt: todo.createdAt,
updatedAt: todo.updatedAt,
});
export const toTodoDtoList = (todos: Todo[]): TodoDto[] => todos.map(toTodoDto);
// todo.repository.ts
import storage from "@forge/kvs";
import { Todo } from "../types/entities/todo.entity";
const TODOS_KEY = "todos";
export const getTodos = async (): Promise<Todo[]> => {
const result = (await storage.get<Todo[]>(TODOS_KEY)) as Todo[] | undefined;
return result ?? [];
};
export const getTodoById = async (id: string): Promise<Todo | undefined> => {
const todos = await getTodos();
return todos.find((todo) => todo.id === id);
};
export const createTodo = async (todo: Todo): Promise<Todo> => {
const todos = await getTodos();
await storage.set(TODOS_KEY, [todo, ...todos]);
return todo;
};
export const updateTodo = async (todoToUpdate: Todo): Promise<Todo> => {
const todos = await getTodos();
const updatedTodos = todos.map((todo) =>
todo.id === todoToUpdate.id ? todoToUpdate : todo
);
await storage.set(TODOS_KEY, updatedTodos);
return todoToUpdate;
};
export const deleteTodo = async (id: string): Promise<void> => {
const todos = await getTodos();
const updatedTodos = todos.filter((todo) => todo.id !== id);
await storage.set(TODOS_KEY, updatedTodos);
};
// todo.service.ts
import {
createTodo as createTodoInDb,
deleteTodo as deleteTodoInDb,
getTodoById,
getTodos as getTodosInDb,
updateTodo as updateTodoInDb,
} from "../repositories/todo.repository";
import { CreateTodoDto, UpdateTodoDto } from "../types/dtos/todo.dto";
import { toTodo, toTodoDto, toTodoDtoList } from "../mappers/todo.mapper";
export const getTodos = async () => {
const todos = await getTodosInDb();
return toTodoDtoList(todos);
};
export const createTodo = async (createTodoDto: CreateTodoDto) => {
const todo = toTodo(createTodoDto);
const newTodo = await createTodoInDb(todo);
return toTodoDto(newTodo);
};
export const updateTodo = async (id: string, todoDto: UpdateTodoDto) => {
const originalTodo = await getTodoById(id);
if (!originalTodo) {
throw new Error("Todo not found");
}
const todoToUpdate = {
...originalTodo,
...todoDto,
updatedAt: new Date().toISOString(),
};
const updatedTodo = await updateTodoInDb(todoToUpdate);
return toTodoDto(updatedTodo);
};
export const deleteTodo = (id: string) => deleteTodoInDb(id);
// todo.router.ts
import Resolver from "@forge/resolver";
import {
createTodo as createTodoInService,
deleteTodo as deleteTodoInService,
getTodos as getTodosInService,
updateTodo as updateTodoInService,
} from "../services/todo.service";
import type { CreateTodoDto, UpdateTodoDto } from "../types/dtos/todo.dto";
import {
createErrorResponse,
createSuccessResponse,
} from "../types/dtos/baseDto";
import type { TodoDto } from "../types/dtos/todo.dto";
const todoHandlers = {
getTodos: async () => {
try {
const todos = await getTodosInService();
return createSuccessResponse(todos);
} catch (e) {
return createErrorResponse<TodoDto[]>((e as Error).message);
}
},
createTodo: async (req: { payload: CreateTodoDto }) => {
try {
const todo = req.payload;
const newTodo = await createTodoInService(todo);
return createSuccessResponse(newTodo);
} catch (e) {
return createErrorResponse<CreateTodoDto>((e as Error).message);
}
},
updateTodo: async (req: { payload: { id: string; todo: UpdateTodoDto } }) => {
try {
const { id, todo } = req.payload;
const updatedTodo = await updateTodoInService(id, todo);
return createSuccessResponse(updatedTodo);
} catch (e) {
return createErrorResponse<UpdateTodoDto>((e as Error).message);
}
},
deleteTodo: async (req: { payload: { id: string } }) => {
try {
const { id } = req.payload;
await deleteTodoInService(id);
return createSuccessResponse(true);
} catch (e) {
return createErrorResponse<boolean>((e as Error).message);
}
},
};
export const registerTodoRoutes = (resolver: Resolver, baseUrl: string) => {
resolver.define(`${baseUrl}/get-all`, todoHandlers.getTodos);
resolver.define(`${baseUrl}/create`, todoHandlers.createTodo);
resolver.define(`${baseUrl}/update`, todoHandlers.updateTodo);
resolver.define(`${baseUrl}/delete`, todoHandlers.deleteTodo);
};
// index.ts
import Resolver from "@forge/resolver";
import { registerTodoRoutes } from "./routers/todo.router";
const resolver = new Resolver();
registerTodoRoutes(resolver, "/api/todos");
export const handler = resolver.getDefinitions();
notice how everything is digestible, the functions are short and do only one thing.
So what is left for us to do?
now all we need is for our UI to speak with the backend and connect the entire logic. we will use the forge’s bridge invoke functionality which is an extremely powerful tool given to us out-of-the-box.
What is Forge’s bridge resolver function and why is it important?
Atlassian Forge’s invoke function securely calls backend resolvers—serverless functions running directly on Atlassian's managed infrastructure. For a solo developer, this model is a massive security advantage as it eliminates critical overhead. You don't need to build, secure, patch, or monitor your own server. Atlassian handles all the complex infrastructure security, authentication, and authorization, ensuring your app runs in an isolated, hardened environment. This allows you to focus solely on your app's features, inheriting a secure, production-grade backend by default.
So, what does rtk-query has to do with it?
well, rtk-query is a powerful tool for API calls, it caches responses and lets you refetch on trigger like when adding a todo, automatically, the todo-list refreshes. and also, it creates for you powerful hooks automatically when you’re done with the configuration (for more information: RTK Query Overview | Redux Toolkit)
Lets start building the API:
rtk-query is part of redux toolkit, and it is built on top of redux that’s why you’ll need to configure a store first, then you can create your BaseAPI (if you have multiple servers you need to communicate with then it is probably better to call it ResolverAPI or some other name).
when defining an API rtk-query lets you define your own query function and give the relevant tags to the API.
what is a tag? a tag is a string that can be provided to an endpoint (like get todos) and a mutation — mutation is an endpoint that changes the data in the server and should cause a refetch and clear of the cache, in our case add, delete, update of a todo item.
what about the base query? usually with rtk query you use the fetchBaseQuery that is given to you by the package. but we are using forge, and forge resolvers, so we will create our own invokeBaseQuery and it will look like this:
import type { BaseQueryFn } from "@reduxjs/toolkit/query/react";
import type { ServerResponse } from "../server-dtos/base.dto";
import { invoke } from "@forge/bridge";
interface InvokePayload {
[key: string]: unknown;
}
export const invokeBaseQuery =
(
{ baseUrl }: { baseUrl?: string } = { baseUrl: "/api" }
): BaseQueryFn<
{
url: string;
data?: InvokePayload | undefined;
},
unknown,
unknown
> =>
async ({ url, data }) => {
try {
const result: ServerResponse<unknown> = await invoke(
`${baseUrl}${url}`,
data
);
if (!result.success) {
throw new Error(result.error);
}
return { data: result.data };
} catch (error) {
const err = error as Error;
return {
error: {
status: err.message,
data: err.message,
},
};
}
};
what did we do here? well, lets say we didn’t use rtk-query. we would have probably created a hook called useInvoke or something that would probably look like this:
const useInvoke = (url: string) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const invoke = useCallback(async (data: any) => {
setError(null);
setIsLoading(true);
try {
const result: ServerResponse<unknown> = await invoke(
url,
data
);
if (!result.success) {
throw new Error(result.error);
}
return { data: result.data };
} catch (error) {
const err = error as Error;
setError(err.message);
return {
error: {
status: err.message,
data: err.message,
},
};
} finally {
setIsLoading(false);
}
}, [url]);
return { isLoading, error, invoke };
}
this code is very similar to our invokeBaseQuery, but the nice part is that we are only concerned about the execution of the function itself, we don’t have to manage any state, it is all given to us. also we do not have to create all the hooks for the endpoints, because it generates them automatically based on our configuration.
we’ll define a baseApi:
import { createApi } from "@reduxjs/toolkit/query/react";
import { invokeBaseQuery } from "./base/invokeBaseQuery";
import { ALL_TAGS } from "./tags";
export const baseApi = createApi({
reducerPath: "baseApi",
baseQuery: invokeBaseQuery({ baseUrl: "/api/" }),
tagTypes: ALL_TAGS,
endpoints: () => ({}),
});
then inject the endpoints of the todo. why are we not creating API for each router? short answer: because of the tags, a lot of times when using the same api when you have multiple routes some mutations can affect other routers and you want to be able to refetch based on the entire API tags and not just the route’s tags
import { baseApi } from "./baseApi";
import type {
CreateTodoDto,
TodoDto,
UpdateTodoDto,
} from "./server-dtos/todo.dto";
import TAGS from "./tags";
export const todoApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getTodos: builder.query<TodoDto[], void>({
query: () => ({ url: "todos/get-all" }),
providesTags: [TAGS.Todos],
}),
addTodo: builder.mutation<TodoDto, CreateTodoDto>({
query: (todo) => ({ url: "todos/create", data: todo }),
invalidatesTags: [TAGS.Todos],
}),
updateTodo: builder.mutation<TodoDto, { id: string; todo: UpdateTodoDto }>({
query: ({ id, todo }) => ({ url: "todos/update", data: { id, todo } }),
invalidatesTags: [TAGS.Todos],
}),
deleteTodo: builder.mutation<boolean, { id: string }>({
query: (id) => ({ url: "todos/delete", data: id }),
invalidatesTags: [TAGS.Todos],
}),
}),
});
export const {
useGetTodosQuery,
useAddTodoMutation,
useUpdateTodoMutation,
useDeleteTodoMutation,
} = todoApi;
Now all you need to do is update the TodoContext to use the hooks instead of doing the state manipulation itself, everything else stays the same (a point for the context structure in my opinion :)).
THAT’S IT!!
one thing before you build and deploy. small configurations you need to make sure are there.
in the vite.config.ts, make sure you have base: "./" in your config, why? because your Atlassian Forge app’s static files (your React app) are not served from the root of a domain. and by using it you are telling it to use relative paths to find everything.
also, just to make sure, look at your manifest and make sure the static path matches your react app dist location. should look something like this:
resources:
- key: main
path: static/to-do-ui/dist
and make sure you have the permissions to use the storage:
permissions:
scopes:
- storage:app
now all there is left for you is to build your client using npm run build in the proper directory. and deploy the app using forge deploy and then forge install the forge install is needed only when permission scopes are changed and on the initial install.
now you should see your app under the apps section and should be able to play with it.

What I did not address?
One thing that is critical for a production ready product is a good CI/CD process. I’ve already talked about a lot of different things in this article, and didn’t want to make it too long. so if a lot of you request it, I will add another article, hopefully shorter about the CI/CD configuration.
What Now?
When I started writing this article, I didn’t think it was going to be this long. but building something in a “best practice” way means also understanding why we are doing each step, and I know it is a lot of code, and a lot of writing for a simple to do app.
Also, today in the age of AI, and the absolutely amazing Cursor IDE. When you understand why you are doing each step and know how to build app that scales and is easy to maintain, you could and should give these guidelines to the agent and let it save you at least 50% of the time.
But all in all, what I hope you got from this article is new knowledge about the opportunity of building forge apps as solo developers or as a side project. also, an example of a scalable project to help you get started quickly and avoid chasing after your own tail if your product gains success.
I wish you great luck if you decide to build your own app, if there’s a time to do it, it is now!
here’s a link to the full project on Github, hope this article was not too much to digest:
IdoBruker/Forge-ToDo-List-Example: an example of a forge app built to scale
메타데이터
- post_id
- b498a391d6ea
- slug
- how-to-build-an-atlassian-forge-app-that-scales-b498a391d6ea
- url
- https://medium.com/@ido.bruker/how-to-build-an-atlassian-forge-app-that-scales-b498a391d6ea
- canonical_url
- https://medium.com/@ido.bruker/how-to-build-an-atlassian-forge-app-that-scales-b498a391d6ea
- author_url
- https://medium.com/@ido.bruker
- status
- ok
- fetched_at
- 2026-07-09 18:09:57