Skip to content

How to build a multi-tenant SaaS app with Appwrite Teams_

Step-by-step tutorial for building a multi-tenant SaaS app with Appwrite Teams for tenant isolation and DocumentsDB for schemaless data, using a real forms app.

Every multi-tenant SaaS product has the same bug waiting in it. Somewhere in the codebase a query forgets the WHERE tenant_id = ? clause, and one customer sees another customer's data. The usual defense is discipline. Every table gets a tenant column, middleware injects the filter, and code review catches the misses. That holds up until one new query ships without the filter.

The alternative is to let Appwrite refuse cross-tenant requests, so the application never has to remember a filter. Formwrite is built that way. It is a multi-tenant forms builder in the style of Typeform or Google Forms. Every workspace is an Appwrite Team, each workspace's data lives in its own DocumentsDB collections, and no application code filters by tenant. The app is live and the source is on GitHub.

Multi-tenancy with Appwrite Teams in one paragraph

A tenant is a team. When a user creates a workspace, the app creates an Appwrite Team with that user as its owner, then provisions a forms collection, a submissions collection, and a storage bucket whose permissions are granted to Role.team(teamId) and nothing else. Every dashboard request runs through an Appwrite client bound to the signed-in user's session, so Appwrite's permission engine decides what that user can read or change. A session from one workspace that touches another workspace's collection gets a 401 from Appwrite, not an empty result from a forgotten filter.

The sections below cover how the two Appwrite clients are set up, how provisioning and role checks work, and why a document database fits the data.

What Formwrite does

Formwrite is a forms product with the features you would expect from one:

  • Workspaces with owners, editors, and viewers, each with their own forms, responses, and uploads
  • A form builder with thirteen question types, sections, multi-step layouts, and per-form theming with a logo
  • Public form pages that respondents fill out without signing in, with file uploads and an embed mode
  • A responses view with triage states, filtering, cursor pagination, and CSV export
  • Passwordless sign-in with email one-time passwords

The front-end is a SvelteKit app rendered on the server. There is no client-side Appwrite SDK at all. Every Appwrite call happens in a load function, a form action, or a request handler on the server, which is what makes the two-client model below possible.

Note: The code in this post focuses on the tenancy and data model. The form builder UI, theming, and validation are in the repository linked at the end.

Prerequisites

You need an Appwrite Cloud project with three things configured.

Email OTP. In the project's Auth settings, enable the Email OTP sign-in method. Formwrite uses no other auth method.

A DocumentsDB database. On the Databases page, click Create database, choose DocumentsDB as the type, and select a tier. Give it the ID formwrite. Every tenant's collections will live inside this one database.

Scopes for the server. The server needs these scopes: sessions.write, users.read, users.write, teams.read, teams.write, the documentsdb scopes for databases, collections, documents, and indexes, buckets.read, buckets.write, files.read, and files.write. You grant them to the site itself once the app runs on Appwrite Sites, and Sites then hands the server an ephemeral API key carrying those scopes on every request. The deployment section covers both steps. You never create an API key in this tutorial.

Project setup

Create a SvelteKit project with TypeScript, then install the Node server SDK and SvelteKit's Node adapter:

Bash
npx sv create formwrite
cd formwrite
npm i node-appwrite
npm i -D @sveltejs/adapter-node

sv create sets up the project with adapter-auto, which can't build the Node server that Appwrite Sites runs for SSR. In svelte.config.js, change the adapter import and leave the rest of the file as it is:

JavaScript
import adapter from '@sveltejs/adapter-node';

Add a .env file at the root of the project:

Bash
APPWRITE_ENDPOINT=https://<REGION>.cloud.appwrite.io/v1
APPWRITE_PROJECT_ID=<YOUR_PROJECT_ID>
APPWRITE_DATABASE_ID=formwrite

None of these are prefixed with PUBLIC_, so SvelteKit refuses to ship them to the browser.

Two Appwrite clients

