Convex – an alternative to Firebase and Supabase

Hi all!

In this article I will talk about Convex – backend platform.

Convex is currently available natively on

  • JavaScript/TypeScript (React, React Native, Next, Node)

  • Python

  • Rust

Due to the fact that Node is supported, it is most likely possible to use it on different frameworks and libraries

History of appearance

In April 2022, several developers from Dropbox decided to develop their scalable backend service. At Dropbox, they were moving billions of gigabytes of user files from the Amazon cloud to an internal system they built. In a funding round with the participation of Netlify and Heo, they were able to raise $25 million to develop the startup. (Source)

Founders of Convex

Founders of Convex

What is Convex?

Convex is a backend building platform. It includes:

  • Server Functions

  • ACID Database

  • Vector Search

  • Scheduling and crons

  • File Storage

Everything in Convex works in real time. Convenient interfaces for working with Convex in React have also been written.

Comparison with relational databases

Source – article from Convex developers

Query language

Convex database queries are written entirely in Typescript, eliminating the need to build SQL queries or use cumbersome ORM systems.
In addition, indexed queries against the Convex database require you to explicitly specify which index should be used for the query. This is different from query planners in DBMSs (such as PostgreSQL), where the DBMS tries to automatically decide which index to use, which sometimes causes unexpected results.

Caching

To implement caching in relational databases, additional data storage is used, for example, Redis or Memcached. For the developer in this case, it is necessary to implement data synchronization between the relational database and the cache store, which is prone to errors.
In Convex, caching is completely automatic. It caches the results of your query functions and recalculates the values ​​when the underlying data changes.

Real time data

To obtain real-time data in relational databases, you can use two options:

  • Regularly polling the server for changes. The problem is that frequent polling has a negative impact on the server and can lead to database or server overload.

  • Distribution of updates. This requires the use of clusters or built-in Pub/Sub capabilities, as well as a WebSocket connection for data transfer. Implementing real-time updates is a huge task, many web developers don't even try to implement it.

In Convex, all queries are reactive by default, meaning they transmit data in real time. To do this, they use a system based on a WebSocket connection. Convex understands which query functions depend on which data. When data changes, Convex re-runs the necessary functions and transmits the resulting data.

main idea

The main idea of ​​the Convex developers is to make it easier to build scalable applications without using complex and cumbersome tools such as PostgreSQL, Redis, and so on.

Convex takes care of servers, caching and reactivity, allowing developers to focus on the product.

Comparison with Firebase Cloud Firestore

Source – article by Convex developers
Convex and Firebase Cloud Firestore are very similar in functionality:

  • Both platforms store data in a specific format

  • Notify about data changes in real time

  • Does not require web developers to manage server infrastructure

But there are also many differences in the internals of how Convex and Firestore work

Documents or functions?

Firestore uses documents. The client interacts with its data by downloading documents from the database. Convex uses functions that return data when necessary. You cannot directly change or retrieve information from the database.

This additional level (function) solves two problems:

Waterfall of sequential queries

It occurs when several consecutive requests to the server are required to load all the data. This degrades page loading performance.

For example, this is how you can get all users who sent a message to Firestore

const querySnapshot = await getDocs(collection(db, "messages"));
const userSnapshots = await Promise.all(
  querySnapshot.docs().map(async messageSnapshot => {
    return await getDoc(docSnapshot.data().creator);
  })
);

The fact is that this code will make several sequential requests to the Firestore database. Each request will go separately.

In Convex, you can implement a function that will internally receive the entire message and all users who sent the message.

export const list = query(async (ctx) => {
  const messages = await ctx.db.query("messages").collect();
  return Promise.all(
    messages.map(async (message) => {
      const user = await ctx.db.get(message.user);
      return {
        author: user!.name,
        ...message,
      };
    }),
  );
});

As a result, when using Convex, we will send one request to the server, which will return us the data already collected in the version we need.

In Firestore, you can avoid the query waterfall using cloud functions, but cloud functions do not support real-time data transfer.

Business logic

Functions in Convex serve as a natural method for placing business logic. As a developer, you can share logic between all your platforms.

Reactivity

Convex was designed for use with React, so convenient React hooks have been developed that allow you to load and edit data.

