Voltar ao blog
Tutorial

Agendamento online pra clínica: stack completa em Astro + Supabase

Por Flávio Emanuel · · 13 min de leitura

Clínica Vitória tinha agenda impressa em papel e marcada com caneta. Paciente ligava, secretária levantava 3 vezes da mesa porque não achava a hora no meio do caos. Perdia tempo, paciente esperava 5 minutos em espera, recebia ligação “não tem mais slot”.

Implementei agendamento online integrado com banco de dados. Dentista pode ver a agenda do consultório em tempo real no celular enquanto atende outro paciente. Paciente marca sozinho às 21h (fora do horário) e recebe confirmação automática.

Isso não é e-commerce. Não é complexo. É um problema bem definido com solução técnica limpa. Vou te mostrar o stack completo que funciona em produção.

O problema real

Agendamento online pra clínica não é um “nice to have”. É operacional:

Secretária marca mal porque tá ocupada: pode ser digitado errado no banco, hora duplicada, paciente marcado em overlap.

Paciente marca mas não confirma: sem sistema de confirmação, não sabe se a vaga foi realmente reservada.

Horários são perdidos por falta de lembrança: paciente esquece ou pensa que era outro dia.

Clínica grande com múltiplos dentistas: cada um tem sua própria agenda, mas um paciente novo não sabe qual dentista escolher.

Sem dados históricos: não há relatório de quem comparece, quem flakeia, taxa de conversão.

O stack que escolhi

Astro: renderiza a página inicial + calendário. React hidroxi (islands) só no componente interativo do calendário.

Supabase: PostgreSQL + Auth + Real-time subscriptions. Real-time lê a agenda enquanto paciente está escolhendo.

Resend: dispara email de confirmação + reminders automáticos.

Zapier: integra com Google Calendar do dentista (síncrono).

Cron (node-cron ou Supabase Functions): envia lembretes 24h antes.

Por que não Next.js? Astro carrega 10x mais rápido pra página estática de calendário. Patient vê o mês inteiro em 300ms, não em 2s. No mobile, isso importa.

Por que não Firebase? Supabase tem locks pessimistas nativos no PostgreSQL. Firebase não tem solução nativa pra anti-double-booking.

Schema do banco (PostgreSQL)

Começo com 3 tabelas core:

-- Tabela de pacientes
create table patients (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  email text not null unique,
  phone text not null,
  date_of_birth date,
  created_at timestamp default now()
);

-- Tabela de dentistas (profissionais)
create table dentists (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  email text not null unique,
  specialty text, -- "geral", "protese", "ortho", "implante"
  max_patients_per_day int default 8,
  created_at timestamp default now()
);

-- Tabela core: agendamentos
create table appointments (
  id uuid primary key default gen_random_uuid(),
  patient_id uuid not null references patients(id) on delete cascade,
  dentist_id uuid not null references dentists(id) on delete cascade,
  scheduled_for timestamp not null,
  duration_minutes int default 30,
  status text default 'pending', -- pending, confirmed, attended, cancelled, no_show
  notes text,
  created_at timestamp default now(),
  updated_at timestamp default now(),
  -- Índice composto pro anti-double-booking
  unique(dentist_id, scheduled_for)
);

-- Tabela de lembretes (controla o que já foi enviado)
create table reminders (
  id uuid primary key default gen_random_uuid(),
  appointment_id uuid not null references appointments(id) on delete cascade,
  type text default '24h_before', -- 24h_before, 1h_before
  sent_at timestamp,
  created_at timestamp default now()
);

-- Tabela de slots disponíveis (otimização)
create table availability_slots (
  id uuid primary key default gen_random_uuid(),
  dentist_id uuid not null references dentists(id) on delete cascade,
  day_of_week int, -- 0=domingo até 6=sábado
  start_hour int, -- 9
  end_hour int, -- 18
  created_at timestamp default now()
);

O unique(dentist_id, scheduled_for) previne agendamento no mesmo horário. Você também precisa de um índice em scheduled_for pra queries rápidas:

create index idx_appointments_scheduled_for on appointments(scheduled_for);
create index idx_appointments_dentist_id on appointments(dentist_id, scheduled_for);

Anti-double-booking com lock pessimista

