O Meu Mercado nasceu como uma aplicação simples na superfície: criar e acompanhar listas de compras mensais. Mas, por baixo da interface, o projeto resolve vários problemas clássicos de uma aplicação full-stack pequena e pronta para produção: autenticação, persistência local, seed de dados, API REST, estado no frontend, build estático, base path em subdiretório e deploy via Docker.
A ideia principal é transformar um processo doméstico recorrente em um sistema estruturado. Em vez de montar uma lista do zero todo mês, o usuário trabalha com um catálogo pré-cadastrado de produtos, quantidades ideais, categorias e histórico de listas. A aplicação permite criar listas, clonar compras anteriores, marcar itens como comprados, ajustar quantidades e acompanhar o progresso em tempo real.

Stack
O projeto usa uma stack enxuta, escolhida para reduzir complexidade operacional:
React 18 para a interface
TypeScript no frontend para tipar entidades e contratos
Vite como ferramenta de desenvolvimento e build
Tailwind CSS para a camada visual
Lucide React para iconografia
Node.js + Express no backend
SQLite como banco embarcado
better-sqlite3 como driver síncrono e performático para SQLite
bcryptjs para hash de senha
JWT para autenticação stateless
Docker para empacotamento e deploy
Nginx como reverse proxy em produção
Essa combinação funciona muito bem para aplicações com baixo custo de infraestrutura, poucos pontos móveis e necessidade de persistência confiável sem provisionar um servidor de banco separado.
Arquitetura geral
A aplicação é dividida em duas partes:
Frontend React/Vite
|
| HTTP + JWT
v
Backend Express
|
| better-sqlite3
v
SQLite
Frontend React/Vite
|
| HTTP + JWT
v
Backend Express
|
| better-sqlite3
v
SQLite
Frontend React/Vite
|
| HTTP + JWT
v
Backend Express
|
| better-sqlite3
v
SQLite
Em desenvolvimento, o Vite roda o frontend e usa proxy para encaminhar chamadas de API para o Express:
server: {
proxy: {
'/meu-mercado/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
}server: {
proxy: {
'/meu-mercado/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
}server: {
proxy: {
'/meu-mercado/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
}Em produção, o Express também serve os arquivos estáticos gerados pelo Vite:
const distPath = path.join(__dirname, '..', 'dist');
app.use(`${BASE_PATH}`, express.static(distPath));const distPath = path.join(__dirname, '..', 'dist');
app.use(`${BASE_PATH}`, express.static(distPath));const distPath = path.join(__dirname, '..', 'dist');
app.use(`${BASE_PATH}`, express.static(distPath));O mesmo servidor Node entrega o frontend e responde a API. Isso simplifica bastante o deploy: um único container sobe a aplicação inteira.
Base path em subdiretório
Um detalhe importante é que o projeto foi desenhado para rodar em:
Isso aparece tanto no Vite quanto no Express:
const BASE_PATH = '/meu-mercado';
const BASE_PATH = '/meu-mercado';
const BASE_PATH = '/meu-mercado';
Essa decisão evita problemas quando a aplicação não está na raiz do domínio, por exemplo em:
Sem esse cuidado, assets, rotas e chamadas de API poderiam quebrar em produção, especialmente atrás de um reverse proxy.
Modelo de dados
O banco usa SQLite e é inicializado automaticamente na primeira execução. A conexão é aberta com duas configurações importantes:
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');O modo WAL melhora o comportamento de leitura e escrita concorrente no SQLite. Já foreign_keys = ON garante que as relações declaradas no schema sejam respeitadas.
O modelo possui cinco tabelas principais:
users
categories
items
shopping_lists
shopping_list_items
users
categories
items
shopping_lists
shopping_list_items
users
categories
items
shopping_lists
shopping_list_items
A tabela users guarda o login:
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)
As categorias agrupam os produtos:
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
icon TEXT NOT NULL DEFAULT 'package',
sort_order INTEGER NOT NULL DEFAULT 0
)
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
icon TEXT NOT NULL DEFAULT 'package',
sort_order INTEGER NOT NULL DEFAULT 0
)
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
icon TEXT NOT NULL DEFAULT 'package',
sort_order INTEGER NOT NULL DEFAULT 0
)

