Seu visitante passa pela propriedade #47 quando o calendário de disponibilidade congela por três segundos. Ele fecha a aba. Pronto, você perdeu $180 em comissão — e isso aconteceu porque sua tabela de reservas travou durante uma conversão de fuso horário. Eu vi isso acontecer em duas plataformas de aluguel de férias ao vivo, ambas construídas com Next.js e Supabase, ambas processando pagamentos reais via Stripe Connect. Uma atingiu 3.000 listagens em oito meses. A outra quase colapsou com apenas 400 porque o fundador pulou row-level security e deixou hóspedes verem o histórico de reservas um do outro. A diferença não era talento ou orçamento — eram decisões de schema de banco de dados feitas na segunda semana. Aqui está a arquitetura que sobreviveu, os triggers do Supabase que evitaram overbooking às 23h de um sábado, e por que esse stack lida com a complexidade de aluguel de curta duração melhor do que o monolito Rails que todos ainda recomendam.

O mercado de aluguel de férias deve atingir $113,9 bilhões em 2027 (Statista). Airbnb controla uma fatia massiva, mas plataformas de nicho — estadias pet-friendly, vilas de luxo, retiros rurais, surf houses — estão prosperando porque servem públicos específicos melhor do que um generalist jamais conseguiria. Você não precisa vencer o Airbnb. Você precisa servi-los melhor em seu nicho.

Índice

Build a Vacation Rental Platform with Next.js and Supabase

Por que Next.js + Supabase para uma Plataforma de Aluguel

Seja direto: você poderia construir isso com dezenas de stacks diferentes. Laravel, Rails, Django — todas opções válidas. Mas a combinação Next.js + Supabase atinge um ponto ideal especificamente para plataformas de aluguel.

Next.js oferece:

  • Server-side rendering para SEO (páginas de listagem precisam rankear)
  • App Router com React Server Components para carregamentos iniciais rápidos
  • Rotas de API para lógica backend sem servidor separado
  • Otimização de imagens integrada (crítico para fotos de propriedades)
  • Incremental Static Regeneration para páginas de listagem que raramente mudam

Supabase oferece:

  • PostgreSQL com PostGIS para queries geográficas ("mostre-me aluguéis em um raio de 20km")
  • Row Level Security (RLS) que funciona de verdade para apps multi-tenant
  • Subscrições em tempo real para mensagens e atualizações de reservas
  • Autenticação integrada com provedores OAuth
  • Storage para imagens de propriedades com entrega via CDN
  • Edge Functions para lógica de negócio serverless

O recurso verdadeiramente mata é que Supabase é apenas Postgres sob o capô. Quando você crescer além da oferta gerenciada do Supabase (ou precisar), você pode migrar para qualquer host Postgres. Sem vendor lock-in em seu ativo mais crítico — seus dados.

Se você está avaliando frameworks, nosso time de desenvolvimento Next.js enviou várias plataformas nesse exato stack.

Visão Geral da Arquitetura do Sistema

Aqui está a arquitetura de alto nível que funcionou bem em vários projetos:

┌─────────────────────────────────────────────┐
│              Next.js Application             │
│  ┌─────────┐  ┌──────────┐  ┌────────────┐  │
│  │  Pages   │  │   API    │  │  Server    │  │
│  │ (SSR/ISR)│  │  Routes  │  │ Components │  │
│  └────┬─────┘  └────┬─────┘  └─────┬──────┘  │
│       │              │              │         │
│  ┌────▼──────────────▼──────────────▼──────┐  │
│  │         Supabase Client SDK             │  │
│  └────────────────┬────────────────────────┘  │
└───────────────────┼───────────────────────────┘
                    │
         ┌──────────▼──────────┐
         │     Supabase        │
         │  ┌──────────────┐   │
         │  │  PostgreSQL   │   │
         │  │  + PostGIS    │   │
         │  ├──────────────┤   │
         │  │  Auth         │   │
         │  ├──────────────┤   │
         │  │  Storage      │   │
         │  ├──────────────┤   │
         │  │  Realtime     │   │
         │  ├──────────────┤   │
         │  │  Edge Funcs   │   │
         │  └──────────────┘   │
         └─────────────────────┘
                    │
         ┌──────────▼──────────┐
         │  External Services   │
         │  • Stripe Connect    │
         │  • Mapbox/Google Maps│
         │  • SendGrid/Resend   │
         │  • Cloudflare CDN    │
         └─────────────────────┘