The most important file in the project is src/lib/server/appwrite.ts. It exports two factories: an admin client that carries an API key resolved from the current request, and a session client that carries a user's session secret.

TypeScript
import { env } from '$env/dynamic/private';
import { Account, Client, DocumentsDB, Storage, Teams, Users } from 'node-appwrite';

export const DATABASE_ID = env.APPWRITE_DATABASE_ID || 'formwrite';
export const SESSION_COOKIE = `a_session_${env.APPWRITE_PROJECT_ID}`;

function baseClient(): Client {
    return new Client()
        .setEndpoint(env.APPWRITE_ENDPOINT)
        .setProject(env.APPWRITE_PROJECT_ID);
}

const EPHEMERAL_KEY_HEADER = 'x-appwrite-key';

// Appwrite Sites sends an ephemeral API key with every SSR request
function apiKeyFor(request: Request): string {
    const key = request.headers.get(EPHEMERAL_KEY_HEADER);
    if (!key) throw new Error(`Missing ${EPHEMERAL_KEY_HEADER} header: this app expects to run on Appwrite Sites`);
    return key;
}

export function createAdminClient(request: Request) {
    const client = baseClient().setKey(apiKeyFor(request));
    return {
        account: new Account(client),
        teams: new Teams(client),
        users: new Users(client),
        db: new DocumentsDB(client),
        storage: new Storage(client)
    };
}

export function createSessionClient(session: string) {
    const client = baseClient().setSession(session);
    return {
        account: new Account(client),
        teams: new Teams(client),
        db: new DocumentsDB(client),
        storage: new Storage(client)
    };
}

export type AdminServices = ReturnType<typeof createAdminClient>;
export type SessionServices = ReturnType<typeof createSessionClient>;

The admin client takes the request because that is where its key comes from. On Appwrite Sites, every server-side request carries an ephemeral API key on the x-appwrite-key header, minted for that request with the scopes granted to the site. The factory throws without the header instead of reaching for a stored secret, and no route handles the key directly.

The header only exists when Appwrite serves the site, so npm run dev renders the pages but cannot sign anyone in. That is why the project goes on Appwrite Sites right after the session hook, before any feature code. The Formwrite repository adds an environment-variable fallback for local development.

Anything a user does in the dashboard goes through the session client. The admin client handles only the operations Appwrite cannot authorize on a user's behalf, and each section below points them out. Appwrite's own SSR guide describes the same split.

Attaching the session to every request

SvelteKit's hooks.server.ts runs before every request. It reads the session cookie, builds a session client, and stores both the user and the client on event.locals so load functions and actions can use them.

TypeScript
import type { Handle } from '@sveltejs/kit';
import { SESSION_COOKIE, createSessionClient } from '$lib/server/appwrite';

export const handle: Handle = async ({ event, resolve }) => {
    event.locals.user = null;
    event.locals.appwrite = null;

    const session = event.cookies.get(SESSION_COOKIE);
    if (session) {
        const services = createSessionClient(session);
        try {
            event.locals.user = await services.account.get();
            event.locals.appwrite = services;
        } catch {
            event.cookies.delete(SESSION_COOKIE, { path: '/' });
        }
    }

    return resolve(event);
};

A small helper turns "no user" into a redirect, so protected routes stay one line long:

TypeScript
export function requireUser(event: RequestEvent) {
    const { user, appwrite } = event.locals;
    if (!user || !appwrite) {
        redirect(303, `/login?next=${encodeURIComponent(event.url.pathname)}`);
    }
    return { user, appwrite };
}

Deploying to Appwrite Sites without a stored API key

Every step after this one starts with a signed-in user, and signing in needs the admin client's key, so deploy the project to Appwrite Sites now. Create a site from your Git repository in the Console so every push redeploys it. The .env file stays out of Git, so add APPWRITE_ENDPOINT, APPWRITE_PROJECT_ID, and APPWRITE_DATABASE_ID as site environment variables with the same values.