Os itens representam o catálogo base da aplicação:
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL,
name TEXT NOT NULL,
ideal_qty REAL DEFAULT 0,
unit TEXT DEFAULT 'un',
perishable INTEGER DEFAULT 0,
purchase_freq TEXT DEFAULT 'Estoque',
notes TEXT DEFAULT '',
sort_order INTEGER DEFAULT 0,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
)
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL,
name TEXT NOT NULL,
ideal_qty REAL DEFAULT 0,
unit TEXT DEFAULT 'un',
perishable INTEGER DEFAULT 0,
purchase_freq TEXT DEFAULT 'Estoque',
notes TEXT DEFAULT '',
sort_order INTEGER DEFAULT 0,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
)
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL,
name TEXT NOT NULL,
ideal_qty REAL DEFAULT 0,
unit TEXT DEFAULT 'un',
perishable INTEGER DEFAULT 0,
purchase_freq TEXT DEFAULT 'Estoque',
notes TEXT DEFAULT '',
sort_order INTEGER DEFAULT 0,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
)
As listas pertencem a um usuário e possuem estado:
CREATE TABLE IF NOT EXISTS shopping_lists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
date TEXT NOT NULL,
status TEXT DEFAULT 'draft' CHECK(status IN ('draft','active','completed')),
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
CREATE TABLE IF NOT EXISTS shopping_lists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
date TEXT NOT NULL,
status TEXT DEFAULT 'draft' CHECK(status IN ('draft','active','completed')),
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
CREATE TABLE IF NOT EXISTS shopping_lists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
date TEXT NOT NULL,
status TEXT DEFAULT 'draft' CHECK(status IN ('draft','active','completed')),
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
Por fim, shopping_list_items resolve a relação entre uma lista e os itens do catálogo:
CREATE TABLE IF NOT EXISTS shopping_list_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
list_id INTEGER NOT NULL,
item_id INTEGER NOT NULL,
qty REAL NOT NULL DEFAULT 1,
purchased INTEGER DEFAULT 0,
purchased_at TEXT,
FOREIGN KEY (list_id) REFERENCES shopping_lists(id) ON DELETE CASCADE,
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE
)
CREATE TABLE IF NOT EXISTS shopping_list_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
list_id INTEGER NOT NULL,
item_id INTEGER NOT NULL,
qty REAL NOT NULL DEFAULT 1,
purchased INTEGER DEFAULT 0,
purchased_at TEXT,
FOREIGN KEY (list_id) REFERENCES shopping_lists(id) ON DELETE CASCADE,
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE
)
CREATE TABLE IF NOT EXISTS shopping_list_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
list_id INTEGER NOT NULL,
item_id INTEGER NOT NULL,
qty REAL NOT NULL DEFAULT 1,
purchased INTEGER DEFAULT 0,
purchased_at TEXT,
FOREIGN KEY (list_id) REFERENCES shopping_lists(id) ON DELETE CASCADE,
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE
)