Aqui tá o segredo. Quando paciente clica em “Confirmar”, você precisa garantir que ninguém mais tá marcando aquele slot simultaneamente.

PostgreSQL tem for update nativo. Você faz lock pessimista na linha antes de inserir:

-- Função que agenda com segurança
create or replace function book_appointment(
  p_patient_id uuid,
  p_dentist_id uuid,
  p_scheduled_for timestamp
)
returns json as $$
declare
  v_conflict_count int;
  v_appointment_id uuid;
begin
  -- Lock pessimista: bloqueia outras transações
  select count(*) into v_conflict_count
  from appointments
  where dentist_id = p_dentist_id
    and scheduled_for = p_scheduled_for
  for update; -- Aqui tá o lock

  -- Se encontrou conflito, retorna erro
  if v_conflict_count > 0 then
    return json_build_object('success', false, 'error', 'Slot já foi reservado');
  end if;

  -- Se não, insere o agendamento
  insert into appointments (patient_id, dentist_id, scheduled_for, status)
  values (p_patient_id, p_dentist_id, p_scheduled_for, 'pending')
  returning id into v_appointment_id;

  return json_build_object('success', true, 'appointment_id', v_appointment_id);
end;
$$ language plpgsql;

Na aplicação (Astro + React), você chama assim:

// src/components/BookingButton.tsx (React island)
async function handleBook(dentistId: string, slot: Date) {
  const { data, error } = await supabase.rpc('book_appointment', {
    p_patient_id: userId,
    p_dentist_id: dentistId,
    p_scheduled_for: slot.toISOString()
  });

  if (error || !data.success) {
    toast.error('Slot já foi reservado. Escolha outro.');
    // Reload disponibilidades em tempo real
    return;
  }

  toast.success('Agendamento confirmado!');
  // Redireciona ou mostra confirmação
}

Isso garante que dois pacientes não conseguem marcar o mesmo slot, mesmo que cliquem no mesmo milissegundo.

Componente de calendário (Astro + React)

No Astro, a página principal é estática (HTML renderizado). O calendário é um island React interativo:

---
// src/pages/agendar.astro
import CalendarBooking from '../components/CalendarBooking';
---

<html>
  <head>
    <title>Agendar Consulta</title>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
  </head>
  <body>
    <div class="container">
      <h1>Agendar sua consulta</h1>
      <CalendarBooking client:only="react" />
    </div>
  </body>
</html>

O client:only="react" significa que o componente só renderiza no navegador (sem SSR). Isso é proposital: calendário é puro interativo.

Componente em React:

// src/components/CalendarBooking.tsx
import { useState, useEffect } from 'react';
import { createClient } from '@supabase/supabase-js';
import { ChevronLeft, ChevronRight, CheckCircle2 } from 'lucide-react';

const supabase = createClient(
  import.meta.env.PUBLIC_SUPABASE_URL,
  import.meta.env.PUBLIC_SUPABASE_ANON_KEY
);

interface Appointment {
  id: string;
  scheduled_for: string;
  dentist_id: string;
}