Firestore was not designed for use with React. It has a JavaScript SDK, but data transfer in React applications is left to the discretion of the developer, which creates problems and differences between different applications, implemented using Firestore.

Also, as described above, Convex functions are reactive by default, unlike Firestore, where not all methods receive data in real time.

Comparison with Supabase

Source – article by Convex developers
Both Convex and Supabase:

  • Based on ACID compliant data stores.

  • Integration with most modern TS/JS frameworks.

  • Provide a database editor user interface in the browser.

  • Comes with file storage and its own vector database.

But there are a few key differences:

Reactivity

Supabase offers real-time capabilities through its Realtime server containers, which you can deploy and manage simultaneously with your installation. Supabase real-time data is not delivered consistently over the same channel, limiting consistency guarantees.

Database Internals

Supabase uses PostgreSQL as backing storage, Convex uses its own transaction document storage, and the record log is stored in AWS RDS. In addition, Convex has built-in caching.

PostgreSQL

Since Supabase uses PostgreSQL, the developer takes responsibility for SQL scripts and data storage. Convex takes full responsibility for storing and transmitting data.

Authorization

Supabase uses its own authentication provider for authorization. Convex integrates with a standard set of external authentication providers.

Usage

Convex has a cool documentation

Quick start React

First, let's create a basic project using Vite or Next

yarn create vite@latest

After this we need to install the package convex

yarn add convex

After which we can start the Convex server. When you launch Convex for the first time, you will be asked to log in to their system and also to create a project in Convex.

npx convex dev

After the above command, Convex creates the file .env and in the root folder a new folder convex. In folder convex You will need to write the functions and schema of your database.

To create a schema, you need to create a file convex/schema.ts. For typing, a special validator is used – v (more on this below)

import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  tasks: defineTable({
    text: v.string(),
    isCompleted: v.boolean(),
  }),
});

After this you can write certain functions, for example query. They need to be described in a file inside the folder convex. The file name affects where we will pull this function from in the future

// convex/tasks.ts 
import { query } from "./_generated/server";

export const get = query({
  args: {},
  handler: async (ctx) => {
    return await ctx.db.query("tasks").collect();
  },
});

When the Convex server is running (npx convex dev), it parses the folder convex and pushes the changes to the server itself. When receiving files, Convex uses esbuild to combine files. When a function is called, Convex runs the functions using V8

For Convex to work, you also need to create a Convex client and wrap the entire application in a provider

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
import { ConvexProvider, ConvexReactClient } from "convex/react";
// Создаем клиента Convex
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string);

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    // Оборачиваем провайдером
    <ConvexProvider client={convex}>
      <App />
    </ConvexProvider>
  </React.StrictMode>,
);

Next we can use our functions in the application

import "./App.css";
import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";

function App() { 
// получаем все задачи с помощью функции tasks.get
  const tasks = useQuery(api.tasks.get);
  return (
    <div className="App">
      {tasks?.map(({ _id, text }) => <div key={_id}>{text}</div>)}
    </div>
  );
}

export default App;

Scheme

Create a schema

// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  messages: defineTable({
    body: v.string(),
    user: v.id("users"),
  }),
  users: defineTable({
    name: v.string(),
    tokenIdentifier: v.string(),
  }).index("by_token", ["tokenIdentifier"]),
});

Data types

  • v.id(tableName) – used for foreign keys

  • v.null()

  • v.int64() – in JavaScript it will be BigInt

  • v.number() – floating point number

  • v.boolean()

  • v.string()

  • v.bytes() – in JavaScript it will be ArrayBuffer

  • v.array(values)

  • v.object({property: value})

Additional validators

  • v.optional() – optional field, if nothing comes into such a field, then the cell in the database will be empty

  • v.union() – combining several elements

  • v.literal() – single values, for example v.literal("admin")

  • v.any()

Indexes

To create an index, you need to defineTable indicate the name of the index and the fields to which the index applies

// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  messages: defineTable({
    channel: v.id("channels"),
    body: v.string(),
    user: v.id("users"),
  })
    .index("by_channel", ["channel"])
    .index("by_channel_user", ["channel", "user"]),
});

You can use the index like this:

