Home | Send Feedback | Share on Bluesky |

Building a JavaScript Application with Appwrite Backend

Published: 5. September 2025  •  angular, ionic, appwrite

In this blog post, I'll guide you through building a simple JavaScript application with Appwrite as the backend.

Appwrite is an open-source Backend-as-a-Service (BaaS) platform that provides developers with a comprehensive set of APIs to build modern applications. It offers features like user authentication, database management, storage, functions, messaging, and static site hosting.

You can compare Appwrite with services like Firebase or Supabase. Like Supabase, Appwrite is open-source and can be self-hosted, whereas Firebase is a proprietary service provided by Google.

Appwrite provides client SDKs for various platforms including Web, Flutter, React Native, iOS, and Android. Appwrite also provides server-side SDKs for Node.js, PHP, Python, Ruby, Go, Swift, and Kotlin. These server SDKs can be used to access Appwrite services from your own backend or inside Appwrite Functions.


For this blog post, I created a simple to-do application that demonstrates three features of Appwrite: user authentication, database operations, and site deployment. The application allows users to register, log in, and manage their to-dos with full CRUD (Create, Read, Update, Delete) functionality. The application is built with Ionic and Angular, but the Appwrite Web SDK can be used with any JavaScript framework or even plain JavaScript.

You can find the complete code for this example on GitHub.
The application is available online at https://appwrite-todo.appwrite.network/.

Setting Up Appwrite

Appwrite can be used either as a cloud service or self-hosted. For this example, I'll show you how to use Appwrite Cloud. Appwrite offers a free tier that is sufficient for this example application, making it a good way to test if the platform fits your needs. Check out the pricing details for more information.


Cloud Setup

To get started with Appwrite Cloud:

  1. Go to https://cloud.appwrite.io and create an account
  2. Create a new project by clicking "Create project"
  3. Choose a name for your project (e.g., "todo")
  4. Choose the region where the backend will be hosted (e.g., Frankfurt)
  5. Click "Create", your project will be created with a unique Project ID

Once your project is created, you'll have access to the Appwrite Console where you can manage databases, authentication, storage, and other services.

Note the project ID and API endpoint, as you'll need these to configure the Appwrite SDK in your application.


Local Development Alternative

If you prefer to run Appwrite locally for development, you can use Docker. Check out the self-hosting guide for detailed instructions.

Database and Tables

In Appwrite, data is organized into databases and tables. A database is a container that holds multiple tables, and each table stores data in rows.

This example application stores data in a todos table within a todos-database. Here I will show you how to create the database and table using the Appwrite Console.


Database Setup

  1. Create Database:

    • Navigate to Databases in the Appwrite Console
    • Click Create database
    • Name: Todos Database
    • Database ID: todos-database (or let Appwrite generate one)
  2. Create Table:

    • Inside your database, click Create table
    • Name: Todos
    • Table ID: todos (or let Appwrite generate one)

Appwrite tables are comprised of columns. Appwrite automatically adds the following columns to each table:

The todo application requires the following columns in the todos table:

  1. title

    • Key: title
    • Type: String
    • Size: 255
    • Required: Yes
  2. description

    • Key: description
    • Type: String
    • Size: 1000
    • Required: No
  3. completed

    • Key: completed
    • Type: Boolean
    • Required: Yes
  4. dueDate

    • Key: dueDate
    • Type: DateTime
    • Required: No

Security and Permissions

Appwrite uses a robust permission system to control access to your data. Instead of API rules like other backends, Appwrite uses row-level permissions and table-level settings. By default, nobody can access the data in a table. You need to explicitly define who can read, write, update, or delete rows.

In this example, we want to ensure that users can only create todos, and they can only read, update, or delete their own todos. This is achieved through the following configuration:

  1. Go to the Settings tab of the todos table
  2. Under Permissions, add one role:
    • Role All users
    • Enable Create only. Other permissions must be disabled.
  3. Click Update
  4. Under Row Security, enable Row security to allow row-level permissions.
  5. Click Update

This is one part of the configuration, that Appwrite calls it "Private rows". This configuration allows all authenticated users to create new rows in the table. The other part is to set the permissions when creating a new todo, which is done in the client code and will be shown later.

"Private rows" ensures that users can create new rows, but they can only read, update, or delete rows that they own. Check out this documentation for other common permission setups.

Authentication Setup

Appwrite provides built-in user authentication with multiple methods including email/password, OAuth providers (Google, GitHub, etc.), and other methods. Check out the authentication documentation for a list of all supported methods.