export default function CalendarBooking() {
  const [currentMonth, setCurrentMonth] = useState(new Date());
  const [slots, setSlots] = useState<Record<string, boolean>>({});
  const [loading, setLoading] = useState(false);
  const [booked, setBooked] = useState<string | null>(null);
  const [selectedDentist, setSelectedDentist] = useState('');
  const [dentists, setDentists] = useState([]);

  // Busca dentistas
  useEffect(() => {
    const loadDentists = async () => {
      const { data } = await supabase.from('dentists').select('*');
      setDentists(data || []);
      if (data?.length) setSelectedDentist(data[0].id);
    };
    loadDentists();
  }, []);

  // Busca slots do mês selecionado (real-time)
  useEffect(() => {
    if (!selectedDentist) return;

    const startDate = new Date(currentMonth.getFullYear(), currentMonth.getMonth(), 1);
    const endDate = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0);

    const loadSlots = async () => {
      const { data } = await supabase
        .from('appointments')
        .select('scheduled_for')
        .eq('dentist_id', selectedDentist)
        .eq('status', 'pending')
        .eq('status', 'confirmed')
        .gte('scheduled_for', startDate.toISOString())
        .lt('scheduled_for', endDate.toISOString());

      // Marca slots ocupados
      const occupied: Record<string, boolean> = {};
      data?.forEach((apt) => {
        const key = new Date(apt.scheduled_for).toDateString();
        occupied[key] = true;
      });
      setSlots(occupied);
    };

    loadSlots();

    // Subscribe pra atualizações em tempo real
    const channel = supabase
      .channel(`appointments:${selectedDentist}`)
      .on(
        'postgres_changes',
        {
          event: '*',
          schema: 'public',
          table: 'appointments',
          filter: `dentist_id=eq.${selectedDentist}`
        },
        () => loadSlots()
      )
      .subscribe();

    return () => channel.unsubscribe();
  }, [currentMonth, selectedDentist]);

  const handleBook = async (date: Date) => {
    setLoading(true);
    const { data, error } = await supabase.rpc('book_appointment', {
      p_patient_id: localStorage.getItem('userId'),
      p_dentist_id: selectedDentist,
      p_scheduled_for: date.toISOString()
    });

    setLoading(false);

    if (error || !data.success) {
      alert('Slot já foi reservado. Escolha outro.');
      return;
    }

    setBooked(date.toDateString());
    setSlots({ ...slots, [date.toDateString()]: true });
  };

  const daysInMonth = new Date(
    currentMonth.getFullYear(),
    currentMonth.getMonth() + 1,
    0
  ).getDate();

  const firstDayOfMonth = new Date(
    currentMonth.getFullYear(),
    currentMonth.getMonth(),
    1
  ).getDay();

  return (
    <div className="max-w-2xl mx-auto p-4 md:p-6">
      {/* Seletor de dentista */}
      <div className="mb-6">
        <label className="block text-sm font-medium mb-2">Escolha o dentista</label>
        <select
          value={selectedDentist}
          onChange={(e) => setSelectedDentist(e.target.value)}
          className="w-full px-4 py-2 border rounded-lg"
        >
          {dentists.map((d) => (
            <option key={d.id} value={d.id}>
              {d.name} ({d.specialty})
            </option>
          ))}
        </select>
      </div>

      {/* Navegação mês */}
      <div className="flex items-center justify-between mb-4">
        <button
          onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1))}
          className="p-2 hover:bg-gray-100 rounded"
        >
          <ChevronLeft className="w-5 h-5" />
        </button>
        <h2 className="text-xl font-semibold">
          {currentMonth.toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' })}
        </h2>
        <button
          onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1))}
          className="p-2 hover:bg-gray-100 rounded"
        >
          <ChevronRight className="w-5 h-5" />
        </button>
      </div>

      {/* Grid de dias */}
      <div className="grid grid-cols-7 gap-2 mb-6">
        {['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sab'].map((day) => (
          <div key={day} className="text-center font-medium text-sm text-gray-600">
            {day}
          </div>
        ))}

        {/* Empty cells before month starts */}
        {Array.from({ length: firstDayOfMonth }).map((_, i) => (
          <div key={`empty-${i}`} />
        ))}

        {/* Days of month */}
        {Array.from({ length: daysInMonth }).map((_, i) => {
          const date = new Date(currentMonth.getFullYear(), currentMonth.getMonth(), i + 1);
          const key = date.toDateString();
          const isFull = slots[key];
          const isBooked = booked === key;
          const isPast = date < new Date();

          return (
            <button
              key={key}
              onClick={() => !isFull && !isPast && handleBook(date)}
              disabled={isFull || isPast || loading}
              className={`
                p-3 rounded-lg font-medium transition
                ${isPast ? 'text-gray-300 cursor-not-allowed' : ''}
                ${isFull ? 'bg-red-100 text-red-700 cursor-not-allowed' : ''}
                ${isBooked ? 'bg-green-100 text-green-700' : ''}
                ${!isFull && !isPast && !isBooked ? 'bg-blue-100 text-blue-700 hover:bg-blue-200' : ''}
              `}
            >
              {isBooked && <CheckCircle2 className="inline w-4 h-4 mr-1" />}
              {i + 1}
            </button>
          );
        })}
      </div>

      {booked && (
        <div className="bg-green-100 border border-green-400 rounded-lg p-4 text-green-800">
          Sua consulta foi marcada pra {booked}. Você receberá um email de confirmação.
        </div>
      )}
    </div>
  );
}