const messages = await ctx.db
  .query("messages")
  .withIndex("by_channel_user", (q) => q.eq("channel", channel))
  .collect();

Table

The default table has two fields

  • _id – unique identifier of the inserted document. Generated in format Base32, using the Crockford alphabet. Stores 14 bytes of random numbers and 2 bytes that are responsible for the timestamp

  • _creationTime – time in milliseconds in UNIX format for creating the document. 64-bit floating point numbers are used for storage.

It is possible to use generic types on the client

const [userId, setUserId] = useState<Id<"users">>();
export const getPlayerAdmin = (game: Doc<"games">) => {};

Working with data

Receiving one document

You can get one document using the command .get(id)

import { query } from "./_generated/server";
import { v } from "convex/values";

export const getTask = query({
  args: { taskId: v.id("tasks") },
  handler: async (ctx, args) => {
    const task = await ctx.db.get(args.taskId);
  },
});

Receiving multiple documents

To do this you need to use .query().collect()

import { query } from "./_generated/server";

export const listTasks = query({
  handler: async (ctx) => {
    const tasks = await ctx.db.query("tasks").collect();
  },
});

There is also support for filters and sorting – read more here

Adding Data

For this purpose it is used .insert(tableName, data)

import { mutation } from "./_generated/server";
import { v } from "convex/values";

export const createTask = mutation({
  args: { text: v.string() },
  handler: async (ctx, args) => {
    const taskId = await ctx.db.insert("tasks", { text: args.text });
  },
});

There is also support for data updating, replacement and deletion – read more here

Functions

Query

These are functions that are designed to receive data. Created using query

import { query } from "./_generated/server";
import { v } from "convex/values";
export const getTaskList = query({
  args: { taskListId: v.id("taskLists") },
  handler: async (ctx, args) => {
    const tasks = await ctx.db
      .query("tasks")
      .filter((q) => q.eq(q.field("taskListId"), args.taskListId))
      .order("desc")
      .take(100);
    return tasks;
  },
});

Mutations

Used to change data. Created using mutation

import { mutation } from "./_generated/server";
import { v } from "convex/values";
export const createTask = mutation({
  args: { text: v.string() },
  handler: async (ctx, args) => {
    const newTaskId = await ctx.db.insert("tasks", { text: args.text });
    return newTaskId;
  },
});

Internal

This is very similar to Mutations And Queries functions, but which cannot be called from the client. They are used to call inside other functions

import { internalMutation } from "./_generated/server";
import { v } from "convex/values";

export const markPlanAsProfessional = internalMutation({
  args: { planId: v.id("plans") },
  handler: async (ctx, args) => {
    await ctx.db.patch(args.planId, { planType: "professional" });
  },
});

Usage:

import { action } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";

export const upgrade = action({
  args: {
    planId: v.id("plans"),
  },
  handler: async (ctx, args) => {
    const response = await fetch("https://...");
    if (response.ok) {
      await ctx.runMutation(internal.plans.markPlanAsProfessional, {
        planId: args.planId,
      });
    }
  },
});

Additional material

Convex also implements Actions, HTTPActions, Scheduled Functions, Cron Jobs, Authentication, File Storage, Full Text Search, Vector Search and so on

You can read in detail about all the possibilities Here

Dashboard

Convex has a convenient dashboard where you can view everything related to it:

Main menu

Main menu

Database tables

Database tables

Functions

Functions

Logs

Logs

Deploy

Deploying a Convex web application is as simple and convenient as possible. Vercel needs to change the team build on npx convex deploy --cmd 'npm run build'and also generate CONVEX_DEPLOY_KEY in Convex Dashboard. You can read more Here

Limitations and Free Plan

Restrictions can be seen in the command settings. Only two teams can be free. Each team can have 5 projects, with a total of 10 projects available for free.

Convex Team Usage

Convex Team Usage

Example project on Convex

As an example of a project on Convex, you can consider my pet project – Monopoly online, which I developed in a team with a friend. Source code available – GitHub

Bottom line

Convex is a good tool for web developers that allows you to store and process certain data. For React developers, it is much more convenient than Firebase or a full-fledged database. Of course, this solution does not provide all the features and takes full control over the server and database, so Convex is not suitable for large projects, but for small projects it is ideal.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *