Pular para o conteúdo
🛵 ROTEIRO 3

Como criar um app de delivery com pedidos ao vivo

Açaiteria, lanchonete ou restaurante: o cliente monta o pedido pelo cardápio, a cozinha vê o pedido chegar na tela na hora, e o cliente acompanha o status sem recarregar. Neste roteiro o preço é calculado no banco e o realtime entrega cada pedido só para quem pode ver.

  • ⏱️ cerca de 2 horas
  • 🧩 Lovable, Cursor, Claude, Next.js ou Expo
  • 💸 começa no Grátis
  • ✅ testado em 21/10/2026

Testado de verdade. Todo o SQL e todo arquivo src/lib desta página rodaram contra o SuperDB em produção em 21/10/2026, num projeto de teste que foi apagado depois: 21 verificações, 21 passaram. O realtime foi testado com três conexões abertas ao mesmo tempo (a cozinha e dois clientes). As saídas estão no repositório do SuperDB, em docs/marketing/aprenda/provas/delivery.txt.

O problema do negócio

O pedido chega pelo WhatsApp, alguém copia num papel, a cozinha perde a ordem e o cliente pergunta de cinco em cinco minutos se já saiu. Os aplicativos grandes cobram comissão por pedido. Um app próprio, simples, resolve o básico:

  • cardápio aberto, sem login, com o que está disponível agora;
  • pedido com o total calculado pelo banco, a partir do cardápio;
  • a cozinha vê o pedido aparecer, anda com o status, e o cliente vê o status mudar ao vivo.

O modelo de dados

TabelaO que guardaQuem lêQuem altera
cardapionome, descrição, categoria, preço, disponívelqualquer visitante (os disponíveis)a loja
equipe_lojalogins da cozinha e do balcãocada um a própria linhavocê, pelo SQL Editor
pedidosnúmero, cliente, endereço, status, totalo cliente os dele; a loja todoso cliente só cancela (se ainda recebido); a loja anda com o status
itens_pedidoo item, a quantidade e o preço do momentoquem vê o pedidosó a função fazer_pedido

Passo 1: crie a conta e o projeto

Crie a conta e um projeto. Guarde o slug e a anon key.

Criar conta grátis

Passo 2: rode o SQL

No SQL Editor, cole e rode. Pode rodar de novo sem medo.

superdb/delivery.sql
-- Pedidos para açaiteria, lanchonete ou restaurante: cardápio público,
-- pedido do cliente e painel da cozinha ao vivo. Pode rodar de novo sem medo.

create table if not exists equipe_loja (
  user_id uuid primary key,
  nome text not null
);

create table if not exists cardapio (
  id bigint generated always as identity primary key,
  nome text not null,
  descricao text,
  categoria text not null default 'geral',
  preco numeric(10,2) not null check (preco > 0),
  disponivel boolean not null default true
);

create table if not exists pedidos (
  id uuid primary key default gen_random_uuid(),
  numero bigint generated always as identity,
  cliente_id uuid not null default auth.uid(),
  cliente_nome text not null check (length(cliente_nome) between 1 and 120),
  endereco text not null check (length(endereco) between 5 and 300),
  observacao text check (length(observacao) <= 500),
  status text not null default 'recebido'
    check (status in ('recebido', 'preparando', 'saiu', 'entregue', 'cancelado')),
  total numeric(10,2) not null default 0,
  criado_em timestamptz not null default now(),
  atualizado_em timestamptz not null default now()
);
create index if not exists pedidos_por_cliente on pedidos (cliente_id, criado_em desc);

create table if not exists itens_pedido (
  id bigint generated always as identity primary key,
  pedido_id uuid not null references pedidos(id) on delete cascade,
  item_id bigint not null references cardapio(id),
  nome text not null,
  quantidade int not null check (quantidade between 1 and 20),
  preco_unitario numeric(10,2) not null
);