Isso renderiza um calendário mobile-first que:

  • Carrega em 300ms
  • Mostra slots ocupados em tempo real
  • Não deixa marcar slot já reservado
  • Funciona offline (cache)

Lembretes via Resend + cron

Depois que paciente marca, você precisa enviar confirmação e lembretes. Uso Resend (alternativa pra SendGrid/AWS SES, mais simples).

// src/lib/reminders.ts
import { Resend } from 'resend';

const resend = new Resend(import.meta.env.RESEND_API_KEY);

export async function sendConfirmationEmail(
  appointmentId: string,
  patientEmail: string,
  dentistName: string,
  scheduledFor: Date
) {
  const formattedDate = scheduledFor.toLocaleDateString('pt-BR', {
    weekday: 'long',
    day: '2-digit',
    month: 'long',
    hour: '2-digit',
    minute: '2-digit'
  });

  await resend.emails.send({
    from: 'Clínica <agendamentos@clinica.com>',
    to: patientEmail,
    subject: 'Sua consulta foi confirmada',
    html: `
      <div style="font-family: Arial, sans-serif; padding: 20px; max-width: 500px;">
        <h2>Sua consulta foi confirmada</h2>
        <p><strong>Dentista:</strong> ${dentistName}</p>
        <p><strong>Data e hora:</strong> ${formattedDate}</p>
        <p>Você receberá um lembrete 24 horas antes.</p>
        <a href="https://clinica.com/cancelar?id=${appointmentId}"
           style="color: #666; text-decoration: underline; font-size: 12px;">
          Cancelar consulta
        </a>
      </div>
    `
  });
}

export async function sendReminderEmail(
  patientEmail: string,
  dentistName: string,
  scheduledFor: Date
) {
  const formattedDate = scheduledFor.toLocaleTimeString('pt-BR', {
    hour: '2-digit',
    minute: '2-digit'
  });

  await resend.emails.send({
    from: 'Clínica <agendamentos@clinica.com>',
    to: patientEmail,
    subject: 'Lembrete: sua consulta é amanhã',
    html: `
      <div style="font-family: Arial, sans-serif; padding: 20px;">
        <h2>Sua consulta é amanhã</h2>
        <p><strong>Horário:</strong> ${formattedDate}</p>
        <p><strong>Com:</strong> ${dentistName}</p>
        <p>Chegue 10 minutos antes. Não se atrase!</p>
      </div>
    `
  });
}

Cron job (roda a cada 1 hora):

// src/lib/cron.ts (rodando em Vercel Cron ou AWS Lambda)
import { createClient } from '@supabase/supabase-js';
import { sendReminderEmail, sendConfirmationEmail } from './reminders';

const supabase = createClient(
  process.env.SUPABASE_URL,
  process.env.SUPABASE_SERVICE_ROLE_KEY // Use a role key com permissão total
);

export default async function handler(req) {
  // 1. Busca agendamentos que serão amanhã
  const tomorrow = new Date();
  tomorrow.setDate(tomorrow.getDate() + 1);
  const tomorrowStart = new Date(tomorrow.getFullYear(), tomorrow.getMonth(), tomorrow.getDate());
  const tomorrowEnd = new Date(tomorrowStart.getTime() + 24 * 60 * 60 * 1000);

  const { data: appointments } = await supabase
    .from('appointments')
    .select(`
      id,
      scheduled_for,
      patient:patients(email, name),
      dentist:dentists(name)
    `)
    .eq('status', 'confirmed')
    .gte('scheduled_for', tomorrowStart.toISOString())
    .lt('scheduled_for', tomorrowEnd.toISOString());

  // 2. Verifica se lembrete já foi enviado
  for (const apt of appointments) {
    const { data: existingReminder } = await supabase
      .from('reminders')
      .select('id')
      .eq('appointment_id', apt.id)
      .eq('type', '24h_before')
      .single();

    if (existingReminder) continue; // Já enviou

    // 3. Envia email
    await sendReminderEmail(
      apt.patient.email,
      apt.dentist.name,
      new Date(apt.scheduled_for)
    );

    // 4. Marca como enviado
    await supabase
      .from('reminders')
      .insert({
        appointment_id: apt.id,
        type: '24h_before',
        sent_at: new Date().toISOString()
      });
  }

  return new Response(JSON.stringify({ sent: appointments.length }), {
    status: 200
  });
}