A decisão arquitetural chave é usar Supabase Edge Functions para operações críticas de negócio como criação de reservas e webhooks de pagamento, enquanto mantém as rotas da API do Next.js para tarefas mais leves como queries de busca e validação de formulários. Essa separação importa quando um webhook do Stripe dispara e você precisa garantir que o estado da reserva atualize atomicamente.

Design do Schema do Banco de Dados

Este é o lugar onde a maioria das plataformas de aluguel erra no início e paga por isso depois. Aqui está um schema que sobreviveu ao tráfego de produção:

-- Habilitar PostGIS
create extension if not exists postgis;

-- Profiles estendem auth.users do Supabase
create table public.profiles (
  id uuid references auth.users on delete cascade primary key,
  full_name text not null,
  avatar_url text,
  phone text,
  role text check (role in ('guest', 'host', 'admin')) default 'guest',
  stripe_account_id text, -- Para payouts de host
  identity_verified boolean default false,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- Properties/Listagens
create table public.properties (
  id uuid default gen_random_uuid() primary key,
  host_id uuid references public.profiles(id) not null,
  title text not null,
  slug text unique not null,
  description text,
  property_type text check (property_type in (
    'apartment', 'house', 'villa', 'cabin', 'unique'
  )),
  max_guests int not null default 1,
  bedrooms int not null default 0,
  beds int not null default 1,
  bathrooms numeric(3,1) not null default 1,
  amenities text[] default '{}',
  -- Preços
  base_price_cents int not null,
  cleaning_fee_cents int default 0,
  currency text default 'USD',
  -- Localização
  address_line1 text,
  city text not null,
  state text,
  country text not null,
  postal_code text,
  location geography(Point, 4326), -- PostGIS
  -- Status
  status text check (status in ('draft', 'listed', 'unlisted', 'suspended'))
    default 'draft',
  -- Políticas
  cancellation_policy text check (cancellation_policy in (
    'flexible', 'moderate', 'strict'
  )) default 'moderate',
  check_in_time time default '15:00',
  check_out_time time default '11:00',
  min_nights int default 1,
  max_nights int default 365,
  -- Metadata
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- Índice espacial para queries geo
create index properties_location_idx
  on public.properties using gist (location);

-- Disponibilidade e overrides de preço
create table public.availability (
  id uuid default gen_random_uuid() primary key,
  property_id uuid references public.properties(id) on delete cascade,
  date date not null,
  available boolean default true,
  price_override_cents int, -- null = usar preço base
  min_nights_override int,
  unique(property_id, date)
);

-- Reservas
create table public.bookings (
  id uuid default gen_random_uuid() primary key,
  property_id uuid references public.properties(id) not null,
  guest_id uuid references public.profiles(id) not null,
  check_in date not null,
  check_out date not null,
  guests_count int not null default 1,
  -- Snapshot de preço (imutável após criação)
  nightly_rate_cents int not null,
  nights int not null,
  subtotal_cents int not null,
  cleaning_fee_cents int not null,
  service_fee_cents int not null,
  total_cents int not null,
  currency text default 'USD',
  -- Status
  status text check (status in (
    'pending', 'confirmed', 'cancelled', 'completed', 'disputed'
  )) default 'pending',
  -- Pagamento
  stripe_payment_intent_id text,
  stripe_transfer_id text,
  paid_at timestamptz,
  -- Timestamps
  created_at timestamptz default now(),
  updated_at timestamptz default now(),
  -- Prevenir overbooking no nível do DB
  exclude using gist (
    property_id with =,
    daterange(check_in, check_out) with &&
  ) where (status in ('pending', 'confirmed'))
);

Esse constraint exclude na tabela de reservas? Essa é a linha mais importante de todo o schema. Ela previne overbooking no nível do banco de dados usando um constraint de exclusão GiST. Sem race conditions. Sem emails "desculpe, alguém reservou 2 segundos antes de você". O banco de dados literalmente não permitirá intervalos de datas sobrepostos para a mesma propriedade.

Você precisará da extensão btree_gist:

create extension if not exists btree_gist;

Build a Vacation Rental Platform with Next.js and Supabase - architecture

Construindo o Motor de Reservas

O fluxo de reserva é o coração de qualquer plataforma de aluguel. Aqui está como eu estruturo:

Passo 1: Verificação de Disponibilidade

// lib/bookings/check-availability.ts
import { createClient } from '@/lib/supabase/server';

export async function checkAvailability(
  propertyId: string,
  checkIn: string,
  checkOut: string
) {
  const supabase = await createClient();

  // Verificar datas bloqueadas
  const { data: blockedDates } = await supabase
    .from('availability')
    .select('date')
    .eq('property_id', propertyId)
    .eq('available', false)
    .gte('date', checkIn)
    .lt('date', checkOut);

  if (blockedDates && blockedDates.length > 0) {
    return { available: false, reason: 'Some dates are unavailable' };
  }

  // Verificar reservas existentes (cinto + suspensório com constraint do DB)
  const { data: conflicts } = await supabase
    .from('bookings')
    .select('id')
    .eq('property_id', propertyId)
    .in('status', ['pending', 'confirmed'])
    .or(`check_in.lt.${checkOut},check_out.gt.${checkIn}`);

  if (conflicts && conflicts.length > 0) {
    return { available: false, reason: 'Dates already booked' };
  }

  return { available: true };
}

Passo 2: Cálculo de Preço

Nunca confie em cálculos de preço do lado do cliente. Sempre recompute no servidor:

// lib/bookings/calculate-price.ts
export async function calculateBookingPrice(
  propertyId: string,
  checkIn: string,
  checkOut: string
) {
  const supabase = await createClient();

  const { data: property } = await supabase
    .from('properties')
    .select('base_price_cents, cleaning_fee_cents')
    .eq('id', propertyId)
    .single();

  if (!property) throw new Error('Property not found');

  // Obter qualquer override de preço para estas datas
  const { data: overrides } = await supabase
    .from('availability')
    .select('date, price_override_cents')
    .eq('property_id', propertyId)
    .gte('date', checkIn)
    .lt('date', checkOut)
    .not('price_override_cents', 'is', null);

  const overrideMap = new Map(
    overrides?.map(o => [o.date, o.price_override_cents]) ?? []
  );

  // Calcular preço noite por noite
  let subtotal = 0;
  const start = new Date(checkIn);
  const end = new Date(checkOut);
  const nights = Math.round(
    (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)
  );

  for (let i = 0; i < nights; i++) {
    const current = new Date(start);
    current.setDate(current.getDate() + i);
    const dateStr = current.toISOString().split('T')[0];
    const rate = overrideMap.get(dateStr) ?? property.base_price_cents;
    subtotal += rate;
  }

  const serviceFee = Math.round(subtotal * 0.12); // 12% taxa de serviço
  const total = subtotal + property.cleaning_fee_cents + serviceFee;

  return {
    nights,
    nightlyRate: property.base_price_cents,
    subtotal,
    cleaningFee: property.cleaning_fee_cents,
    serviceFee,
    total,
  };
}

Passo 3: Criar Reserva com Payment Intent

Este é o lugar onde Stripe entra em cena. Eu uso uma Server Action no Next.js 14+:

// app/actions/create-booking.ts
'use server';

import Stripe from 'stripe';
import { createClient } from '@/lib/supabase/server';
import { calculateBookingPrice } from '@/lib/bookings/calculate-price';
import { checkAvailability } from '@/lib/bookings/check-availability';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function createBooking(formData: FormData) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) throw new Error('Not authenticated');

  const propertyId = formData.get('propertyId') as string;
  const checkIn = formData.get('checkIn') as string;
  const checkOut = formData.get('checkOut') as string;
  const guestsCount = parseInt(formData.get('guests') as string);

  // Re-verificar disponibilidade
  const availability = await checkAvailability(propertyId, checkIn, checkOut);
  if (!availability.available) {
    return { error: availability.reason };
  }

  // Re-calcular preço no lado do servidor
  const pricing = await calculateBookingPrice(propertyId, checkIn, checkOut);

  // Criar Stripe Payment Intent
  const paymentIntent = await stripe.paymentIntents.create({
    amount: pricing.total,
    currency: 'usd',
    metadata: {
      propertyId,
      checkIn,
      checkOut,
      guestId: user.id,
    },
  });

  // Inserir reserva
  const { data: booking, error } = await supabase
    .from('bookings')
    .insert({
      property_id: propertyId,
      guest_id: user.id,
      check_in: checkIn,
      check_out: checkOut,
      guests_count: guestsCount,
      nightly_rate_cents: pricing.nightlyRate,
      nights: pricing.nights,
      subtotal_cents: pricing.subtotal,
      cleaning_fee_cents: pricing.cleaningFee,
      service_fee_cents: pricing.serviceFee,
      total_cents: pricing.total,
      stripe_payment_intent_id: paymentIntent.id,
      status: 'pending',
    })
    .select()
    .single();

  if (error) {
    // O constraint de exclusão vai pegar overbooking
    if (error.code === '23P01') {
      return { error: 'Estas datas foram reservadas por alguém mais.' };
    }
    throw error;
  }

  return {
    bookingId: booking.id,
    clientSecret: paymentIntent.client_secret,
  };
}