alter table equipe_loja enable row level security;
alter table cardapio enable row level security;
alter table pedidos enable row level security;
alter table itens_pedido enable row level security;

-- "Quem pergunta é da loja?" SECURITY DEFINER: é ela que faz a regra valer
-- também no realtime, que confere cada mudança com um papel sem acesso a
-- equipe_loja.
create or replace function e_da_loja() returns boolean
language sql stable security definer set search_path from current as $$
  select exists (select 1 from equipe_loja where user_id = auth.uid())
$$;

-- Cardápio: todo mundo vê o disponível; a loja vê e altera tudo.
drop policy if exists cardapio_ler on cardapio;
create policy cardapio_ler on cardapio for select to anon, authenticated
  using (disponivel or e_da_loja());
drop policy if exists cardapio_loja on cardapio;
create policy cardapio_loja on cardapio for all to authenticated
  using (e_da_loja()) with check (e_da_loja());

drop policy if exists equipe_ver_a_propria on equipe_loja;
create policy equipe_ver_a_propria on equipe_loja for select to authenticated
  using (user_id = auth.uid());

-- Pedidos: o cliente vê os dele, a loja vê todos.
drop policy if exists pedidos_ler on pedidos;
create policy pedidos_ler on pedidos for select to authenticated
  using (cliente_id = auth.uid() or e_da_loja());
-- O cliente só cancela, e só enquanto a cozinha não começou.
drop policy if exists pedidos_cliente_cancela on pedidos;
create policy pedidos_cliente_cancela on pedidos for update to authenticated
  using (cliente_id = auth.uid() and status = 'recebido')
  with check (cliente_id = auth.uid() and status = 'cancelado');
-- A loja anda com o status.
drop policy if exists pedidos_loja on pedidos;
create policy pedidos_loja on pedidos for update to authenticated
  using (e_da_loja()) with check (e_da_loja());

drop policy if exists itens_ler on itens_pedido;
create policy itens_ler on itens_pedido for select to authenticated
  using (exists (select 1 from pedidos p where p.id = pedido_id
                  and (p.cliente_id = auth.uid() or e_da_loja())));

revoke all on equipe_loja, cardapio, pedidos, itens_pedido from anon, authenticated;
grant select on cardapio to anon, authenticated;
grant insert, update, delete on cardapio to authenticated;
grant select on equipe_loja to authenticated;
grant select on pedidos, itens_pedido to authenticated;
grant update (status) on pedidos to authenticated;

create or replace function pedido_atualizado() returns trigger
language plpgsql as $$
begin
  new.atualizado_em := now();
  return new;
end $$;
drop trigger if exists pedido_atualizado on pedidos;
create trigger pedido_atualizado before update on pedidos
  for each row execute function pedido_atualizado();

-- Fazer o pedido: confere os itens, tira o preço do cardápio e grava tudo
-- numa transação. O app nunca diz quanto custa, e o pedido já nasce com o
-- total certo (a cozinha recebe um INSERT só, completo).
create or replace function fazer_pedido(
  p_itens jsonb,          -- [{"item_id": 1, "quantidade": 2}, ...]
  p_cliente_nome text,
  p_endereco text,
  p_observacao text default null
) returns pedidos
language plpgsql security definer set search_path from current as $$
declare
  novo pedidos;
  v_total numeric(10,2);
  v_itens int;