This example application uses email and password authentication. Here is how to configure authentication in the Appwrite Console.

  1. Navigate to Auth in the left sidebar

  2. Go to Settings

  3. In Auth methods section, ensure Email/Password is enabled and all other methods are disabled

  4. Go to Auth --> Security

  5. Check all options and set them according to your security needs.

  6. Click Update whenever you make changes

Platform

Appwrite supports multiple platforms including Web, Flutter, React Native, iOS, and Android. For this example, we will use the Appwrite Web SDK to interact with the backend. We need to configure the platform settings in the Appwrite Console.

  1. In your Appwrite Console, go to Overview
  2. Click Add Platform
  3. Select Web
  4. Select Type Angular
  5. Enter Hostname localhost (for development)
  6. Click Create platform

The hostname is an important setting which is used for CORS validation. For local development localhost is sufficient. You don't have to specify the protocol (http/https) or port. For production, you need to add the actual domain name of your application.

Everything is set up on the Appwrite side. Let's now move on to the client-side implementation.

Appwrite Web SDK

The Appwrite Web SDK is a JavaScript library that provides methods to interact with Appwrite services. Add the Appwrite Web SDK to your project with npm:

npm install appwrite

In this Ionic/Angular example, I created environment configuration files to store the Appwrite project details.

export const environment: {
  appwriteEndpoint: string;
  appwriteProjectId: string;
  appwriteProjectName: string;
  production: boolean;
} = {
  appwriteEndpoint: 'https://fra.cloud.appwrite.io/v1',
  appwriteProjectId: '68b2b9730029ec3ac337',
  appwriteProjectName: 'todo',
  production: false
};

environment.ts

In the following sections, I will focus on the parts that interact with the Appwrite API. The complete source code of the application can be found on GitHub.


All Appwrite interactions are encapsulated in the AppwriteService. This service initializes the Appwrite client with the configuration from the environment file. Then it creates instances of the Account and TablesDB services to handle authentication and database operations.

export class AppwriteService {
  isLoggedIn = signal<boolean>(false);
  currentUser = signal<User | null>(null);
  authInitialized = signal<boolean>(false);

  private readonly client: Client;
  private readonly account: Account;
  private readonly tablesDB: TablesDB;

  private readonly DATABASE_ID = 'todos-database';
  private readonly TODOS_TABLE_ID = 'todos';