Note como nós tratamos o código de erro 23P01 — esse é o erro de violação de exclusão do PostgreSQL. Mesmo se dois usuários clicarem "Reservar" no exato mesmo milissegundo, apenas uma reserva passa.

Busca e Filtragem com PostGIS

Busca por geo-localização é inegociável para plataformas de aluguel. Aqui está uma função Postgres que lida com busca baseada em raio com filtros:

create or replace function search_properties(
  lat double precision,
  lng double precision,
  radius_km int default 50,
  min_price int default 0,
  max_price int default 100000,
  min_bedrooms int default 0,
  guest_count int default 1,
  p_check_in date default null,
  p_check_out date default null
)
returns setof public.properties
language sql stable
as $$
  select p.*
  from public.properties p
  where p.status = 'listed'
    and ST_DWithin(
      p.location,
      ST_MakePoint(lng, lat)::geography,
      radius_km * 1000
    )
    and p.base_price_cents between min_price and max_price
    and p.bedrooms >= min_bedrooms
    and p.max_guests >= guest_count
    and (
      p_check_in is null
      or not exists (
        select 1 from public.bookings b
        where b.property_id = p.id
          and b.status in ('pending', 'confirmed')
          and b.check_in < p_check_out
          and b.check_out > p_check_in
      )
    )
  order by p.location <-> ST_MakePoint(lng, lat)::geography
  limit 50;