The site stores no API key variable. Appwrite Sites generates an ephemeral API key for every SSR request and delivers it on the x-appwrite-key header. The key carries only the scopes granted to the site and expires shortly after the site's timeout, so there is nothing long-lived to leak, rotate, or forget in a variable list. Form actions and API routes get one too, so every admin-client call in the sections below receives its key the same way.

A new site starts with no scopes. You grant them in the same Console you used for the database and the auth method. Open the site, go to the Settings tab, open the Build section, and in the Scopes card select the scopes listed in the prerequisites: sessions.write, users.read, users.write, teams.read, teams.write, documentsdb.read, documentsdb.write, documentsdb.collections.read, documentsdb.collections.write, documentsdb.documents.read, documentsdb.documents.write, documentsdb.indexes.read, documentsdb.indexes.write, buckets.read, buckets.write, files.read, and files.write. Click Update, and the next request to the site carries a key with exactly those scopes.

Grant only what the admin client needs. A missing scope shows up as a 401 on the first call that uses it, so sign-in fails right away without sessions.write. That is easier to spot than a key that can do everything.

If you are migrating a site that already stores an API key variable, deploy the code that reads the header first, then delete the variable once that deployment is live.

Signing in with email OTP

Sending a one-time code and exchanging it for a session are the first two admin-client operations. There is no user session yet, so there is nothing else to use.

The login action creates an email token. If the address has never been seen, Appwrite creates the account; otherwise the userId is ignored. The returned userId is stashed in a short-lived cookie so the verify step can find it.

TypeScript
export const actions: Actions = {
    default: async ({ request, cookies }) => {
        const email = String((await request.formData()).get('email') ?? '').trim().toLowerCase();
        const { account } = createAdminClient(request);
        const token = await account.createEmailToken({ userId: ID.unique(), email, phrase: true });
        setPendingOtp(cookies, { userId: token.userId, email, phrase: token.phrase, expire: token.expire });
        redirect(303, '/login/verify');
    }
};

The phrase: true option makes Appwrite include a security phrase in the email. The app shows the same phrase on the verify page so the user can confirm the email is the one they requested.

The verify action exchanges the code for a session and writes the session secret into the a_session_<PROJECT_ID> cookie:

TypeScript
export const actions: Actions = {
    default: async ({ request, cookies }) => {
        const pending = readPendingOtp(cookies);
        const code = String((await request.formData()).get('code') ?? '');
        const { account } = createAdminClient(request);
        const session = await account.createSession({ userId: pending.userId, secret: code });
        cookies.set(SESSION_COOKIE, session.secret, {
            path: '/',
            httpOnly: true,
            sameSite: 'lax',
            secure: true,
            expires: new Date(session.expire)
        });
        clearPendingOtp(cookies);
        redirect(303, '/app');
    }
};

From here on, every request carries a session, and hooks.server.ts builds a session client for it.

Modeling a tenant as a team

A workspace in Formwrite is an Appwrite Team plus three resources whose IDs derive from the team ID:

ResourceIDReadCreate, update, delete
Forms collectionforms_<teamId>team membersowners and editors
Submissions collectionsubmissions_<teamId>team membersserver only (delete: owners and editors)
Uploads bucket<teamId>team membersserver only (delete: owners and editors)

Deriving the IDs from the team ID means there is no lookup table mapping tenants to resources. Given a teamId, the app knows exactly where that tenant's data lives:

TypeScript
export const formsCollection = (teamId: string) => `forms_${teamId}`;
export const submissionsCollection = (teamId: string) => `submissions_${teamId}`;
export const bucketId = (teamId: string) => teamId;

Provisioning a workspace

Creating a workspace is a two-step operation, and the split between clients matters.

The team is created with the session client, because the user who creates a team through their own session becomes its owner automatically. The collections and bucket are then created with the admin client, because creating collections and buckets is a server operation. If any step fails, the function tears down what it created, so a half-provisioned tenant never exists.

TypeScript
import { DocumentsDBIndexType, ID, Permission, Role } from 'node-appwrite';