Essa separação é importante porque o item do catálogo continua sendo uma referência global, enquanto a quantidade e o status de compra são específicos de cada lista.
Seed automático
Na primeira execução, o backend verifica se já existem categorias:
const catCount = db.prepare('SELECT COUNT(*) as c FROM categories').get().c;
if (catCount > 0) return;const catCount = db.prepare('SELECT COUNT(*) as c FROM categories').get().c;
if (catCount > 0) return;const catCount = db.prepare('SELECT COUNT(*) as c FROM categories').get().c;
if (catCount > 0) return;Se o banco estiver vazio, ele popula categorias e produtos padrão usando uma transaction:
const transaction = db.transaction(() => {
categories.forEach((cat, catIdx) => {
const result = insertCat.run(cat.name, cat.icon, catIdx + 1);
const catId = result.lastInsertRowid;
cat.items.forEach((item, itemIdx) => {
insertItem.run(
catId,
item.name,
item.ideal_qty || 0,
item.unit || 'un',
item.perishable ? 1 : 0,
item.purchase_freq || 'Estoque',
item.notes || '',
itemIdx + 1
);
});
});
});const transaction = db.transaction(() => {
categories.forEach((cat, catIdx) => {
const result = insertCat.run(cat.name, cat.icon, catIdx + 1);
const catId = result.lastInsertRowid;
cat.items.forEach((item, itemIdx) => {
insertItem.run(
catId,
item.name,
item.ideal_qty || 0,
item.unit || 'un',
item.perishable ? 1 : 0,
item.purchase_freq || 'Estoque',
item.notes || '',
itemIdx + 1
);
});
});
});const transaction = db.transaction(() => {
categories.forEach((cat, catIdx) => {
const result = insertCat.run(cat.name, cat.icon, catIdx + 1);
const catId = result.lastInsertRowid;
cat.items.forEach((item, itemIdx) => {
insertItem.run(
catId,
item.name,
item.ideal_qty || 0,
item.unit || 'un',
item.perishable ? 1 : 0,
item.purchase_freq || 'Estoque',
item.notes || '',
itemIdx + 1
);
});
});
});Usar transaction aqui evita um estado parcial do banco caso alguma inserção falhe durante o seed.
Autenticação com JWT
O fluxo de autenticação é simples:
O usuário registra ou faz login com email e senha
O backend valida as credenciais
A senha é comparada com bcrypt
O backend gera um JWT
O frontend salva o token e o usuário no localStorage
Chamadas autenticadas enviam Authorization Bearer
No cliente, o header é montado centralmente:
function getHeaders(): HeadersInit {
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
const token = localStorage.getItem('token');
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}function getHeaders(): HeadersInit {
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
const token = localStorage.getItem('token');
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}function getHeaders(): HeadersInit {
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
const token = localStorage.getItem('token');
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}Isso evita repetir lógica de autenticação em cada chamada de API.
API REST
A API é organizada em recursos:
POST /meu-mercado/api/auth/register
POST /meu-mercado/api/auth/login
GET /meu-mercado/api/categories
GET /meu-mercado/api/items
GET /meu-mercado/api/lists
POST /meu-mercado/api/lists
GET /meu-mercado/api/lists/:id
PUT /meu-mercado/api/lists/:id
DELETE /meu-mercado/api/lists/:id
POST /meu-mercado/api/lists/:id/clone
POST /meu-mercado/api/lists/:id/items
DELETE /meu-mercado/api/lists/:id/items/:itemId
PATCH /meu-mercado/api/lists/:id/items/:itemId
POST /meu-mercado/api/auth/register
POST /meu-mercado/api/auth/login
GET /meu-mercado/api/categories
GET /meu-mercado/api/items
GET /meu-mercado/api/lists
POST /meu-mercado/api/lists
GET /meu-mercado/api/lists/:id
PUT /meu-mercado/api/lists/:id
DELETE /meu-mercado/api/lists/:id
POST /meu-mercado/api/lists/:id/clone
POST /meu-mercado/api/lists/:id/items
DELETE /meu-mercado/api/lists/:id/items/:itemId
PATCH /meu-mercado/api/lists/:id/items/:itemId
POST /meu-mercado/api/auth/register
POST /meu-mercado/api/auth/login
GET /meu-mercado/api/categories
GET /meu-mercado/api/items
GET /meu-mercado/api/lists
POST /meu-mercado/api/lists
GET /meu-mercado/api/lists/:id
PUT /meu-mercado/api/lists/:id
DELETE /meu-mercado/api/lists/:id
POST /meu-mercado/api/lists/:id/clone
POST /meu-mercado/api/lists/:id/items
DELETE /meu-mercado/api/lists/:id/items/:itemId
PATCH /meu-mercado/api/lists/:id/items/:itemId
Um ponto interessante está na listagem de listas. A API retorna metadados agregados de progresso:
SELECT sl.*,
COUNT(sli.id) as total_items,
SUM(CASE WHEN sli.purchased = 1 THEN 1 ELSE 0 END) as purchased_items
FROM shopping_lists sl
LEFT JOIN shopping_list_items sli ON sl.id = sli.list_id
WHERE sl.user_id = ?
GROUP BY sl.id
ORDER BY sl.date DESC, sl.created_at DESC
SELECT sl.*,
COUNT(sli.id) as total_items,
SUM(CASE WHEN sli.purchased = 1 THEN 1 ELSE 0 END) as purchased_items
FROM shopping_lists sl
LEFT JOIN shopping_list_items sli ON sl.id = sli.list_id
WHERE sl.user_id = ?
GROUP BY sl.id
ORDER BY sl.date DESC, sl.created_at DESC
SELECT sl.*,
COUNT(sli.id) as total_items,
SUM(CASE WHEN sli.purchased = 1 THEN 1 ELSE 0 END) as purchased_items
FROM shopping_lists sl
LEFT JOIN shopping_list_items sli ON sl.id = sli.list_id
WHERE sl.user_id = ?
GROUP BY sl.id
ORDER BY sl.date DESC, sl.created_at DESC
Com isso, o frontend não precisa buscar todos os itens de todas as listas para renderizar o dashboard.
Frontend mobile-first
A interface foi pensada para uso no celular, que é o dispositivo mais provável durante uma compra.
O app trabalha com uma largura máxima de conteúdo e componentes grandes o suficiente para toque:
<div className="max-w-2xl mx-auto px-4 py-6 space-y-6">
<div className="max-w-2xl mx-auto px-4 py-6 space-y-6">
<div className="max-w-2xl mx-auto px-4 py-6 space-y-6">
O fluxo principal possui três telas:
Dashboard
Nova Lista
Lista de Compras
Em vez de usar um roteador externo, a navegação entre telas é controlada por estado local:
type View = 'dashboard' | 'new-list' | 'shopping-list';
const [view, setView] = useState<View>('dashboard');type View = 'dashboard' | 'new-list' | 'shopping-list';
const [view, setView] = useState<View>('dashboard');type View = 'dashboard' | 'new-list' | 'shopping-list';
const [view, setView] = useState<View>('dashboard');Estado otimista na lista de compras
Na tela de compra, marcar um item como comprado atualiza a UI imediatamente:
setList(prev => prev ? {
...prev,
purchased_items: (prev.purchased_items || 0) + (newPurchased ? 1 : -1),
items: prev.items.map(i =>
i.id === item.id
? { ...i, purchased: newPurchased ? 1 : 0, purchased_at: newPurchased ? new Date().toISOString() : null }
: i
),
} : null);setList(prev => prev ? {
...prev,
purchased_items: (prev.purchased_items || 0) + (newPurchased ? 1 : -1),
items: prev.items.map(i =>
i.id === item.id
? { ...i, purchased: newPurchased ? 1 : 0, purchased_at: newPurchased ? new Date().toISOString() : null }
: i
),
} : null);setList(prev => prev ? {
...prev,
purchased_items: (prev.purchased_items || 0) + (newPurchased ? 1 : -1),
items: prev.items.map(i =>
i.id === item.id
? { ...i, purchased: newPurchased ? 1 : 0, purchased_at: newPurchased ? new Date().toISOString() : null }
: i
),
} : null);Esse é um exemplo de optimistic UI: o usuário sente a resposta instantaneamente enquanto a API confirma a alteração.
Docker e deploy
O Dockerfile empacota backend e frontend no mesmo container:
FROM node:20-slim
WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists
FROM node:20-slim
WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists
FROM node:20-slim
WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists
O volume em /app/data garante persistência do SQLite mesmo recriando containers.
Evolução futura com Flutter
Uma evolução natural do projeto seria transformar o sistema em um app mobile nativo usando Flutter.
O objetivo seria manter a filosofia local-first:
listas funcionando offline
dados persistidos no dispositivo
sincronização opcional com backend
Arquitetura prevista:
Flutter App
|
| leitura/escrita local
v
SQLite no dispositivo
|
| sincronização opcional
v
API Node/Express
|
v
SQLite/Postgres no servidor
Flutter App
|
| leitura/escrita local
v
SQLite no dispositivo
|
| sincronização opcional
v
API Node/Express
|
v
SQLite/Postgres no servidor
Flutter App
|
| leitura/escrita local
v
SQLite no dispositivo
|
| sincronização opcional
v
API Node/Express
|
v
SQLite/Postgres no servidor
Isso permitiria:
uso offline no mercado
sincronização entre dispositivos
backup opcional
futura publicação em iOS e Android
Trade-offs da arquitetura
Toda arquitetura possui escolhas.
Neste projeto, alguns trade-offs são claros:
JWT em localStorage
Simples e funcional, mas menos seguro que cookies HttpOnly.
Navegação por estado local
Reduz complexidade, mas não gera URLs compartilháveis.
SQLite embarcado
Excelente para baixo custo e deploy simples, mas limitado em cenários muito concorrentes.
Backend servindo frontend
Simplifica deploy, mas acopla frontend e API.
Conclusão
O Meu Mercado é um exemplo de aplicação full-stack pragmática:
resolve um problema real
usa uma stack enxuta
evita infraestrutura desnecessária
mas ainda segue boas práticas de engenharia
A maior virtude do projeto está na coerência entre escopo e arquitetura.
Ele não tenta parecer maior do que é, mas também não ignora fundamentos importantes:
autenticação
persistência
transações
organização de dados
deploy reproduzível
experiência mobile-first
Para produtos internos, operações pequenas ou projetos pessoais, a combinação de React + Express + SQLite + Docker entrega muito valor com baixa complexidade operacional.