$$;

Chame a partir do Next.js:

const { data } = await supabase.rpc('search_properties', {
  lat: 34.0522,
  lng: -118.2437,
  radius_km: 30,
  guest_count: 4,
  p_check_in: '2026-08-01',
  p_check_out: '2026-08-07',
});

Isso roda em menos de 50ms para 100K+ listagens com indexação apropriada. Nenhum Elasticsearch necessário até você atingir escala muito maior.

Autenticação e Acesso Multi-função

Supabase Auth lida com o trabalho pesado. A parte complicada é a natureza dual-função de plataformas de aluguel — alguém pode ser tanto um hóspede quanto um host.

Eu trato isso com um campo role no profile que evolui de guest para host quando eles criam seu primeiro aluguel, mais políticas de Row Level Security:

-- Hosts podem editar apenas suas próprias propriedades
create policy "hosts_manage_own_properties" on public.properties
  for all using (host_id = auth.uid());

-- Hóspedes podem ver propriedades listadas
create policy "anyone_view_listed" on public.properties
  for select using (status = 'listed');

-- Hóspedes podem ver apenas suas próprias reservas
create policy "guests_own_bookings" on public.bookings
  for select using (guest_id = auth.uid());

-- Hosts podem ver reservas para suas propriedades
create policy "hosts_property_bookings" on public.bookings
  for select using (
    property_id in (
      select id from public.properties where host_id = auth.uid()
    )
  );

RLS é genuinamente um dos recursos mais fortes do Supabase para apps multi-tenant. As regras de segurança vivem próximas aos dados, não espalhadas por middleware de API.

Processamento de Pagamentos e Payouts