export async function provisionWorkspace(
    session: SessionServices,
    admin: AdminServices,
    name: string
) {
    const team = await session.teams.create({ teamId: ID.unique(), name, roles: ['owner'] });
    const teamId = team.$id;

    const members = Role.team(teamId);
    const owners = Role.team(teamId, 'owner');
    const editors = Role.team(teamId, 'editor');

    try {
        await admin.db.createCollection({
            databaseId: DATABASE_ID,
            collectionId: formsCollection(teamId),
            name: `Forms - ${name}`,
            documentSecurity: false,
            permissions: [
                Permission.read(members),
                Permission.create(owners),
                Permission.update(owners),
                Permission.delete(owners),
                Permission.create(editors),
                Permission.update(editors),
                Permission.delete(editors)
            ]
        });

        await admin.db.createCollection({
            databaseId: DATABASE_ID,
            collectionId: submissionsCollection(teamId),
            name: `Submissions - ${name}`,
            documentSecurity: false,
            // Submissions are written only by the server on behalf of anonymous respondents.
            permissions: [Permission.read(members), Permission.delete(owners), Permission.delete(editors)]
        });

        await admin.db.createIndex({
            databaseId: DATABASE_ID,
            collectionId: submissionsCollection(teamId),
            key: 'idx_form',
            type: DocumentsDBIndexType.Key,
            attributes: ['formId']
        });

        await admin.storage.createBucket({
            bucketId: bucketId(teamId),
            name: `Uploads - ${name}`,
            fileSecurity: false,
            permissions: [Permission.read(members), Permission.delete(owners), Permission.delete(editors)],
            maximumFileSize: 10 * 1024 * 1024,
            encryption: true,
            antivirus: true
        });
    } catch (err) {
        await destroyWorkspace(admin, teamId).catch(() => undefined);
        throw err;
    }

    return team;
}

Role.team(teamId) with no role grants access to every member, whatever their role. Role.team(teamId, 'editor') grants access only to members who hold that role. Viewers are members with no extra grants, so they can read and nothing else. Appwrite's permissions reference covers the full set of roles.

documentSecurity: false means permissions are set once, at the collection level, and apply to every document. There is no per-document permission list to get wrong when a form is created. Each collection belongs to a single tenant, so collection-level permissions are as fine-grained as the app needs.

The submissions collection grants no create or update to anyone. Respondents are anonymous, so the server writes their submissions with the API key. Members can read and delete, and that is all.

Why a collection per tenant instead of a tenant field

The alternative design is one forms collection for all tenants, each document tagged with a teamId, and document-level permissions granting Role.team(teamId) on each one. Appwrite's multi-tenancy guide shows that pattern and it works well.

Formwrite uses a collection per tenant for two reasons. First, the isolation boundary is a resource Appwrite manages rather than a field the app has to set correctly on every write. A bug in the app cannot put a form in the wrong tenant because the collection ID comes from the URL and the permissions came from provisioning. Second, deleting a tenant is a deleteCollection call rather than a paginated sweep of documents.

A database per tenant goes one step further. A DocumentsDB database can run on dedicated compute that you resize and replicate on its own, so one large customer's traffic never competes with anyone else's. Choose it when some tenants are far larger than the rest, or when a customer needs its data on infrastructure it does not share. Formwrite's workspaces are small and similar in size, and a collection already carries its own permissions, so all tenants share one database and each gets its own collections.

Resolving the current workspace

Every route under /app/[team] needs to know two things: is this user a member of this team, and what is their role. The membership check uses the session client, because teams.get only succeeds for members. The role lookup uses the admin client, because membership listings made through a user session can hide user IDs depending on the project's membership privacy settings.

TypeScript
export async function loadWorkspace(
    session: SessionServices,
    admin: AdminServices,
    userId: string,
    teamId: string
) {
    const team = await session.teams.get({ teamId });
    const memberships = await admin.users.listMemberships({
        userId,
        queries: [Query.equal('teamId', teamId), Query.limit(1)]
    });
    const membership = memberships.memberships.find((m) => m.teamId === teamId);
    if (!membership || !membership.confirm) throw new Error('Not a member of this workspace');
    return { team, membership, role: roleOf(membership) };
}