  constructor() {
    this.client = new Client()
      .setEndpoint(environment.appwriteEndpoint)
      .setProject(environment.appwriteProjectId);

    this.account = new Account(this.client);
    this.tablesDB = new TablesDB(this.client);

appwrite.service.ts

For a complete overview of all available methods in the Appwrite SDK, check out the documentation page.

User Registration

Before users can log in, they need to create an account. Users enter their email, password, and name in a registration form. The application then calls the register method of the AppwriteService to create a new user account.

This method calls the create method of the Account instance to register the user. ID.unique() generates a unique ID for the user. Appwrite automatically checks the password strength based on the settings in Auth --> Security. Appwrite also checks if the user already exists. If these checks fail, an error is thrown.

  async register(userData: RegisterRequest): Promise<User> {
    try {
      const user = await this.account.create({
        userId: ID.unique(),
        email: userData.email,
        password: userData.password,
        name: userData.name
      });

      await this.login({
        email: userData.email,
        password: userData.password
      });
      return this.mapAppwriteUserToUser(user);
    } catch (error) {
      throw this.handleError(error);
    }
  }

appwrite.service.ts

After successful registration, the application automatically logs the user in.

Login

The login method requires the user's email and password. It calls the createEmailPasswordSession method of the Account instance to authenticate the user and create a session. Appwrite automatically stores a session cookie in the browser when the user logs in. After successful login, the application retrieves the user details using the get method of the Account instance.

  async login(credentials: LoginRequest): Promise<AuthData> {
    try {
      const session = await this.account.createEmailPasswordSession({
        email: credentials.email,
        password: credentials.password
      });
      const user = await this.account.get();
      const userData = this.mapAppwriteUserToUser(user);
      this.isLoggedIn.set(true);
      this.currentUser.set(userData);
      return {
        token: session.$id,
        record: userData
      };
    } catch (error) {
      throw this.handleError(error);
    }
  }

appwrite.service.ts

Check Authentication State

The account.get() can be used to check if the user is already logged in when the application starts. The demo application calls the following method at startup to initialize the authentication state. If the user is already logged in, the application skips the login screen and shows the to-do list directly.

get() throws an error if the user is not logged in. In this case the application shows the login screen.

  async refreshAuth(): Promise<AuthData | null> {
    try {
      const user = await this.account.get();
      const userData = this.mapAppwriteUserToUser(user);
      this.isLoggedIn.set(true);
      this.currentUser.set(userData);
      const session = await this.account.getSession({ sessionId: 'current' });
      return {
        token: session.$id,
        record: userData
      };
    } catch {
      await this.logout();
      return null;
    } finally {
      this.authInitialized.set(true);
    }
  }

appwrite.service.ts

Logout

To log out the user, the application calls the account.deleteSession method. This method expects a session ID to log out. The special value current refers to the current session.

  async logout(): Promise<void> {
    try {
      await this.account.deleteSession({ sessionId: 'current' });
    } catch {}
    this.isLoggedIn.set(false);
    this.currentUser.set(null);
  }

appwrite.service.ts

You can also implement session management features like listing all sessions (account.listSessions()) and delete specific sessions (account.deleteSession({ sessionId })) that you might have on other devices.

Password Reset

Appwrite provides a built-in password recovery function. When a user forgets their password, they can request a password reset by providing their email address. The account.createRecovery method sends a password recovery email to the user. It requires the user's email and a URL where the user will be redirected after clicking the recovery link in the email.

  async requestPasswordReset(email: string): Promise<void> {
    try {
      const resetUrl = `${window.location.origin}/password-reset`;
      await this.account.createRecovery({
        email,
        url: resetUrl
      });
    } catch (error) {
      throw this.handleError(error);
    }
  }

appwrite.service.ts

In this example the recovery URL points to a password reset page in the application. You need to implement this page to allow the user to enter a new password and complete the recovery process.

The password recovery link in the email contains two query parameters: userId and secret. The userId identifies the user, and the secret is a unique token that verifies the password reset request. You need to extract these parameters from the URL and pass them to the updateRecovery method along with the new password.

  async updateRecovery(
    userId: string,
    secret: string,
    password: string
  ): Promise<void> {
    try {
      await this.account.updateRecovery({
        userId,
        secret,
        password
      });
    } catch (error) {
      throw this.handleError(error);
    }
  }

appwrite.service.ts

If this operation is successful, the user's password is updated, and they can log in with the new password.

In this example I use the standard email template provided by Appwrite. But for a production application, you might want to customize the email templates to match your branding and provide a better user experience. Appwrite allows you to customize the email templates for various authentication-related emails including verification, and password recovery emails. You find the email templates in the Appwrite Console under Auth --> Templates.

CRUD Operations

Accessing and managing todos in Appwrite is straightforward using the TablesDB API.

Read

The tablesDB.listRows() method retrieves todos from the todos table. The method accepts various query parameters to filter, sort, and paginate the results. Note that we don't have to specify the user ID in the query to filter todos by user. This is automatically handled by Appwrite's row-level permissions. Because we configured "Private rows", each user can only see their own todos.

The listRows() method requires the database ID and table ID to identify the table from which to retrieve rows. You find these IDs in the Appwrite Console.

The getTodos() method retrieves all todos for the current user sorted by creation date in descending order. If the user wants to hide completed todos, an additional filter is applied. Filtering, pagination, and sorting are done using the Query class. Check out the Query documentation for more information.

  async getTodos(hideCompleted: boolean): Promise<Todo[]> {
    try {
      const queries = [Query.orderDesc('$createdAt')];

      if (hideCompleted) {
        queries.push(Query.equal('completed', false));
      }

      const response = await this.tablesDB.listRows({
        databaseId: this.DATABASE_ID,
        tableId: this.TODOS_TABLE_ID,
        queries: queries
      });

      return response.rows.map(row => this.mapRowToTodo(row));
    } catch (error) {
      throw this.handleError(error);
    }
  }

appwrite.service.ts


When the user wants to edit an existing todo the application calls the following method. The tablesDB.getRow() from the SDK retrieves a single row by its ID. Like in the list operation, we need to specify the database and table IDs.

  async getTodo(todoId: string): Promise<Todo> {
    try {
      const document = await this.tablesDB.getRow({
        databaseId: this.DATABASE_ID,
        tableId: this.TODOS_TABLE_ID,
        rowId: todoId
      });

      return this.mapRowToTodo(document);
    } catch (error) {
      throw this.handleError(error);
    }
  }

appwrite.service.ts


Create

Creating a new todo involves filling out a form with title, description, and due date. The application then calls the tablesDB.createRow() method to insert a new row into the todos table. This method requires the database and table IDs, a unique row ID that is generated here using ID.unique(), the row data, and the permissions for the new row.

Here we see the second part of the "Private rows" setup. The permissions are set to allow only the creator (the current user) to read, update, and delete the todo. The user ID is retrieved from the current user. Because on the server side we only allow authenticated users to create rows, we need to set the permissions for read, update, and delete operations. If we don't set these permissions, nobody (not even the creator) would be able to read, update, or delete the entry.

  async createTodo(todoData: Omit<CreateTodoRequest, 'user'>): Promise<Todo> {
    try {
      const data = {
        title: todoData.title,
        description: todoData.description ?? '',
        completed: todoData.completed ?? false,
        dueDate: todoData.dueDate ?? null
      };

      const currentUserId = this.currentUser()?.id;
      if (!currentUserId) {
        return Promise.reject(new Error('User not authenticated'));
      }

      const document = await this.tablesDB.createRow({
        databaseId: this.DATABASE_ID,
        tableId: this.TODOS_TABLE_ID,
        rowId: ID.unique(),
        data: data,
        permissions: [
          Permission.read(Role.user(currentUserId)),
          Permission.update(Role.user(currentUserId)),
          Permission.delete(Role.user(currentUserId))
        ]
      });

      return this.mapRowToTodo(document);
    } catch (error) {
      throw this.handleError(error);
    }
  }

appwrite.service.ts


Update

To edit an existing todo, the user selects a todo from the list and modifies the data in a form. The application then calls the tablesDB.updateRow() method to update the row in the todos table. Like all other table operations, we need to specify the database and table IDs, the row ID of the todo to update, and the updated data.

  async updateTodo(todoId: string, data: UpdateTodoRequest): Promise<Todo> {
    try {
      const updateData: any = {};

      if (data.title !== undefined) updateData.title = data.title;
      if (data.description !== undefined)
        updateData.description = data.description;
      if (data.completed !== undefined) updateData.completed = data.completed;
      if (data.dueDate !== undefined) updateData.dueDate = data.dueDate;

      const document = await this.tablesDB.updateRow({
        databaseId: this.DATABASE_ID,
        tableId: this.TODOS_TABLE_ID,
        rowId: todoId,
        data: updateData
      });

      return this.mapRowToTodo(document);
    } catch (error) {
      throw this.handleError(error);
    }
  }

appwrite.service.ts

Appwrite automatically updates the $updatedAt timestamp.


Delete

Deleting a todo is straightforward using the tablesDB.deleteRow() method of the TablesDB API. It requires the database ID, table ID, and the row ID of the todo to delete.

  async deleteTodo(todoId: string): Promise<void> {
    try {
      await this.tablesDB.deleteRow({
        databaseId: this.DATABASE_ID,
        tableId: this.TODOS_TABLE_ID,
        rowId: todoId
      });
    } catch (error) {
      throw this.handleError(error);
    }
  }

appwrite.service.ts

Thanks to the row-level permissions set during todo creation, users can only read, update and delete their own todos. Appwrite automatically enforces these permissions.

Deployment

Appwrite provides a feature called Sites that allows you to deploy static websites and single-page applications (SPAs) directly from the Appwrite Console. This feature is useful for deploying frontend applications that interact with the Appwrite backend.

You can attach a GitHub repository to your Appwrite project. For this example, I attached the GitHub repository of the to-do application to the Appwrite project and configured automatic deployments. Appwrite lets you specify the build command and the output directory. For Angular applications, you have the option to use Server-Side Rendering (SSR) or a static build, which is what I used for this example.

Appwrite supports many different frameworks including Angular, React, Vue, Svelte, Next.js, Nuxt.js, and more. Appwrite Sites can either expose the site with their default domain (e.g., your-project.your-region.appwrite.network) or you can use your own custom domain. Sites can also manage multiple versions of your site, allowing you to roll back to a previous version if needed. Via the web console, you can see the logs and basic usage statistics.

At the time of writing (September 2025), the sites features is tagged as "Early Access" so there might be some changes in the future. For this example it worked well and the deployment was straightforward.

Other Features

In this blog post we have covered three features of Appwrite: authentication, database operations, and deployment. Appwrite provides other features that can be useful for building modern applications:

Conclusion

In this blog post, I demonstrated how to build a simple todo application using Appwrite as the backend service. Appwrite provides a robust, scalable backend solution that handles authentication, database operations, and deployment with ease and without the need to write custom backend code.

Appwrite's open-source nature allows you to self-host the platform if needed, giving you full control over your backend infrastructure. The hosted Appwrite Cloud service is a convenient option for quickly getting started. The free tier allows you to explore the platform without incurring costs.

To learn more about Appwrite's capabilities, check out the official documentation and explore the various example projects built by the community.