Use Stripe Connect. Sem exceções. Ele lida com pagamentos de marketplace, splits, 1099s, KYC, e payouts internacionais. A alternativa é construir seu próprio sistema de transmissão de dinheiro, que é... não faça isso.

Aqui está o fluxo:

  1. Host faz onboarding via Stripe Connect Express (Stripe lida com a UI de verificação de identidade)
  2. Hóspede paga via Stripe Payment Intents
  3. Pagamento é retido até check-in + 24 horas
  4. Payout transfere para host menos sua taxa de plataforma

Precificação do Stripe Connect em 2026: 0.25% + $0.25 por payout em cima das taxas padrão de processamento (2.9% + $0.30 por transação). Para uma reserva de $200/noite, você está procurando em torno de $6.50 em taxas do Stripe. Orçamento para isso.

Mensagens em Tempo Real e Notificações

Supabase Realtime torna a mensagem host-hóspede direta:

// Subscrever a novas mensagens em uma conversa
const channel = supabase
  .channel(`conversation:${conversationId}`)
  .on(
    'postgres_changes',
    {
      event: 'INSERT',
      schema: 'public',
      table: 'messages',
      filter: `conversation_id=eq.${conversationId}`,
    },
    (payload) => {
      setMessages(prev => [...prev, payload.new]);
    }
  )
  .subscribe();

Para notificações por email (confirmações de reserva, lembretes de check-in), eu uso Resend ou SendGrid acionados por Supabase Edge Functions via webhooks de banco de dados. A precificação do Resend começa em $20/mês para 50K emails — mais que suficiente para uma plataforma em crescimento.

Manipulação de Imagens e Performance

Fotos de propriedades fazem ou quebram taxa de conversão. Cada listagem pode ter 15-30 imagens, e elas precisam carregar rápido.

Minha abordagem:

  • Fazer upload de originais para Supabase Storage
  • Usar a API de transformação de imagem do Supabase para redimensionamento on-the-fly
  • Servir via componente <Image> do Next.js com sizes e srcSet apropriados
  • Lazy-load tudo abaixo da dobra
  • Usar placeholder blur com um preview em base64 tiny gerado no tempo de upload
<Image
  src={`${SUPABASE_URL}/storage/v1/render/image/public/properties/${imageId}?width=800&quality=80`}
  alt={property.title}
  width={800}
  height={600}
  placeholder="blur"
  blurDataURL={image.blur_hash}
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>

Esta abordagem oferece LCP sub-segundo em páginas de listagem com 20+ fotos.

Considerações de Deployment e Escalabilidade

Para deployment, Vercel é a escolha natural para Next.js. Mas aqui está uma nuance que a maioria dos artigos pula: use o Vercel Edge Runtime raramente para uma plataforma de aluguel. O fluxo de reserva precisa do Node.js runtime para SDK do Stripe e operações complexas de banco de dados. Edge é ótimo para middleware (geo-redirects, verificações de auth) mas não para lógica de negócio.

Opção de Deployment Melhor Para Custo Mensal (estimativa)
Vercel Pro + Supabase Pro MVP até 10K MAU $45 - $100
Vercel Pro + Supabase Team 10K-100K MAU $200 - $500
Next.js Auto-hospedado + Supabase Pro Otimização de custo $100 - $300
AWS/GCP + Supabase Auto-hospedado Controle total em escala $500+

Supabase Pro começa em $25/mês por projeto e inclui 8GB de banco de dados, 250GB de banda, e 100GB de armazenamento. Suficiente para a maioria das MVPs e plataformas em estágio inicial.

Detalhamento de Custos: O Que Você Realmente Gastará

Aqui está o que uma verdadeira plataforma de aluguel de férias custa para construir e executar em 2026:

Item Custo MVP Custo Mensal de Execução
Supabase Pro - $25
Vercel Pro - $20
Stripe Connect - ~2.9% + $0.30/transação
Mapbox/Google Maps - $0-200 (baseado em uso)
Resend (email) - $20
Domínio + DNS (Cloudflare) $15/ano $0
Desenvolvimento (agência) $40K-120K -
Desenvolvimento (dev solo) $15K-40K -
Total Mensal de Infra - ~$65-265

Compare isso com construir em uma plataforma SaaS como Sharetribe ($299-599/mês) ou Guesty, e a economia de desenvolvimento customizado começa a fazer sentido quando você tem qualquer tração real.