export function roleOf(membership: Models.Membership): WorkspaceRole {
    if (membership.roles.includes('owner')) return 'owner';
    if (membership.roles.includes('editor')) return 'editor';
    return 'viewer';
}

The layout for /app/[team] calls this once and turns any failure into a 404, so a non-member cannot even confirm the workspace exists:

TypeScript
export const load: LayoutServerLoad = async (event) => {
    const { user, appwrite } = requireUser(event);
    try {
        const { team, role } = await loadWorkspace(appwrite, createAdminClient(event.request), user.$id, event.params.team);
        return {
            workspace: { id: team.$id, name: team.name },
            role,
            canEdit: role === 'owner' || role === 'editor',
            canManage: role === 'owner'
        };
    } catch {
        error(404, 'Workspace not found');
    }
};

The canEdit and canManage flags only decide what buttons to render. They are not the security boundary. If a viewer forges a request to create a form, the session client sends it to Appwrite, and Appwrite refuses it because the viewer has no create permission on the collection.

Where DocumentsDB fits

A form has a title, a status, a theme, and a list of fields. Each field has a type, and the type decides what else it carries. A dropdown has options. A rating has max. A section has neither and never collects an answer. Thirteen field types, each with a different shape, all in one ordered list.

TypeScript
export interface FormField {
    id: string;
    type: FieldType;
    label: string;
    required: boolean;
    placeholder?: string;
    helpText?: string;
    options?: string[];
    max?: number;
}

export interface FormData {
    title: string;
    description: string;
    status: 'draft' | 'published';
    fields: FormField[];
    successMessage: string;
    createdBy: string;
    theme?: FormTheme;
}

A submission is even less regular. It is a map from field IDs to answers, and an answer is a string, an array of strings, an uploaded file reference, or null, depending on the field.

TypeScript
export interface SubmissionData {
    formId: string;
    answers: Record<string, string | string[] | UploadedFile | null>;
    userAgent: string;
    status?: 'new' | 'read' | 'flagged';
    embed?: boolean;
}

In a relational schema this becomes a forms table, a fields table with a nullable column for every type-specific property, a submissions table, and an answers table with one row per field per submission and a polymorphic value column. Reading one form with its responses is four joins. Adding a field type is a migration.

In DocumentsDB the form is one document and the submission is one document. The fields array and the answers map are stored as the JSON the app already works with. Formwrite gained the theme object on forms and the status triage field on submissions partway through development. Older documents do not have them, and the code treats a missing value as the default, so nothing needed a migration.

Creating and updating forms

With the session client, creating a form is one call. The collection ID comes from the route parameter, and Appwrite checks that the caller holds create on that collection, which only owners and editors do.

TypeScript
create: async (event) => {
    const { user, appwrite } = requireUser(event);
    const teamId = event.params.team;

    const created = await appwrite.db.createDocument<FormDocument>({
        databaseId: DATABASE_ID,
        collectionId: formsCollection(teamId),
        documentId: ID.unique(),
        data: {
            title: 'Untitled form',
            description: '',
            status: 'draft',
            fields: [
                { id: 'q_name', type: 'text', label: 'Your name', required: true },
                { id: 'q_email', type: 'email', label: 'Email address', required: true }
            ],
            successMessage: 'Thanks! Your response has been recorded.',
            createdBy: user.$id
        }
    });
    redirect(303, `/app/${teamId}/forms/${created.$id}`);
}

Saving the builder replaces the whole fields array in one updateDocument call. The server parses and sanitizes the posted JSON first, capping the number of fields, the label lengths, and the option counts, because schemaless storage does not mean unvalidated storage.

TypeScript
await appwrite.db.updateDocument<FormDocument>({
    databaseId: DATABASE_ID,
    collectionId: formsCollection(teamId),
    documentId: formId,
    data: { title, description, fields: parseFields(raw) }
});

