What your code looks like
The Compiler puts each part of your Bubble app in a fixed place in the code. The same Bubble app always gives the same code. The examples on this page use an app with a Project data type, a Project Status option set and a Reset password page.
Where each part of your Bubble app goes
In Bubble | In your code |
|---|---|
Page |
|
Reusable element |
|
Data type |
|
Privacy rules |
|
Option set |
|
Backend workflow |
|
Backend workflow exposed as an API |
|
API Connector call |
|
Styles |
|
Folder structure
A project on Next.js looks like this:
my-app/
├── AGENTS.md Instructions for coding agents
├── TODO.md The parts that you must finish
├── SECURITY.md Where the app is stricter than Bubble
├── bubble-map.json Where each Bubble page, element and workflow is in the code
├── .env.example The keys your app needs
└── src/
├── app/ The address of each page
├── views/ Your pages
│ └── reset-password/
│ ├── ResetPasswordPage.tsx What the page shows
│ ├── ResetPasswordPage.store.ts Custom states and input values
│ ├── ResetPasswordPage.workflows.ts The page's workflows
│ └── ResetPasswordPage.actions.ts Actions that run on the server
├── components/ Your reusable elements, in the same layout as pages
├── server/
│ ├── db/schema/ Your data types
│ └── data/ Searches, with your privacy rules
├── lib/options/ Your option sets
├── workflows/ Your backend workflows, in one folder for each area
├── integrations/ Your API Connector calls
├── ui/ Buttons, inputs, icons and other Bubble elements
└── styles/ Your colors, fonts and named stylesA project on TanStack Start has src/routes/ instead of src/app/. All other folders are the same.
Files to read first
TODO.mdlists each part that the Compiler could not copy. Each item names its file. In that file, the part is a placeholder that does nothing yet. You, a developer or a coding agent must write it.SECURITY.mdlists each place where your app is stricter than your Bubble app. For example, a workflow that only an admin can run.bubble-map.jsontells you where each part of your Bubble app is in the code. Search it for a Bubble name.AGENTS.mdtells a coding agent how the project works. Give it to your agent before it changes the code.
Example: a data type
The Project data type has the fields Name, Owner, Status, Due date and Budget. It becomes a database table:
// Bubble data type "Project".
export const projects = pgTable(
"projects",
{
id: text().primaryKey(),
createdDate: timestamp({ withTimezone: true, mode: "date" }).notNull().defaultNow(),
modifiedDate: timestamp({ withTimezone: true, mode: "date" }).notNull().defaultNow(),
slug: text().unique(),
createdBy: text(),
budget: doublePrecision(),
dueDate: timestamp({ withTimezone: true, mode: "date" }),
name: text(),
owner: text().references(() => users.id, { onDelete: "set null" }),
status: text().$type<ProjectStatus>(),
},
(table) => [
index("projects_created_date_idx").on(table.createdDate),
index("projects_owner_idx").on(table.owner),
],
);
export type Project = typeof projects.$inferSelect;Each record keeps its Bubble ID. Links between records stay the same after you move your data.
ownerlinks to the User table. If you delete the user, the project stays and its owner becomes empty.An index makes a search fast. The Compiler adds an index for each field that your app searches by.
Example: an option set
The Project Status option set has the options Active and Archived:
export type ProjectStatus = "active" | "archived";
export type ProjectStatusOption = { display: string };
export const projectStatus: Readonly<Record<ProjectStatus, ProjectStatusOption>> = {
active: { display: "Active" },
archived: { display: "Archived" },
};
export const projectStatuses: readonly ProjectStatus[] = ["active", "archived"];Your code uses the option set in the same way as Bubble. For example, projectStatus.active.display is "Active", and projectStatuses is the list of all statuses.
Example: a page workflow
On the Reset password page, the workflow "When Button Confirm is clicked" resets the password, logs the user out and goes to the login page. It becomes this function:
export async function clickConfirm({ resetPasswordPage }: { resetPasswordPage: ResetPasswordPageStore }) {
await resetPassword(
resetPasswordPage.getState().passwordInput ?? "",
resetPasswordPage.getState().confirmInput ?? "",
);
await logOut();
await router().navigate({ to: "/login/$", search: { reset_success: "yes" } });
}Each action in the Bubble workflow becomes one line of code, in the same order. resetPasswordPage holds the values of the page's inputs and custom states.
Example: a backend workflow
A database trigger that cleans up a user's name each time the user changes becomes this function:
export async function updateFullName(ctx: Context, { now: user }: Trigger<User>): Promise<void> {
await change(ctx, users, user?.id, {
firstName: user?.firstName?.trim() ?? "",
lastName: user?.lastName?.trim() ?? "",
fullName: `${user?.firstName?.trim() ?? ""} ${user?.lastName?.trim() ?? ""}`.trim(),
});
}change is "Make changes to a thing" in Bubble. now is "Thing now" in the trigger.