Especialista em backend reativo Convex: design de schema, funções TypeScript, assinaturas em tempo real, autenticação, armazenamento de arquivos, agendamento e deploy.
Scanned 9/8/2026
Install to Claude Code
npx -y skills add artubss/SKILLS-CLAUDE-CODE --skill convex --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Convex?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/artubss-convex)More formats (shields.io, HTML) on the badges page.
---
name: convex
description: "Especialista em backend reativo Convex: design de schema, funções TypeScript, assinaturas em tempo real, autenticação, armazenamento de arquivos, agendamento e deploy."
risk: safe
source: "https://docs.convex.dev"
date_added: "2026-02-27"
---
# Convex
Você é um especialista em Convex — a plataforma backend reativa e open-source onde queries são código TypeScript. Possui conhecimento profundo de design de schema, autoria de funções (queries, mutations, actions), assinaturas de dados em tempo real, autenticação, armazenamento de arquivos, agendamento e fluxos de deploy em React, Next.js, Angular, Vue, Svelte, React Native e ambientes server-side.
## Quando Usar
- Use ao construir um novo projeto com Convex como backend
- Use ao adicionar Convex a uma aplicação React, Next.js, Angular, Vue, Svelte ou React Native existente
- Use ao projetar schemas para um banco de dados relacional de documentos Convex
- Use ao escrever ou debugar funções Convex (queries, mutations, actions)
- Use ao implementar padrões de dados em tempo real/reativos
- Use ao configurar autenticação com Convex Auth ou provedores terceiros (Clerk, Auth0, etc.)
- Use ao trabalhar com armazenamento de arquivos, funções agendadas ou cron jobs no Convex
- Use ao fazer deploy ou gerenciar projetos Convex
## Conceitos Principais
Convex é um **banco de dados relacional de documentos** com um backend totalmente gerenciado. Diferenciais principais:
- **Reativo por padrão**: Queries re-executam automaticamente e enviam atualizações a todos os clientes conectados quando dados subjacentes mudam
- **Primeiro TypeScript**: Toda a lógica backend — queries, mutations, actions, schemas — é escrita em TypeScript
- **Transações ACID**: Isolamento serializável com controle de concorrência otimista
- **Sem infraestrutura para gerenciar**: Serverless, escala automaticamente, zero config
- **Segurança de tipo ponta a ponta**: Tipos fluem de schema → funções backend → hooks do cliente
### Tipos de Função
| Tipo | Propósito | Pode Ler BD | Pode Escrever BD | Pode Chamar APIs Externas | Cache/Reativo |
| :-------------- | :------------------------- | :------------ | :---------------- | :------------------------ | :------------ |
| **Query** | Ler dados | ✅ | ❌ | ❌ | ✅ |
| **Mutation** | Escrever dados | ✅ | ✅ | ❌ | ❌ |
| **Action** | Efeitos colaterais | via `runQuery` | via `runMutation` | ✅ | ❌ |
| **HTTP Action** | Webhooks/endpoints custom | via `runQuery` | via `runMutation` | ✅ | ❌ |
## Configuração do Projeto
### Novo Projeto (Next.js)
```bash
npx create-next-app@latest my-app
cd my-app && npm install convex
npx convex dev
```
### Adicionar a Projeto Existente
```bash
npm install convex
npx convex dev
```
O comando `npx convex dev`:
1. Solicita login (GitHub)
2. Cria um projeto e deployment
3. Gera a pasta `convex/` para funções backend
4. Sincroniza funções com seu deployment de dev em tempo real
5. Cria `.env.local` com `CONVEX_DEPLOYMENT` e `NEXT_PUBLIC_CONVEX_URL`
### Estrutura de Pastas
```
my-app/
├── convex/
│ ├── _generated/ ← Auto-gerado (NÃO EDITE)
│ │ ├── api.d.ts
│ │ ├── dataModel.d.ts
│ │ └── server.d.ts
│ ├── schema.ts ← Definição de schema do banco
│ ├── tasks.ts ← Funções query/mutation
│ └── http.ts ← HTTP actions (opcional)
├── .env.local ← CONVEX_DEPLOYMENT, NEXT_PUBLIC_CONVEX_URL
└── convex.json ← Config do projeto (opcional)
```
## Design de Schema
Defina seu schema em `convex/schema.ts` usando a biblioteca validator:
```typescript
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
users: defineTable({
name: v.string(),
email: v.string(),
avatarUrl: v.optional(v.string()),
tokenIdentifier: v.string(),
})
.index("by_token", ["tokenIdentifier"])
.index("by_email", ["email"]),
messages: defineTable({
authorId: v.id("users"),
channelId: v.id("channels"),
body: v.string(),
attachmentId: v.optional(v.id("_storage")),
})
.index("by_channel", ["channelId"])
.searchIndex("search_body", { searchField: "body" }),
channels: defineTable({
name: v.string(),
description: v.optional(v.string()),
isPrivate: v.boolean(),
}),
});
```
### Tipos de Validator
| Validator | Tipo TypeScript | Notas |
| :-------------------------------- | :-------------------- | :--------------------------------------------- |
| `v.string()` | `string` | |
| `v.number()` | `number` | IEEE 754 float |
| `v.bigint()` | `bigint` | |
| `v.boolean()` | `boolean` | |
| `v.null()` | `null` | |
| `v.id("tableName")` | `Id<"tableName">` | Referência de documento |
| `v.array(v.string())` | `string[]` | |
| `v.object({...})` | `{...}` | Objetos aninhados |
| `v.optional(v.string())` | `string \| undefined` | |
| `v.union(v.string(), v.number())` | `string \| number` | |
| `v.literal("active")` | `"active"` | Tipos literais |
| `v.bytes()` | `ArrayBuffer` | Dados binários |
| `v.float64()` | `number` | Float 64-bit explícito (usado em índices de vetor) |
| `v.any()` | `any` | Escape hatch |
### Indexes
```typescript
// Index de campo único
defineTable({ email: v.string() }).index("by_email", ["email"]);
// Index composto (ordem importa para range queries)
defineTable({
orgId: v.string(),
createdAt: v.number(),
}).index("by_org_and_date", ["orgId", "createdAt"]);
// Index full-text search
defineTable({ body: v.string(), channelId: v.id("channels") }).searchIndex(
"search_body",
{
searchField: "body",
filterFields: ["channelId"],
},
);
// Index vector search (para IA/embeddings)
defineTable({ embedding: v.array(v.float64()), text: v.string() }).vectorIndex(
"by_embedding",
{
vectorField: "embedding",
dimensions: 1536,
},
);
```
## Escrevendo Funções
### Queries (Ler Dados)
Queries são reativas — clientes recebem automaticamente atualizações quando dados mudam.
```typescript
import { query } from "./_generated/server";
import { v } from "convex/values";
// Query simples — listar todas as tarefas
export const list = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("tasks").collect();
},
});
// Query com argumentos e filtragem
export const getByChannel = query({
args: { channelId: v.id("channels") },
handler: async (ctx, args) => {
return await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.order("desc")
.take(50);
},
});
// Query com verificação de auth
export const getMyProfile = query({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return null;
return await ctx.db
.query("users")
.withIndex("by_token", (q) =>
q.eq("tokenIdentifier", identity.tokenIdentifier),
)
.unique();
},
});
```
### Queries com Paginação
Use paginação baseada em cursor para listas ou infinite scroll.
```typescript
import { query } from "./_generated/server";
import { paginationOptsValidator } from "convex/server";
export const listPaginated = query({
args: {
paginationOpts: paginationOptsValidator
},
handler: async (ctx, args) => {
return await ctx.db
.query("messages")
.order("desc")
.paginate(args.paginationOpts);
},
});
```
### Mutations (Escrever Dados)
Mutations executam como transações ACID com isolamento serializável.
```typescript
import { mutation } from "./_generated/server";
import { v } from "convex/values";
// Inserir um documento
export const create = mutation({
args: { text: v.string(), isCompleted: v.boolean() },
handler: async (ctx, args) => {
const taskId = await ctx.db.insert("tasks", {
text: args.text,
isCompleted: args.isCompleted,
});
return taskId;
},
});
// Atualizar um documento
export const update = mutation({
args: { id: v.id("tasks"), isCompleted: v.boolean() },
handler: async (ctx, args) => {
await ctx.db.patch(args.id, { isCompleted: args.isCompleted });
},
});
// Deletar um documento
export const remove = mutation({
args: { id: v.id("tasks") },
handler: async (ctx, args) => {
await ctx.db.delete(args.id);
},
});
// Transação multi-documento (automaticamente atômica)
export const transferCredits = mutation({
args: {
fromUserId: v.id("users"),
toUserId: v.id("users"),
amount: v.number(),
},
handler: async (ctx, args) => {
const fromUser = await ctx.db.get(args.fromUserId);
const toUser = await ctx.db.get(args.toUserId);
if (!fromUser || !toUser) throw new Error("Usuário não encontrado");
if (fromUser.credits < args.amount) throw new Error("Créditos insuficientes");
await ctx.db.patch(args.fromUserId, {
credits: fromUser.credits - args.amount,
});
await ctx.db.patch(args.toUserId, {
credits: toUser.credits + args.amount,
});
},
});
```
### Actions (APIs Externas e Efeitos Colaterais)
Actions podem chamar serviços terceiros, mas não conseguem acessar diretamente o banco — devem usar `ctx.runQuery` e `ctx.runMutation`.
```typescript
import { action } from "./_generated/server";
import { v } from "convex/values";
import { api } from "./_generated/api";
export const sendEmail = action({
args: { to: v.string(), subject: v.string(), body: v.string() },
handler: async (ctx, args) => {
// Chamar API externa
const response = await fetch("https://api.sendgrid.com/v3/mail/send", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SENDGRID_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
personalizations: [{ to: [{ email: args.to }] }],
from: { email: "noreply@example.com" },
subject: args.subject,
content: [{ type: "text/plain", value: args.body }],
}),
});
if (!response.ok) throw new Error("Falha ao enviar email");
// Escrever resultado de volta ao BD via mutation
await ctx.runMutation(api.emails.recordSent, {
to: args.to,
subject: args.subject,
sentAt: Date.now(),
});
},
});
// Gerar embeddings de IA
export const generateEmbedding = action({
args: { text: v.string(), documentId: v.id("documents") },
handler: async (ctx, args) => {
const response = await fetch("https://api.openai.com/v1/embeddings", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "text-embedding-3-small",
input: args.text,
}),
});
const { data } = await response.json();
await ctx.runMutation(api.documents.saveEmbedding, {
documentId: args.documentId,
embedding: data[0].embedding,
});
},
});
```
### HTTP Actions (Webhooks)
```typescript
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { api } from "./_generated/api";
const http = httpRouter();
http.route({
path: "/webhooks/stripe",
method: "POST",
handler: httpAction(async (ctx, request) => {
const body = await request.text();
const signature = request.headers.get("stripe-signature");
// Verificar assinatura do webhook aqui...
const event = JSON.parse(body);
await ctx.runMutation(api.payments.handleWebhook, { event });
return new Response("OK", { status: 200 });
}),
});
export default http;
```
## Integração no Cliente
### React / Next.js
```typescript
// app/ConvexClientProvider.tsx
"use client";
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { ReactNode } from "react";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function ConvexClientProvider({ children }: { children: ReactNode }) {
return <ConvexProvider client={convex}>{children}</ConvexProvider>;
}
```
```typescript
// app/layout.tsx — envolver children
import { ConvexClientProvider } from "./ConvexClientProvider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="pt-BR">
<body>
<ConvexClientProvider>{children}</ConvexClientProvider>
</body>
</html>
);
}
```
```typescript
// Componente usando hooks Convex
"use client";
import { useQuery, useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";
export function TaskList() {
// Query reativa — auto-atualiza quando dados mudam
const tasks = useQuery(api.tasks.list);
const addTask = useMutation(api.tasks.create);
const toggleTask = useMutation(api.tasks.update);
if (tasks === undefined) return <p>Carregando...</p>;
return (
<div>
{tasks.map((task) => (
<div key={task._id}>
<input
type="checkbox"
checked={task.isCompleted}
onChange={() =>
toggleTask({ id: task._id, isCompleted: !task.isCompleted })
}
/>
{task.text}
</div>
))}
<button onClick={() => addTask({ text: "Nova tarefa", isCompleted: false })}>
Adicionar Tarefa
</button>
</div>
);
}
```
```typescript
// Componente usando Queries com Paginação
"use client";
import { usePaginatedQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
export function MessageLog() {
const { results, status, loadMore } = usePaginatedQuery(
api.messages.listPaginated,
{}, // args
{ initialNumItems: 20 }
);
return (
<div>
{results.map((msg) => (
<div key={msg._id}>{msg.body}</div>
))}
{status === "LoadingFirstPage" && <p>Carregando...</p>}
{status === "CanLoadMore" && (
<button onClick={() => loadMore(20)}>Carregar Mais</button>
)}
</div>
);
}
```
### Com Auth (Convex Auth First-Party)
Convex fornece uma biblioteca de autenticação robusta e nativa (`@convex-dev/auth`) com Magic Links, Senhas e 80+ provedores OAuth sem precisar de um serviço terceiro.
```typescript
// app/ConvexClientProvider.tsx
"use client";
import { ConvexAuthProvider } from "@convex-dev/auth/react";
import { ConvexReactClient } from "convex/react";
import { ReactNode } from "react";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function ConvexClientProvider({ children }: { children: ReactNode }) {
return (
<ConvexAuthProvider client={convex}>
{children}
</ConvexAuthProvider>
);
}
```
```typescript
// Sign in no lado do cliente
import { useAuthActions } from "@convex-dev/auth/react";
export function Login() {
const { signIn } = useAuthActions();
return <button onClick={() => signIn("github")}>Entrar com GitHub</button>;
}
```
### Com Auth (Exemplo Clerk Terceiro)
Se preferir uma solução hosted terceira como Clerk:
```typescript
// app/ConvexClientProvider.tsx
"use client";
import { ConvexProviderWithClerk } from "convex/react-clerk";
import { ClerkProvider, useAuth } from "@clerk/nextjs";
import { ConvexReactClient } from "convex/react";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function ConvexClientProvider({ children }: { children: ReactNode }) {
return (
<ClerkProvider publishableKey={process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY!}>
<ConvexProviderWithClerk client={convex} useAuth={useAuth}>
{children}
</ConvexProviderWithClerk>
</ClerkProvider>
);
}
```
### Com Auth (Componente Better Auth)
Convex também tem um componente community (`@convex-dev/better-auth`) que integra a biblioteca Better Auth direto no backend Convex. Atualmente em **early alpha**.
```bash
npm install better-auth @convex-dev/better-auth
npx convex env set BETTER_AUTH_SECRET your-secret-here
npx convex env set SITE_URL http://localhost:3000
```
Better Auth fornece email/senha, logins sociais, autenticação de dois fatores e gerenciamento de sessões — tudo rodando dentro de funções Convex em vez de um servidor auth externo.
### Integração Angular
Convex não tem uma biblioteca client oficial para Angular, mas apps Angular podem usar o pacote core `convex` diretamente com Angular's Dependency Injection e Signals.
```typescript
// services/convex.service.ts
import { Injectable, signal, effect, OnDestroy } from "@angular/core";
import { ConvexClient } from "convex/browser";
import { api } from "../../convex/_generated/api";
import { FunctionReturnType } from "convex/server";
@Injectable({ providedIn: "root" })
export class ConvexService implements OnDestroy {
private client = new ConvexClient(environment.convexUrl);
// Signal reativo — atualiza automaticamente quando dados mudam
tasks = signal<FunctionReturnType<typeof api.tasks.list> | undefined>(
undefined,
);
constructor() {
// Inscrever em uma query reativa
this.client.onUpdate(api.tasks.list, {}, (result) => {
this.tasks.set(result);
});
}
async addTask(text: string) {
await this.client.mutation(api.tasks.create, {
text,
isCompleted: false,
});
}
ngOnDestroy() {
this.client.close();
}
}
```
```typescript
// Uso do componente
import { Component, inject } from "@angular/core";
import { ConvexService } from "./services/convex.service";
@Component({
selector: "app-task-list",
template: `
@if (convex.tasks(); as tasks) {
@for (task of tasks; track task._id) {
<div>{{ task.text }}</div>
}
} @else {
<p>Carregando...</p>
}
<button (click)="convex.addTask('Nova tarefa')">Adicionar Tarefa</button>
`,
})
export class TaskListComponent {
convex = inject(ConvexService);
}
```
> **Nota:** A biblioteca community `@robmanganelly/ngx-convex` fornece uma experiência mais Angular-nativa com hooks tipo-React adaptados para Angular DI e Signals.
## Agendamento e Cron Jobs
### Funções Agendadas Únicas
```typescript
import { mutation } from "./_generated/server";
import { api } from "./_generated/api";
export const sendReminder = mutation({
args: { userId: v.id("users"), message: v.string(), delayMs: v.number() },
handler: async (ctx, args) => {
await ctx.scheduler.runAfter(args.delayMs, api.notifications.send, {
userId: args.userId,
message: args.message,
});
},
});
```
### Cron Jobs
```typescript
// convex/crons.ts
import { cronJobs } from "convex/server";
import { api } from "./_generated/api";
const crons = cronJobs();
crons.interval("limpar logs antigos", { hours: 24 }, api.logs.clearOld);
crons.cron(
"digestão semanal",
"0 9 * * 1", // Toda segunda às 9 AM
api.emails.sendWeeklyDigest,
);
export default crons;
```
## Armazenamento de Arquivos
```typescript
// Gerar URL de upload (mutation)
export const generateUploadUrl = mutation({
args: {},
handler: async (ctx) => {
return await ctx.storage.generateUploadUrl();
},
});
// Salvar referência de arquivo após upload (mutation)
export const saveFile = mutation({
args: { storageId: v.id("_storage"), name: v.string() },
handler: async (ctx, args) => {
await ctx.db.insert("files", {
storageId: args.storageId,
name: args.name,
});
},
});
// Obter URL para servir um arquivo (query)
export const getFileUrl = query({
args: { storageId: v.id("_storage") },
handler: async (ctx, args) => {
return await ctx.storage.getUrl(args.storageId);
},
});
```
## Variáveis de Ambiente
```bash
# Definir variáveis de ambiente para seu deployment
npx convex env set OPENAI_API_KEY sk-...
npx convex env set SENDGRID_API_KEY SG...
# Listar variáveis de env atuais
npx convex env list
# Remover uma variável de env
npx convex env unset OPENAI_API_KEY
```
Acessar em actions (NÃO em queries ou mutations):
```typescript
// Apenas disponível em actions
const apiKey = process.env.OPENAI_API_KEY;
```
## Deploy e CLI
```bash
# Desenvolvimento (observa mudanças, sincroniza com deployment de dev)
npx convex dev
# Deploy para produção
npx convex deploy
# Importar dados
npx convex import --table tasks data.jsonl
# Exportar dados
npx convex export --path ./backup
# Abrir dashboard Convex
npx convex dashboard
# Executar uma função via CLI
npx convex run tasks:list
# Ver logs
npx convex logs
```
## Melhores Práticas
- ✅ Defina schemas — adiciona segurança de tipo em toda a sua pilha
- ✅ Use indexes para queries — evita full table scans
- ✅ Use indexes compostos com filtros de igualdade primeiro, filter de range por último
- ✅ Confie no determinismo nativo — `Date.now()` e `Math.random()` são 100% seguros em queries e mutations porque Convex congela o tempo no início de toda execução de função!
- ✅ Use `v.id("tableName")` para referências de documentos em vez de strings simples
- ✅ Use actions para chamadas de API externa (nunca chame APIs externas de queries ou mutations)
- ✅ Use `ctx.runQuery` / `ctx.runMutation` de actions — nunca acesse `ctx.db` diretamente em actions
- ✅ Adicione validadores de argumentos a todas as funções — eles aplicam segurança de tipo em runtime
- ✅ Retorne `null` quando um documento não for encontrado em vez de lançar erro, a menos que ausência seja excepcional
- ✅ Prefira `withIndex` ao `.filter()` para performance de query
## Anti-Patterns para Evitar
1. **❌ Chamadas de API externa em queries/mutations**: Apenas actions podem chamar serviços externos. Queries e mutations rodam no motor de transação Convex.
2. **❌ Fazer trabalho CPU-bound pesado em mutations**: Mutations bloqueiam commits do banco; transfira processamento pesado para actions.
3. **❌ Usar `.collect()` em tabelas grandes sem limites**: Carrega todos os documentos na memória. Use `.take(N)` ou `.paginate()`.
4. **❌ Pular definição de schema**: Sem schema você perde segurança de tipo ponta a ponta, a principal vantagem Convex.
5. **❌ Usar `.filter()` em vez de indexes**: `.filter()` faz full table scan. Defina um index e use `.withIndex()`.
6. **❌ Armazenar blobs grandes em documentos**: Use armazenamento de arquivos Convex (`_storage`) para arquivos; mantenha documentos enxutos.
7. **❌ Cadeias circulares `runQuery`/`runMutation`**: Actions chamando mutations que agendando actions podem criar loops infinitos.
## Armadilhas Comuns
- **Problema:** "Query retorna `undefined` na primeira renderização"
**Solução:** Isso é esperado — queries Convex são async. Verifique `undefined` antes de renderizar (significa carregando, não vazio).
- **Problema:** "Mutation lança `Document not found`"
**Solução:** Documentos podem ter sido deletados entre sua leitura e escrita devido a controle de concorrência otimista. Releia dentro da mutation.
- **Problema:** "`process.env` é undefined em query/mutation"
**Solução:** Variáveis de ambiente estão acessíveis apenas em **actions** (não queries ou mutations) porque queries/mutations rodam no motor de transação determinístico.
- **Problema:** "Handler de função é muito lento"
**Solução:** Adicione indexes para seus padrões de query. Use `withIndex()` em vez de `.filter()`. Para operações complexas, quebre em mutations menores.
- **Problema:** "Schema push falha com dados existentes"
**Solução:** Convex valida dados existentes contra novos schemas. Ou migre documentos existentes primeiro, ou use `v.optional()` para novos campos.
## Limitações
- Queries e mutations não conseguem chamar APIs HTTP externas (use actions em vez disso)
- Sem SQL bruto — você trabalha com a API do query builder Convex
- Variáveis de ambiente disponíveis apenas em actions, não em queries ou mutations
- Limite de tamanho de documento de 1MB
- Limites de tempo de execução de função se aplicam
- Sem server-side rendering de dados Convex sem padrões SSR específicos (use preloading)
- Schemas são aplicados em write-time; mudar schemas requer migração de dados para documentos existentes
## Skills Relacionadas
- `@firebase` — BaaS alternativo com Firestore (comparar: Convex é TypeScript-first com transações ACID)
- `@supabase-automation` — Alternativa com backend PostgreSQL (comparar: Convex é relacional de documentos com reatividade nativa)
- `@prisma-expert` — ORM para bancos tradicionais (Convex substitui ORM e banco)
- `@react-patterns` — Padrões frontend que funcionam bem com hooks Convex React
- `@nextjs-app-router` — Padrões de integração Next.js App Router
- `@authentication-oauth` — Padrões de auth (Convex suporta Clerk, Auth0, Convex Auth)
- `@stripe` — Integração de pagamento via actions Convex e webhooks HTTP
## Recursos
- [Documentação Oficial](https://docs.convex.dev)
- [Convex Stack (Blog)](https://Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!