Querying responses

Listing a form's responses is a filtered, ordered, paginated query against the tenant's submissions collection. The formId index created during provisioning is what makes the Query.equal fast.

TypeScript
const queries = [
    Query.equal('formId', formId),
    Query.orderDesc('$createdAt'),
    Query.limit(50)
];
if (status) queries.push(Query.equal('status', status));
if (after) queries.push(Query.cursorAfter(after));

const page = await appwrite.db.listDocuments<SubmissionDocument>({
    databaseId: DATABASE_ID,
    collectionId: submissionsCollection(teamId),
    queries
});

Cursor pagination with Query.cursorAfter is stable even as new submissions arrive at the top, which matters for a responses view that refreshes itself. The dashboard also uses Query.select(['$id']) with Query.limit(1) when it only needs a count, so it does not pull full documents to read the total field.

Accepting anonymous submissions

Public form pages live at /f/[team]/[formId]. Respondents have no session, so the admin client does every read and write on these pages, and this is the path that needs the most care. The API key bypasses permissions, so the app has to guarantee that a request for one tenant cannot touch another.

The guarantee comes from addressing. The form is always loaded from formsCollection(teamId) using both IDs from the URL, and the request is passed along so the admin client can pick up its key. A form ID on its own never resolves to anything, so guessing a form ID from another workspace returns a 404 from the wrong collection.

TypeScript
async function loadPublishedForm(request: Request, teamId: string, formId: string) {
    const admin = createAdminClient(request);
    const form = await admin.db.getDocument<FormDocument>({
        databaseId: DATABASE_ID,
        collectionId: formsCollection(teamId),
        documentId: formId
    });
    if (form.status !== 'published') error(404, 'This form is not accepting responses');
    return { admin, form };
}

The submission action validates the posted answers against that form's field definitions, uploads any files into the tenant's bucket, and writes one submission document into the tenant's submissions collection. If the document write fails, the action deletes the uploaded files so nothing is orphaned.

TypeScript
const submission: SubmissionData = {
    formId: form.$id,
    answers: stored,
    userAgent: (request.headers.get('user-agent') ?? '').slice(0, 300),
    status: 'new'
};
await admin.db.createDocument<SubmissionDocument>({
    databaseId: DATABASE_ID,
    collectionId: submissionsCollection(params.team),
    documentId: ID.unique(),
    data: submission
});

The submissions collection grants read to team members, so once the document exists, the workspace's owners, editors, and viewers can see it through their own sessions with no further work.

Roles in practice

Formwrite has three roles, and the enforcement for each is split between Appwrite and the app.

Appwrite enforces the data permissions. Viewers can read forms and responses. Editors and owners can create, update, and delete forms, and delete responses. Every one of these is a collection or bucket permission that Appwrite checks on the session client. The app does not check the role before these calls.

Appwrite enforces team management. Only owners can rename a team, change a member's roles, or remove a member. These run on the session client and Appwrite rejects them for non-owners.

TypeScript
// Only succeeds for owners; Appwrite enforces it.
await appwrite.teams.updateMembership({ teamId, membershipId, roles: [role] });
await appwrite.teams.deleteMembership({ teamId, membershipId });

The app enforces role checks before admin-client calls. Two owner-only operations need the API key: adding a member by email, and destroying a workspace. Marking a response as read or flagged also needs the key, because members hold no update permission on submissions. Before each of these, the action calls loadWorkspace and checks the role itself.