begin
  if auth.uid() is null then
    raise exception 'entre_na_conta_para_pedir';
  end if;
  if jsonb_typeof(p_itens) <> 'array' or jsonb_array_length(p_itens) not between 1 and 30 then
    raise exception 'itens_invalidos';
  end if;
  -- Cada item precisa existir e estar disponível; a quantidade vai de 1 a 20.
  select count(*), sum(c.preco * (l->>'quantidade')::int)
    into v_itens, v_total
    from jsonb_array_elements(p_itens) l
    join cardapio c on c.id = (l->>'item_id')::bigint and c.disponivel
   where (l->>'quantidade')::int between 1 and 20;
  if v_itens <> jsonb_array_length(p_itens) then
    raise exception 'item_indisponivel';
  end if;
  insert into pedidos (cliente_id, cliente_nome, endereco, observacao, total)
  values (auth.uid(), p_cliente_nome, p_endereco, p_observacao, v_total)
  returning * into novo;
  insert into itens_pedido (pedido_id, item_id, nome, quantidade, preco_unitario)
  select novo.id, c.id, c.nome, (l->>'quantidade')::int, c.preco
    from jsonb_array_elements(p_itens) l
    join cardapio c on c.id = (l->>'item_id')::bigint;
  return novo;
end $$;
revoke all on function fazer_pedido(jsonb, text, text, text) from public, anon;
grant execute on function fazer_pedido(jsonb, text, text, text) to authenticated;

Por que e_da_loja() é uma função: o realtime confere a regra de leitura com um papel próprio, que não lê as outras tabelas do projeto. Se a política consultasse equipe_loja direto, a API funcionaria e o realtime ficaria mudo. Mais em Realtime chat.

Passo 3: ligue o realtime

No painel, abra o projeto, vá em Realtime e ligue a tabela pedidos. O painel recusa tabela sem RLS e avisa se alguma regra não vale no realtime; no teste, a tabela ligou sem aviso nenhum.

Logo depois de ligar, espere cerca de um minuto antes de abrir a primeira conexão. No teste, um projeto recém-criado recusou as conexões no primeiro minuto e aceitou todas depois.

Passo 4: cardápio e equipe

SQL Editor: cardápio e cozinha
insert into cardapio (nome, descricao, categoria, preco)
select * from (values
  ('Açaí 300 ml', 'Com granola e banana', 'açaí', 18.00),
  ('Açaí 500 ml', 'Com granola, banana e leite em pó', 'açaí', 26.00),
  ('Água 500 ml', null, 'bebidas', 4.00)) v(nome, descricao, categoria, preco)
where not exists (select 1 from cardapio);

-- troque pelo id do login da cozinha (painel: Autenticação → Usuários)
insert into equipe_loja (user_id, nome)
values ('00000000-0000-0000-0000-000000000000', 'Cozinha')
on conflict do nothing;

Passo 5: conecte o app

Instale com npm install @superdb/client @supabase/realtime-js. O SDK do SuperDB cuida do login e dos dados; a conexão ao vivo usa o @supabase/realtime-js com um token de realtime pedido com a sessão de quem está logado.

src/lib/superdb.ts
// src/lib/superdb.ts
import { createClient } from '@superdb/client'

// A anon key é pública e pode ficar no front. A service_role, nunca.
export const db = createClient('https://auth.superdb.com.br', 'SUA_ANON_KEY', {
  project: 'SEU_SLUG',
})
src/lib/pedidos.ts
// src/lib/pedidos.ts: cardápio, pedido e acompanhamento ao vivo.
// db = createClient('https://auth.superdb.com.br', ANON_KEY, { project: 'SEU_SLUG' })
import type { SupabaseClient } from '@superdb/client'
import { RealtimeClient } from '@supabase/realtime-js'

const AUTH = 'https://auth.superdb.com.br'

export type Pedido = {
  id: string; numero: number; cliente_nome: string; endereco: string
  status: 'recebido' | 'preparando' | 'saiu' | 'entregue' | 'cancelado'; total: number; criado_em: string
}

export async function cardapio(db: SupabaseClient) {
  const { data, error } = await db.from('cardapio').select('id,nome,descricao,categoria,preco').order('categoria').order('nome')
  if (error) throw error
  return data as { id: number; nome: string; descricao: string | null; categoria: string; preco: number }[]
}