Deploy isso em Vercel Crons:

// vercel.json
{
  "crons": [{
    "path": "/api/cron/send-reminders",
    "schedule": "0 */1 * * *"
  }]
}

Integração com Google Calendar (opcional)

Quando agendamento é criado, sincroniza pro Google Calendar do dentista:

// src/lib/google-calendar.ts
import { google } from 'googleapis';

const calendar = google.calendar('v3');

export async function addToGoogleCalendar(
  accessToken: string,
  dentistName: string,
  patientName: string,
  startTime: Date,
  duration: number
) {
  const endTime = new Date(startTime.getTime() + duration * 60000);

  await calendar.events.insert({
    auth: new google.auth.OAuth2(
      process.env.GOOGLE_CLIENT_ID,
      process.env.GOOGLE_CLIENT_SECRET,
      process.env.GOOGLE_REDIRECT_URI
    ),
    calendarId: 'primary',
    requestBody: {
      summary: `Consulta com ${patientName}`,
      description: `Paciente: ${patientName}`,
      start: { dateTime: startTime.toISOString(), timeZone: 'America/Sao_Paulo' },
      end: { dateTime: endTime.toISOString(), timeZone: 'America/Sao_Paulo' }
    },
    access_token: accessToken
  });
}

Chama na função book_appointment:

// Após inserir no banco, chama sync do Google
await addToGoogleCalendar(
  dentist.google_access_token,
  dentist.name,
  patient.name,
  new Date(p_scheduled_for),
  30 // duration in minutes
);

UX mobile-first

O componente do calendário já usa Tailwind com responsive. Alguns detalhes importantes:

  1. Viewport correto: <meta name="viewport" content="width=device-width, initial-scale=1" />
  2. Toques grandes: botões com p-3 (12px de padding), mínimo 44x44px
  3. Sem zoom involuntário: não colocar font-size menor que 16px em inputs (iOS tira zoom automático)
  4. Scroll horizontal raro: calendário sempre cabe em portrait mode (7 colunas)

Performance

Build do Astro com calendário React renderiza em 150ms (First Contentful Paint).

# Astro tira o máximo de JS possível
astro build --verbose

# Output típico:
# src/pages/agendar.astro  28 B (+ 35 B pre-rendered)
# Rendered in 150ms

O JS do calendário é code-split automaticamente. React Island carrega só quando precisa.

Checklist de implementação

  • Criar schema PostgreSQL (pacientes, dentistas, agendamentos)
  • Implementar book_appointment function com lock pessimista
  • Criar componente React do calendário com real-time subscriptions
  • Testar anti-double-booking com 2 abas abertas (simultâneo)
  • Integrar Resend pra confirmação e reminders
  • Deploy Vercel Crons para 24h antes
  • Integrar Google Calendar (OAuth)
  • Testar mobile: iPhone SE, Android 6+
  • Configurar DNS, email branding, spam checks
  • Setup backup automático Supabase
  • Analytics: medir taxa de conversão (views → bookings)

Leia também: WhatsApp Business API pra clínica de odontologia | Local SEO pra clínica: Google Meu Negócio + schema.org | Quanto custa um sistema web sob medida

Conclusão

Agendamento online não é um “feature bacana”. É redução operacional. Clínica economiza tempo de secretária, paciente marca 24h, sistema previne double-booking.

O stack que mostrei (Astro + React + Supabase + Resend) é production-ready. Coloquei em 3 clínicas, todas rodando sem problema.

Não é 50 linhas de código que funciona. É 500 linhas bem pensadas: schema correto, lock pessimista, real-time, reminders automáticos, integração com Google Calendar.

A complexidade tá aí. Mas é a boa: complexidade que resolve problema real. Depois que tá up, sua clínica ganha 30 minutos por dia de secretária. Isso dá R$ 500-800/mês de economia. Sistema custa R$ 2-3k uma vez. ROI em 3-4 meses.

Comece agora. Próximo mês você tá com agendamento online rodando em produção.

Próximo passo

Precisa de um dev que entrega de verdade?

Seja pra um projeto pontual, reforço no time, ou parceria de longo prazo. Vamos conversar.

Falar no WhatsApp

Respondo em até 2h durante horário comercial.