TypeScript
status: async (event) => {
    const { user, appwrite } = requireUser(event);
    const { team: teamId, formId } = event.params;
    const data = await event.request.formData();
    const submissionId = String(data.get('id') ?? '');
    const status = data.get('status');
    // isSubmissionStatus narrows to 'new' | 'read' | 'flagged'
    if (!submissionId || !isSubmissionStatus(status)) {
        return fail(400, { message: 'Invalid request' });
    }

    const admin = createAdminClient(event.request);
    const workspace = await loadWorkspace(appwrite, admin, user.$id, teamId);
    if (workspace.role === 'viewer') return fail(403, { message: 'Your role cannot update responses' });

    const sub = await admin.db.getDocument<SubmissionDocument>({
        databaseId: DATABASE_ID,
        collectionId: submissionsCollection(teamId),
        documentId: submissionId
    });
    if (sub.formId !== formId) return fail(404, { message: 'Response not found' });

    await admin.db.updateDocument({
        databaseId: DATABASE_ID,
        collectionId: submissionsCollection(teamId),
        documentId: submissionId,
        data: { status }
    });
    return { updated: submissionId };
}

This is the pattern for any admin-client call in a multi-tenant app: prove membership with the session, check the role in code, then address the resource by the tenant's team ID.

Adding members

Owners add members by email from the workspace settings page. Formwrite uses the server-side flow from the team invites docs, where creating a membership with the API key joins the member immediately with no invitation email to accept. The new member signs in with the same email OTP flow and the workspace is already in their list.

Because the membership is created with the API key, the action has to prove the caller is an owner first. The same loadWorkspace check from the triage action does that, and the action accepts only editor or viewer, so this path can never create another owner.

TypeScript
invite: async (event) => {
    const { user, appwrite } = requireUser(event);
    const teamId = event.params.team;
    const admin = createAdminClient(event.request);

    const workspace = await loadWorkspace(appwrite, admin, user.$id, teamId).catch(() => null);
    if (!workspace || workspace.role !== 'owner') {
        return fail(403, { invite: 'Only owners can add members' });
    }

    const data = await event.request.formData();
    const email = String(data.get('email') ?? '').trim().toLowerCase();
    const role = data.get('role');
    if (role !== 'editor' && role !== 'viewer') {
        return fail(400, { invite: 'Choose a role of editor or viewer', email });
    }

    await admin.teams.createMembership({ teamId, email, roles: [role] });
    return { invited: email };
}

If you would rather have an accept step, use the client-side invite flow through the session client instead, and Appwrite will send the invitation email for you.

Verifying isolation

The Formwrite repository includes an end-to-end script that mints two test users, drives every route as a browser would, and then checks the boundaries:

  • A non-member requesting /app/<teamId> gets a 404
  • A non-member requesting a tenant's responses or files gets a 404
  • After being added as a viewer, that user sees the forms but no create button
  • A viewer posting to the create action gets a 400, because Appwrite refused the write
  • A viewer posting to the triage action gets a 403, because the app's role check refused it
  • A viewer trying to add a member gets a 403

The first two checks are the ones that matter most, and neither depends on any filtering code in the app. The session client sends the request, and Appwrite says no.

Trade-offs in this design

Provisioning is a multi-step operation, and each step can fail independently. The rollback in provisionWorkspace and the tolerant destroyWorkspace exist because of this. If you add a fourth resource per tenant, add it to both.

Every admin-client code path is a place where isolation depends on the developer. There are exactly six such paths in Formwrite, and each takes teamId from the URL and uses it to build the collection or bucket ID. Keep that list short and keep it in one file.

Collection-per-tenant makes cross-tenant analytics harder. If you ever need "total submissions across all workspaces", you iterate collections rather than run one query. For a forms product that is fine. For a product whose value is cross-tenant aggregation, tag documents with a tenant field in a shared collection instead.

Building your own multi-tenant app on Appwrite

The pieces that made Formwrite work are all general. One team per tenant, resources granted to Role.team, a session client for every user action, and an admin client only where Appwrite cannot act on the user's behalf. The admin client always takes the tenant ID from the request and checks membership first. On Appwrite Sites that admin client runs on an ephemeral API key that arrives with each request, so the deployment stores no secret at all. DocumentsDB fits wherever your users decide the shape of the data rather than you, which describes forms, surveys, CMS content, and most user-generated structures.

Try the live app at formwrite.appwrite.network and read the full source, including the builder, theming, and the end-to-end isolation test, in the Formwrite repository. To build your own, start with these docs:

Read next

Ready to build?_