PWA de torneios com Next.js, Clerk e Redis: a stack do BeachTennis Manager
Como combinei Next.js App Router, Clerk para auth, Redis (Vercel KV) para sync na nuvem e Serwist para PWA — e por que essa combinação resolve quase qualquer SaaS solo em 2026.
O BeachTennis Manager começou como um app pra organizar um torneio entre amigos na praia. Anotávamos resultados em um caderno, calculávamos ranking de cabeça, e sempre tinha alguém que perdia o resultado de uma partida. No quarto torneio, abri o editor.
Hoje o app suporta quatro formatos de torneio (Rei/Rainha da Praia, Super 8, Chave de Simples e Chave de Duplas), tem ranking em tempo real, sync multi-dispositivo, compartilhamento por QR code e três tiers de plano (FREE, PRO, MAX).
Esse post é sobre a stack que escolhi e por quê. Spoiler: nada de exótico — Next.js, Clerk, Redis, Serwist. Mas a combinação resolve muito mais do que parece.
A pergunta original
A primeira decisão técnica não foi de stack. Foi de plataforma. App nativo ou web?
Os requisitos:
- Roda no celular (uso primário é em pé na quadra)
- Funciona offline (sinal na praia é horrível)
- Sincroniza entre dispositivos quando há sinal (organizador anota no celular, espectadores acompanham no tablet)
- Instalável (sem precisar de browser aberto)
- Atualização rápida (eu corrijo bug no domingo, está deployado em 5 minutos)
App nativo cobre 4 dos 5 requisitos, mas falha no último — fila da App Store, deploy em horas/dias, requer Mac, Apple Developer Program. PWA cobre os 5. Decidido. Já escrevi um post mais detalhado sobre PWA vs nativo nesse projeto, se quiser entrar no comparativo.
Frontend: Next.js App Router
Next.js foi a primeira escolha óbvia. Razões:
1. Server Components por padrão. A maioria das telas do app é exibição de dados (lista de torneios, ranking, partidas). Isso é Server Component puro: o servidor lê do Redis, renderiza HTML, manda. Sem JavaScript no cliente para essa parte.
2. Server Actions.
Para mutations (criar torneio, registrar placar, atualizar ranking), Server Actions são bastante convenientes. Sem rota REST, sem fetch, sem JSON manual. Você escreve a função, marca com "use server", e chama do componente cliente.
// app/actions/tournaments.ts
"use server";
import { auth } from "@clerk/nextjs/server";
import { kv } from "@vercel/kv";
import { revalidatePath } from "next/cache";
export async function createTournament(data: TournamentInput) {
const { userId } = await auth();
if (!userId) throw new Error("Unauthorized");
const tournamentId = crypto.randomUUID();
await kv.set(`tournament:${userId}:${tournamentId}`, {
id: tournamentId,
...data,
createdAt: Date.now(),
});
revalidatePath("/dashboard");
return { id: tournamentId };
}
3. Streaming + Suspense. Telas com várias seções carregam em paralelo. O usuário vê o cabeçalho enquanto o ranking ainda está sendo buscado. Sensação de velocidade.
4. App Router e route groups.
Separei rotas autenticadas em (authenticated) e públicas em (public). Layout próprio pra cada uma, middleware do Clerk só onde precisa.
Autenticação: Clerk
Tinha NextAuth e Auth0 na lista. Clerk ganhou por:
1. UI pronta sem ser cafona.
O componente <SignIn /> do Clerk é responsivo, tem dark mode, é customizável o suficiente. Em projeto solo, não ter que desenhar telas de login é ouro.
2. Middleware de proteção em uma linha.
// middleware.ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
const isProtectedRoute = createRouteMatcher(["/dashboard(.*)", "/torneios(.*)"]);
export default clerkMiddleware(async (auth, req) => {
if (isProtectedRoute(req)) await auth.protect();
});
É difícil ficar mais enxuto que isso.
3. Webhook robusto.
Quando um usuário se cadastra, o Clerk dispara webhook. Eu sincronizo na hora pro Redis, criando uma chave user:{clerkId} com plano FREE como default.
// app/api/webhooks/clerk/route.ts
import { Webhook } from "svix";
import { headers } from "next/headers";
import { kv } from "@vercel/kv";
export async function POST(req: Request) {
const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET!);
const payload = await req.text();
const headerPayload = await headers();
const evt = wh.verify(payload, {
"svix-id": headerPayload.get("svix-id")!,
"svix-timestamp": headerPayload.get("svix-timestamp")!,
"svix-signature": headerPayload.get("svix-signature")!,
});
if (evt.type === "user.created") {
await kv.set(`user:${evt.data.id}`, {
clerkId: evt.data.id,
email: evt.data.email_addresses[0].email_address,
plan: "FREE",
createdAt: Date.now(),
});
}
return new Response("ok");
}
4. Free tier bastante generoso. 10.000 MAUs gratuitos. Cobre boa parte dos projetos pessoais antes de virar negócio.
Persistência: Redis (Vercel KV / Upstash)
Aqui é onde meu projeto difere do default. Quase todo SaaS começa com Postgres. Eu fui de Redis.
A justificativa é simples: o padrão de acesso é orientado a chave. As queries que faço o tempo todo são:
- "me dá o torneio inteiro X" →
GET tournament:{userId}:{tournamentId} - "lista os torneios desse usuário" →
KEYS tournament:{userId}:* - "salva o estado do torneio Y" →
SET tournament:{userId}:{tournamentId} {json}
Ranking, partidas, jogadores: tudo dentro do JSON do torneio. Não tem cross-tenant analytics (cada torneio é isolado por usuário). Não tem full-text search. Não tem aggregations.
Postgres seria over-engineering. Redis com chaves estruturadas:
user:{clerkId}→ perfil + planotournament:{userId}:{tournamentId}→ torneio inteiroshare:{shareCode}→ mapeamento share-code → tournamentId (para QR code público)stats:{userId}→ contadores de uso
Reads são instantâneos. Não tem migration. Backup é vercel kv export. Se um dia eu precisar de relatórios cross-tenant, migro o que precisa pra Postgres na boa.
A regra prática: Redis quando o acesso é por chave, Postgres quando é por relacionamento ou agregação.
PWA: Serwist (substituto do next-pwa)
Aqui foi onde tive mais dor. Por anos, next-pwa era a opção padrão. Em 2025, o autor parou de manter — e Serwist surgiu como sucessor oficial.
Configuração mínima:
// next.config.ts
import withSerwistInit from "@serwist/next";
const withSerwist = withSerwistInit({
swSrc: "src/app/sw.ts",
swDest: "public/sw.js",
});
export default withSerwist({
// ...resto da config
});
E o Service Worker:
// src/app/sw.ts
import { defaultCache } from "@serwist/next/worker";
import { Serwist } from "serwist";
declare const self: ServiceWorkerGlobalScope & {
__SW_MANIFEST: any;
};
const serwist = new Serwist({
precacheEntries: self.__SW_MANIFEST,
skipWaiting: true,
clientsClaim: true,
navigationPreload: true,
runtimeCaching: defaultCache,
});
serwist.addEventListeners();
Resultado: o app é instalável, funciona offline (em modo "cached"), atualiza em background quando há nova versão. Tudo configurável via runtime caching strategies (cache-first, network-first, stale-while-revalidate).
O que não funciona offline: criar torneio, registrar placar, qualquer mutation que toque o Redis. Para isso eu mostraria um banner "você está offline, suas mudanças serão sincronizadas". Está no roadmap, ainda não implementei.
Outras peças: Tailwind, Radix UI, SWR, Zod
Pra fechar o loop:
- Tailwind CSS para styling. Sem
styled-components, sem CSS Modules, sem CSS-in-JS. - Radix UI para componentes acessíveis (Dialog, Dropdown, Tooltip). Sem styling — só comportamento e acessibilidade. Eu visto com Tailwind.
- SWR para data fetching no client (quando preciso, em poucos lugares). Cache automático, revalidate on focus.
- Zod para validação de inputs do usuário e respostas de API. Schema = type via
z.infer.
Cada um é a escolha "estrela do ecossistema" do Next.js. Soma um overhead pequeno, paga em DX.
O que aprendi com essa stack
Algumas coisas que levei pra projetos seguintes:
1. Server Actions reduzem muito a necessidade de REST APIs em projeto pessoal. Na maior parte dos casos, não preciso de uma API. A função roda no servidor, o componente chama, fim. Menos código, menos JSON, menos bugs.
2. Redis cobre mais do que parece. Vinha de anos achando que "todo SaaS precisa de Postgres". Não precisa. Para muitos projetos, Redis com chaves bem desenhadas é mais rápido, mais barato e mais simples.
3. Clerk é caríssimo... se você pagar. O free tier cobre projeto pessoal e MVP. Quando virar negócio com receita, eu reavalio. Mas pra começar, é o melhor custo-benefício.
4. PWA não é "app de segunda categoria". Boa parte dos usuários do BeachTennis Manager dificilmente vai pra App Store ou Play Store atrás dele. O navegador é o ponto de entrada. PWA é o app deles.
A pergunta que você deve fazer
A stack do BeachTennis Manager não é a stack ideal. É a stack que entregou um produto em produção em ~3 meses solos e que eu consigo manter sem virar fim de semana de plantão.
Se você está construindo algo parecido, a pergunta certa não é "qual a stack mais moderna". É qual a stack que tira você do paint do zero ao usuário pagante mais rápido. Em 2026, para SaaS solo, essa stack é difícil de bater.
Se você quer ver como modelei os 4 formatos de torneio em código, tem outro post só sobre isso.