Se você está sério em construir uma plataforma de aluguel e quer desenvolvedores experientes que já enviaram exatamente este tipo de produto, entre em contato com nosso time ou veja nossa página de precificação para estimativas de projeto. Nos especializamos em desenvolvimento de headless CMS e aplicações Next.js complexas.

FAQ

Quanto tempo leva para construir uma plataforma de aluguel de férias como Airbnb?

Uma MVP funcional com listagens, busca, reservas, pagamentos e mensagens leva 3-5 meses com um time experiente de 2-3 desenvolvedores. Um desenvolvedor solo pode precisar de 6-9 meses. Isso te leva ao lançamento — não à paridade de recursos com Airbnb, que tem 15+ anos de desenvolvimento. Foque em seus recursos de nicho primeiro.

Supabase é escalável o suficiente para uma plataforma de aluguel em produção?

Sim, até um ponto. Supabase Pro lida com dezenas de milhares de usuários simultâneos confortavelmente. Seu plano Team ($599/mês) suporta significativamente mais. Instagram rodava em um único servidor PostgreSQL por anos. Seu bottleneck será product-market fit muito antes de ser escala de banco de dados. Quando você realmente crescer além do Supabase, seus dados estão em PostgreSQL padrão — migração é direta.

Como você previne overbooking em um sistema de aluguel de férias?

Use constraints de exclusão do PostgreSQL com a extensão btree_gist. Isso força no nível do banco de dados que não há dois bookings ativos que possam ter intervalos de datas sobrepostos para a mesma propriedade. É o único método confiável — verificações em nível de aplicação têm race conditions. O exemplo de schema acima mostra exatamente como implementar isso.

Devo usar Stripe Connect ou construir meu próprio sistema de pagamento?

Stripe Connect. Sempre. Construir seu próprio sistema de divisão de pagamentos para um marketplace envolve licenças de transmissão de dinheiro, conformidade KYC/AML, reporte de imposto internacional, e prevenção de fraude. Stripe lida com tudo isso. As taxas (aproximadamente 3.2% por transação) valem a pena. Você sempre pode negociar taxas uma vez que está processando volume significativo.

Qual é a melhor forma de lidar com busca de propriedade com mapas?

PostGIS com Supabase para as queries de backend, e Mapbox GL JS ou JavaScript API do Google Maps para o frontend. Queries espaciais do PostGIS com índices GiST apropriados lidam com buscas de raio e bounding-box em milissegundos. Precificação do Mapbox começa com um tier gratuito generoso (50K map loads/mês). Google Maps cobra $7 por 1000 dynamic map loads após o crédito mensal de $200.

Como eu lido com preços sazonais e taxas dinâmicas?

Use uma tabela de disponibilidade/override de preço baseada em data ao lado do preço base da propriedade. Para cada noite de uma reserva, verifique se há um override de preço para aquele dia específico. Se não houver, volte ao preço base. Isso lida com taxas sazonais, premiums de fim de semana, preços de feriados, e descontos de última hora. Algumas plataformas também integram com PriceLabs ($19.99/listagem/mês) ou Beyond Pricing para precificação dinâmica automatizada.

Next.js é melhor que Astro para uma plataforma de aluguel?

Para uma plataforma de aluguel completa com fluxos de reserva interativos, mensagens, e dashboards — Next.js vence. O app precisa de significativa interatividade do lado do cliente. Astro se destaca em sites pesados em conteúdo com interatividade mínima (veja nossas capacidades de desenvolvimento Astro). Dito isso, se você está construindo um site de listagens apenas sem reservas (como um diretório), a performance do Astro seria excelente.

E quanto a apps mobile — preciso de React Native também?

Não para sua MVP. Construa o app Next.js como um PWA responsivo (Progressive Web App) primeiro. Adicione notificações push, cache offline, e um prompt "Adicionar à Tela de Início". Isso cobre 80% dos casos de uso móvel. Uma vez que você tenha validado o produto e tenha receita real, invista em apps nativos. Muitas plataformas de aluguel de nicho bem-sucedidas (Hipcamp, Glamping Hub) lançaram web-first e adicionaram apps nativos depois.