// O total quem calcula é o banco, com o preço do cardápio.
export async function fazerPedido(db: SupabaseClient, p: {
  itens: { item_id: number; quantidade: number }[]; cliente_nome: string; endereco: string; observacao?: string
}) {
  const { data, error } = await db.rpc('fazer_pedido', {
    p_itens: p.itens, p_cliente_nome: p.cliente_nome, p_endereco: p.endereco, p_observacao: p.observacao ?? null,
  })
  if (error) throw error
  return data as Pedido
}

export async function pedidos(db: SupabaseClient) {
  const { data, error } = await db
    .from('pedidos')
    .select('id,numero,cliente_nome,endereco,status,total,criado_em,itens_pedido(nome,quantidade)')
    .order('criado_em', { ascending: false })
    .limit(50)
  if (error) throw error
  return data
}

export async function mudarStatus(db: SupabaseClient, id: string, status: Pedido['status']) {
  const { data, error } = await db.from('pedidos').update({ status }).eq('id', id).select('id,status')
  if (error) throw error
  if (!data?.length) throw new Error('Sem permissão para mudar este pedido.')
  return data[0]
}

// O token de realtime leva o usuário da sessão: o RLS decide o que chega.
async function tokenDeRealtime(db: SupabaseClient) {
  const sessao = await db.auth.getDataPlaneToken()
  if (!sessao) throw new Error('Entre na conta antes de acompanhar os pedidos.')
  const r = await fetch(`${AUTH}/rt/v1/token`, { method: 'POST', headers: { Authorization: `Bearer ${sessao}` } })
  if (!r.ok) throw new Error(`realtime: ${r.status}`)
  return (await r.json()) as { token: string; url: string; expires_in: number }
}

// Ao vivo: o cliente recebe as mudanças dos pedidos dele; a cozinha, de todos.
// `pronto` é chamado quando a escuta da tabela está de pé: recarregue a lista ali.
export async function aoVivo(
  db: SupabaseClient, projeto: string,
  aoMudar: (evento: 'INSERT' | 'UPDATE', pedido: Pedido) => void,
  pronto: () => void = () => {},
) {
  const { token, url, expires_in } = await tokenDeRealtime(db)
  const rt = new RealtimeClient(url, { params: { apikey: token } })
  const schema = `proj_${projeto}`
  rt.channel(`${schema}:pedidos`, { config: { private: true } })
    .on('postgres_changes', { event: 'INSERT', schema, table: 'pedidos' }, (m) => aoMudar('INSERT', m.new as Pedido))
    .on('postgres_changes', { event: 'UPDATE', schema, table: 'pedidos' }, (m) => aoMudar('UPDATE', m.new as Pedido))
    .on('system', {}, (msg: any) => { if (msg.extension === 'postgres_changes' && msg.status === 'ok') pronto() })
    .subscribe()
  const renovar = setInterval(async () => rt.setAuth((await tokenDeRealtime(db)).token), (expires_in - 300) * 1000)
  return () => { clearInterval(renovar); rt.disconnect() }
}

A tela de acompanhar e o painel da cozinha usam a mesma aoVivo: carregue a lista com pedidos, e a cada evento troque o pedido de mesmo id na lista (ou acrescente, no INSERT). Recarregue a lista no pronto: o que chega entre a inscrição e a escuta ficar de pé vem pela API. Ao sair da conta, chame a função que aoVivo devolve, para fechar a conexão.

Com IA: o prompt pronto

Faça os passos 1 a 4 no painel e cole este prompt com o arquivo src/lib/pedidos.ts. No Lovable, siga antes o guia SuperDB no Lovable. Em app de celular com Expo, comece pelo Quickstart Expo.

prompt para Lovable, Cursor ou Claude
Crie o app de pedidos de uma açaiteria, em português, para celular.

IMPORTANTE: NÃO use o Lovable Cloud nem o Supabase. O backend é o SuperDB,
pelo pacote npm @superdb/client, e o ao vivo usa @supabase/realtime-js
como está no arquivo abaixo. Não use @supabase/supabase-js.

As tabelas já existem no projeto SEU_SLUG (cardapio, pedidos, itens_pedido,
equipe_loja) com RLS e realtime ligado em pedidos. NÃO crie nem altere
tabelas e NÃO desligue a RLS. Use SOMENTE as funções de src/lib/pedidos.ts
(colado abaixo) e o cliente de src/lib/superdb.ts.

Telas:
1. Cardápio (sem login), agrupado por categoria, com carrinho.
2. Finalizar: login por código no e-mail, nome e endereço; chama
   fazerPedido com os itens e quantidades. Nunca calcule o total no app.
3. Meu pedido: status ao vivo com aoVivo; trocar o pedido de mesmo id
   na lista a cada evento; recarregar no callback pronto.
4. Cozinha (só se db.from('equipe_loja').select('nome').maybeSingle()
   trouxer uma linha): colunas Recebido, Preparando, Saiu, com botões
   que chamam mudarStatus.

[cole aqui o conteúdo de src/lib/pedidos.ts]

O que testar antes de entregar

O teste deste roteiro abriu três conexões ao vivo, a da cozinha e as de dois clientes:

  • Sem login, a pessoa vê o cardápio e não consegue pedir.
  • O pedido de 2 açaís de 500 ml e 1 água fecha em R$ 56, calculado no banco.
  • Pedido gravado direto na tabela, com preço inventado, é recusado; item que não existe e quantidade fora de 1 a 20 também, e nada fica gravado.
  • A cozinha recebe o pedido novo ao vivo, já com o total, num INSERT só.
  • O cliente vê o status mudar para preparando; o outro cliente não recebe nada.
  • O cliente cancela enquanto o pedido está recebido, mas não depois que a cozinha começou, e não marca o próprio pedido como saiu.
  • Cliente não muda preço do cardápio nem o total do pedido.

Quanto custa

PlanoPreçoO que este roteiro usa do plano
GrátisR$ 0Tudo deste roteiro: cardápio, pedidos, painel e ao vivo, com 50 canais de realtime, 50 mil usuários ativos por mês e, a partir de 14/11/2026, 250 mil requisições por mês por projeto. Projeto sem uso por 30 dias é pausado e volta em 1 clique.
SiteR$ 39/mês100 canais de realtime, 100 mil usuários ativos e requisições sem cota. Bom para uma loja só. Não pausa por inatividade.
ProR$ 119/mês500 canais de realtime, 250 mil usuários ativos e 250.000 push por mês para avisar que o pedido saiu.
EscalaR$ 349/mêsPara rede de lojas ou plataforma de várias lojas: 5.000 canais de realtime e 1 milhão de usuários ativos.

Os limites completos estão em Preços.

Perguntas

O cliente consegue mudar o preço do pedido?

Não. O app manda só os itens e as quantidades para a função fazer_pedido, que tira o preço do cardápio e grava o pedido com o total numa transação. Gravar direto na tabela pedidos é recusado, e o total não aceita escrita.

Um cliente recebe ao vivo o pedido de outro?

Não. O realtime do SuperDB segue a RLS da tabela: cada mudança vai só para quem leria aquela linha pela API. No teste, a cozinha recebeu os dois pedidos e cada cliente só o dele.

Precisa de servidor próprio?

Para este roteiro, não: o cardápio, o pedido, o painel e o status ao vivo usam só a anon key e a sessão de cada pessoa. Servidor entra quando você quiser cobrar online ou mandar push.

Dá para receber pagamento pelo app?

O SuperDB não processa pagamento. Use o gateway que o seu cliente já tem e grave o resultado no pedido pelo seu servidor.

Quantas conexões ao vivo cabem em cada plano?

Os canais de realtime são 50 no Grátis, 100 no Site, 500 no Pro e 5.000 no Escala. Cada aparelho com o app aberto usa um canal.

O seu cliente sem comissão por pedido

Crie a conta, cole o SQL e ligue o realtime. A cozinha vê o primeiro pedido chegar em minutos.