Introdução
Construir aplicações financeiras com React e Next.js nunca foi tão poderoso. Com o App Router do Next.js 14/15, Server Components e as APIs modernas de cache, você pode criar dashboards de ações rápidos, tipados e otimizados para SEO.
Neste tutorial completo, vamos construir uma aplicação real que:
- Busca cotações em tempo real
- Exibe dados fundamentalistas
- Implementa cache inteligente
- Usa TypeScript para type-safety
- Segue as melhores práticas do Next.js 2026
Por que React + Next.js para Aplicações Financeiras?
Vantagens da Stack
┌─────────────────────────────────────────────────────────────┐
│ NEXT.JS + BRAPI │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Server │ │ Edge │ │ Client │ │
│ │ Components │───▶│ Cache │───▶│ Hydration │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ brapi.dev │ │ Interativo │ │
│ │ API │ │ Dashboard │ │
│ └─────────────┘ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘| Feature | Benefício |
|---|---|
| Server Components | Fetch no servidor, zero JS no cliente |
| Streaming | UI progressiva enquanto dados carregam |
| Cache | Revalidação automática com stale-while-revalidate |
| TypeScript | Type-safety end-to-end |
| SEO | SSR/SSG para indexação perfeita |
Setup do Projeto
Criando o Projeto Next.js
npx create-next-app@latest meu-dashboard-acoes --typescript --tailwind --app
cd meu-dashboard-acoesEstrutura de Pastas
meu-dashboard-acoes/
├── app/
│ ├── layout.tsx
│ ├── page.tsx
│ ├── acoes/
│ │ ├── page.tsx
│ │ └── [ticker]/
│ │ └── page.tsx
│ └── api/
│ └── cotacao/
│ └── route.ts
├── components/
│ ├── stock-card.tsx
│ ├── stock-table.tsx
│ ├── price-chart.tsx
│ └── loading-skeleton.tsx
├── lib/
│ ├── brapi.ts
│ └── types.ts
└── hooks/
└── use-stock-data.tsConfigurando Variáveis de Ambiente
BRAPI_TOKEN=seu_token_aqui
NEXT_PUBLIC_BRAPI_URL=https://brapi.dev/apiTipos TypeScript
Primeiro, vamos definir os tipos para garantir type-safety:
// lib/types.ts
export interface StockQuote {
symbol: string;
shortName: string;
longName: string;
currency: string;
regularMarketPrice: number;
regularMarketChange: number;
regularMarketChangePercent: number;
regularMarketTime: string;
regularMarketDayHigh: number;
regularMarketDayLow: number;
regularMarketVolume: number;
regularMarketPreviousClose: number;
regularMarketOpen: number;
fiftyTwoWeekHigh: number;
fiftyTwoWeekLow: number;
logourl?: string;
}
export interface StockFundamentals {
priceEarnings?: number;
earningsPerShare?: number;
dividendYield?: number;
priceToBook?: number;
returnOnEquity?: number;
debtToEquity?: number;
currentRatio?: number;
grossMargin?: number;
operatingMargin?: number;
netMargin?: number;
}
export interface StockData extends StockQuote {
fundamentals?: StockFundamentals;
historicalDataPrice?: HistoricalPrice[];
}
export interface HistoricalPrice {
date: number;
open: number;
high: number;
low: number;
close: number;
volume: number;
adjustedClose: number;
}
export interface BrapiResponse {
results: StockData[];
requestedAt: string;
took: string;
}
export interface StockListItem {
stock: string;
name: string;
close: number;
change: number;
volume: number;
market_cap: number | null;
logo: string;
sector: string | null;
type: string;
}
export interface StockListResponse {
stocks: StockListItem[];
availableSectors: string[];
availableStockTypes: string[];
currentPage: number;
totalPages: number;
totalStocks: number;
}Cliente da API brapi.dev
Agora vamos criar um cliente tipado para a API:
// lib/brapi.ts
import type { BrapiResponse, StockListResponse } from './types';
const BRAPI_URL = process.env.NEXT_PUBLIC_BRAPI_URL || 'https://brapi.dev/api';
const BRAPI_TOKEN = process.env.BRAPI_TOKEN;
interface FetchOptions {
revalidate?: number | false;
tags?: string[];
}
/**
* Busca cotação de uma ou mais ações
*/
export async function getQuote(
tickers: string | string[],
options: {
fundamental?: boolean;
dividends?: boolean;
range?: '1d' | '5d' | '1mo' | '3mo' | '6mo' | '1y' | '5y';
interval?: '1d' | '1wk' | '1mo';
} = {},
fetchOptions: FetchOptions = {}
): Promise<BrapiResponse> {
const tickerString = Array.isArray(tickers) ? tickers.join(',') : tickers;
const params = new URLSearchParams({
...(options.fundamental !== undefined && {
fundamental: String(options.fundamental)
}),
...(options.dividends !== undefined && {
dividends: String(options.dividends)
}),
...(options.range && { range: options.range }),
...(options.interval && { interval: options.interval }),
});
const url = `${BRAPI_URL}/quote/${tickerString}?${params}`;
const response = await fetch(url, {
headers: {
...(BRAPI_TOKEN && { Authorization: `Bearer ${BRAPI_TOKEN}` }),
},
next: {
revalidate: fetchOptions.revalidate ?? 300, // 5 minutos por padrão
tags: fetchOptions.tags ?? ['stock-quote'],
},
});
if (!response.ok) {
throw new Error(`Erro ao buscar cotação: ${response.status}`);
}
return response.json();
}
/**
* Lista todas as ações disponíveis
*/
export async function listStocks(
options: {
search?: string;
sortBy?: 'name' | 'close' | 'change' | 'volume' | 'market_cap';
sortOrder?: 'asc' | 'desc';
limit?: number;
page?: number;
sector?: string;
type?: 'stock' | 'fund' | 'bdr';
} = {},
fetchOptions: FetchOptions = {}
): Promise<StockListResponse> {
const params = new URLSearchParams();
if (options.search) params.set('search', options.search);
if (options.sortBy) params.set('sortBy', options.sortBy);
if (options.sortOrder) params.set('sortOrder', options.sortOrder);
if (options.limit) params.set('limit', String(options.limit));
if (options.page) params.set('page', String(options.page));
if (options.sector) params.set('sector', options.sector);
if (options.type) params.set('type', options.type);
const url = `${BRAPI_URL}/quote/list?${params}`;
const response = await fetch(url, {
headers: {
...(BRAPI_TOKEN && { Authorization: `Bearer ${BRAPI_TOKEN}` }),
},
next: {
revalidate: fetchOptions.revalidate ?? 600, // 10 minutos
tags: fetchOptions.tags ?? ['stock-list'],
},
});
if (!response.ok) {
throw new Error(`Erro ao listar ações: ${response.status}`);
}
return response.json();
}
/**
* Busca múltiplas ações em paralelo
*/
export async function getMultipleQuotes(
tickers: string[],
options: Parameters<typeof getQuote>[1] = {}
): Promise<BrapiResponse> {
// brapi já suporta múltiplos tickers separados por vírgula
return getQuote(tickers, options);
}Server Components
Página Principal com Lista de Ações
// app/acoes/page.tsx
import { Suspense } from 'react';
import { listStocks } from '@/lib/brapi';
import { StockTable } from '@/components/stock-table';
import { LoadingSkeleton } from '@/components/loading-skeleton';
export const metadata = {
title: 'Lista de Ações B3 | Meu Dashboard',
description: 'Acompanhe todas as ações da bolsa brasileira em tempo real',
};
// Componente que busca os dados (Server Component)
async function StockList() {
const data = await listStocks({
sortBy: 'volume',
sortOrder: 'desc',
limit: 50,
});
return <StockTable stocks={data.stocks} />;
}
// Página com Suspense para streaming
export default function AcoesPage() {
return (
<main className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-8">Ações da B3</h1>
<Suspense fallback={<LoadingSkeleton rows={10} />}>
<StockList />
</Suspense>
</main>
);
}Página de Detalhes da Ação
// app/acoes/[ticker]/page.tsx
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import { getQuote } from '@/lib/brapi';
import { StockCard } from '@/components/stock-card';
import { PriceChart } from '@/components/price-chart';
import { FundamentalsTable } from '@/components/fundamentals-table';
import { LoadingSkeleton } from '@/components/loading-skeleton';
interface PageProps {
params: { ticker: string };
}
// Gera metadados dinâmicos
export async function generateMetadata({ params }: PageProps) {
const ticker = params.ticker.toUpperCase();
try {
const data = await getQuote(ticker);
const stock = data.results[0];
return {
title: `${stock.shortName} (${ticker}) | Cotação e Análise`,
description: `Cotação atual de ${stock.shortName}: R$ ${stock.regularMarketPrice.toFixed(2)}. Análise fundamentalista, gráficos e indicadores.`,
};
} catch {
return {
title: `${ticker} | Ação não encontrada`,
};
}
}
// Componente de dados da ação
async function StockData({ ticker }: { ticker: string }) {
try {
const data = await getQuote(
ticker,
{ fundamental: true, range: '1y', interval: '1d' },
{ revalidate: 300, tags: [`stock-${ticker}`] }
);
const stock = data.results[0];
if (!stock) {
notFound();
}
return (
<div className="space-y-8">
<StockCard stock={stock} />
{stock.historicalDataPrice && (
<PriceChart data={stock.historicalDataPrice} symbol={ticker} />
)}
{stock.fundamentals && (
<FundamentalsTable fundamentals={stock.fundamentals} />
)}
</div>
);
} catch (error) {
throw error;
}
}
export default function TickerPage({ params }: PageProps) {
const ticker = params.ticker.toUpperCase();
return (
<main className="container mx-auto py-8">
<Suspense fallback={<LoadingSkeleton />}>
<StockData ticker={ticker} />
</Suspense>
</main>
);
}
// Gera páginas estáticas para ações populares
export async function generateStaticParams() {
const popularTickers = [
'PETR4', 'VALE3', 'ITUB4', 'BBDC4', 'ABEV3',
'WEGE3', 'RENT3', 'MGLU3', 'BBAS3', 'B3SA3'
];
return popularTickers.map((ticker) => ({
ticker: ticker.toLowerCase(),
}));
}Componentes de UI
Card de Ação
// components/stock-card.tsx
import type { StockData } from '@/lib/types';
interface StockCardProps {
stock: StockData;
}
export function StockCard({ stock }: StockCardProps) {
const isPositive = stock.regularMarketChange >= 0;
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
{stock.logourl && (
<img
src={stock.logourl}
alt={stock.shortName}
className="w-12 h-12 rounded-full"
/>
)}
<div>
<h2 className="text-2xl font-bold">{stock.symbol}</h2>
<p className="text-gray-500">{stock.shortName}</p>
</div>
</div>
<div className="text-right">
<p className="text-3xl font-bold">
R$ {stock.regularMarketPrice.toFixed(2)}
</p>
<p className={`text-lg font-medium ${
isPositive ? 'text-green-500' : 'text-red-500'
}`}>
{isPositive ? '+' : ''}{stock.regularMarketChange.toFixed(2)}
({isPositive ? '+' : ''}{stock.regularMarketChangePercent.toFixed(2)}%)
</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
<div>
<p className="text-sm text-gray-500">Abertura</p>
<p className="font-medium">R$ {stock.regularMarketOpen.toFixed(2)}</p>
</div>
<div>
<p className="text-sm text-gray-500">Máxima</p>
<p className="font-medium text-green-500">
R$ {stock.regularMarketDayHigh.toFixed(2)}
</p>
</div>
<div>
<p className="text-sm text-gray-500">Mínima</p>
<p className="font-medium text-red-500">
R$ {stock.regularMarketDayLow.toFixed(2)}
</p>
</div>
<div>
<p className="text-sm text-gray-500">Volume</p>
<p className="font-medium">
{new Intl.NumberFormat('pt-BR').format(stock.regularMarketVolume)}
</p>
</div>
</div>
</div>
);
}Tabela de Ações
// components/stock-table.tsx
import Link from 'next/link';
import type { StockListItem } from '@/lib/types';
interface StockTableProps {
stocks: StockListItem[];
}
export function StockTable({ stocks }: StockTableProps) {
return (
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="bg-gray-100 dark:bg-gray-700">
<th className="px-4 py-3 text-left">Ticker</th>
<th className="px-4 py-3 text-left">Nome</th>
<th className="px-4 py-3 text-right">Cotação</th>
<th className="px-4 py-3 text-right">Variação</th>
<th className="px-4 py-3 text-right">Volume</th>
<th className="px-4 py-3 text-left">Setor</th>
</tr>
</thead>
<tbody>
{stocks.map((stock) => (
<tr
key={stock.stock}
className="border-b hover:bg-gray-50 dark:hover:bg-gray-700"
>
<td className="px-4 py-3">
<Link
href={`/acoes/${stock.stock.toLowerCase()}`}
className="flex items-center gap-2 font-medium text-blue-600 hover:underline"
>
{stock.logo && (
<img src={stock.logo} alt="" className="w-6 h-6 rounded" />
)}
{stock.stock}
</Link>
</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-300">
{stock.name}
</td>
<td className="px-4 py-3 text-right font-mono">
R$ {stock.close.toFixed(2)}
</td>
<td className={`px-4 py-3 text-right font-medium ${
stock.change >= 0 ? 'text-green-500' : 'text-red-500'
}`}>
{stock.change >= 0 ? '+' : ''}{stock.change.toFixed(2)}%
</td>
<td className="px-4 py-3 text-right font-mono">
{new Intl.NumberFormat('pt-BR', {
notation: 'compact'
}).format(stock.volume)}
</td>
<td className="px-4 py-3 text-sm text-gray-500">
{stock.sector || '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}Skeleton de Loading
// components/loading-skeleton.tsx
interface LoadingSkeletonProps {
rows?: number;
}
export function LoadingSkeleton({ rows = 5 }: LoadingSkeletonProps) {
return (
<div className="animate-pulse space-y-4">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex items-center space-x-4">
<div className="w-10 h-10 bg-gray-200 rounded-full" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-gray-200 rounded w-1/4" />
<div className="h-3 bg-gray-200 rounded w-1/2" />
</div>
<div className="h-6 bg-gray-200 rounded w-20" />
</div>
))}
</div>
);
}Client Components e Hooks
Para funcionalidades interativas, usamos Client Components:
Hook para Busca de Ações
// hooks/use-stock-search.ts
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useDebounce } from './use-debounce';
import type { StockListItem } from '@/lib/types';
export function useStockSearch(initialQuery = '') {
const [query, setQuery] = useState(initialQuery);
const [results, setResults] = useState<StockListItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const debouncedQuery = useDebounce(query, 300);
const search = useCallback(async (searchQuery: string) => {
if (!searchQuery.trim()) {
setResults([]);
return;
}
setLoading(true);
setError(null);
try {
const response = await fetch(
`/api/cotacao/search?q=${encodeURIComponent(searchQuery)}`
);
if (!response.ok) {
throw new Error('Erro na busca');
}
const data = await response.json();
setResults(data.stocks);
} catch (err) {
setError(err instanceof Error ? err.message : 'Erro desconhecido');
setResults([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
search(debouncedQuery);
}, [debouncedQuery, search]);
return { query, setQuery, results, loading, error };
}Componente de Busca Interativa
// components/stock-search.tsx
'use client';
import { useState, useRef, useEffect } from 'react';
import Link from 'next/link';
import { useStockSearch } from '@/hooks/use-stock-search';
export function StockSearch() {
const { query, setQuery, results, loading, error } = useStockSearch();
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Fecha dropdown ao clicar fora
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current &&
!containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div ref={containerRef} className="relative w-full max-w-md">
<input
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setIsOpen(true);
}}
onFocus={() => setIsOpen(true)}
placeholder="Buscar ação (ex: PETR4, Vale, Itaú...)"
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
/>
{isOpen && (query.length > 0) && (
<div className="absolute z-50 w-full mt-1 bg-white border rounded-lg shadow-lg max-h-80 overflow-y-auto">
{loading && (
<div className="p-4 text-center text-gray-500">
Buscando...
</div>
)}
{error && (
<div className="p-4 text-center text-red-500">
{error}
</div>
)}
{!loading && results.length === 0 && query.length > 0 && (
<div className="p-4 text-center text-gray-500">
Nenhuma ação encontrada
</div>
)}
{results.map((stock) => (
<Link
key={stock.stock}
href={`/acoes/${stock.stock.toLowerCase()}`}
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 p-3 hover:bg-gray-50"
>
{stock.logo && (
<img src={stock.logo} alt="" className="w-8 h-8 rounded" />
)}
<div className="flex-1">
<p className="font-medium">{stock.stock}</p>
<p className="text-sm text-gray-500">{stock.name}</p>
</div>
<span className={`font-medium ${
stock.change >= 0 ? 'text-green-500' : 'text-red-500'
}`}>
R$ {stock.close.toFixed(2)}
</span>
</Link>
))}
</div>
)}
</div>
);
}Route Handler para API Interna
// app/api/cotacao/search/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { listStocks } from '@/lib/brapi';
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get('q') || '';
try {
const data = await listStocks({
search: query,
limit: 10,
});
return NextResponse.json(data);
} catch (error) {
console.error('Erro na busca:', error);
return NextResponse.json(
{ error: 'Erro ao buscar ações' },
{ status: 500 }
);
}
}Cache e Revalidação
Estratégias de Cache
// lib/cache-config.ts
export const cacheStrategies = {
// Cotações em tempo real - cache curto
realtime: {
revalidate: 60, // 1 minuto
},
// Dados fundamentalistas - cache médio
fundamentals: {
revalidate: 3600, // 1 hora
},
// Lista de ações - cache longo
stockList: {
revalidate: 86400, // 24 horas
},
// Dados históricos - cache muito longo
historical: {
revalidate: false, // Não revalida (dados não mudam)
},
};Revalidação On-Demand
// app/api/revalidate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { revalidateTag } from 'next/cache';
export async function POST(request: NextRequest) {
const { tag, secret } = await request.json();
// Verificar secret para segurança
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
}
try {
revalidateTag(tag);
return NextResponse.json({ revalidated: true, tag });
} catch (error) {
return NextResponse.json({ error: 'Revalidation failed' }, { status: 500 });
}
}Gráfico de Preços com Chart.js
// components/price-chart.tsx
'use client';
import { useMemo } from 'react';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
} from 'chart.js';
import { Line } from 'react-chartjs-2';
import type { HistoricalPrice } from '@/lib/types';
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
);
interface PriceChartProps {
data: HistoricalPrice[];
symbol: string;
}
export function PriceChart({ data, symbol }: PriceChartProps) {
const chartData = useMemo(() => {
const sortedData = [...data].sort((a, b) => a.date - b.date);
return {
labels: sortedData.map((d) =>
new Date(d.date * 1000).toLocaleDateString('pt-BR')
),
datasets: [
{
label: `${symbol} - Fechamento`,
data: sortedData.map((d) => d.close),
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: 0.1,
},
],
};
}, [data, symbol]);
const options = {
responsive: true,
plugins: {
legend: {
position: 'top' as const,
},
tooltip: {
callbacks: {
label: (context: any) => `R$ ${context.raw.toFixed(2)}`,
},
},
},
scales: {
y: {
ticks: {
callback: (value: number) => `R$ ${value.toFixed(2)}`,
},
},
},
};
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h3 className="text-lg font-semibold mb-4">Histórico de Preços</h3>
<Line data={chartData} options={options} />
</div>
);
}Dashboard Completo
// app/page.tsx
import { Suspense } from 'react';
import { getQuote, listStocks } from '@/lib/brapi';
import { StockCard } from '@/components/stock-card';
import { StockTable } from '@/components/stock-table';
import { StockSearch } from '@/components/stock-search';
import { LoadingSkeleton } from '@/components/loading-skeleton';
// Ações em destaque
async function FeaturedStocks() {
const data = await getQuote(
['PETR4', 'VALE3', 'ITUB4', 'WEGE3'],
{ fundamental: true }
);
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{data.results.map((stock) => (
<StockCard key={stock.symbol} stock={stock} />
))}
</div>
);
}
// Top performers do dia
async function TopPerformers() {
const data = await listStocks({
sortBy: 'change',
sortOrder: 'desc',
limit: 10,
type: 'stock',
});
return <StockTable stocks={data.stocks} />;
}
export default function HomePage() {
return (
<main className="container mx-auto py-8 space-y-12">
<section>
<h1 className="text-4xl font-bold mb-4">Dashboard de Ações</h1>
<p className="text-gray-600 mb-6">
Acompanhe o mercado brasileiro em tempo real
</p>
<StockSearch />
</section>
<section>
<h2 className="text-2xl font-bold mb-6">Ações em Destaque</h2>
<Suspense fallback={<LoadingSkeleton rows={4} />}>
<FeaturedStocks />
</Suspense>
</section>
<section>
<h2 className="text-2xl font-bold mb-6">Maiores Altas do Dia</h2>
<Suspense fallback={<LoadingSkeleton rows={10} />}>
<TopPerformers />
</Suspense>
</section>
</main>
);
}Boas Práticas
1. Tratamento de Erros
// components/error-boundary.tsx
'use client';
import { useEffect } from 'react';
interface ErrorBoundaryProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function ErrorBoundary({ error, reset }: ErrorBoundaryProps) {
useEffect(() => {
console.error('Error:', error);
}, [error]);
return (
<div className="p-8 text-center">
<h2 className="text-xl font-bold text-red-500 mb-4">
Algo deu errado
</h2>
<p className="text-gray-600 mb-4">
Não foi possível carregar os dados
</p>
<button
onClick={reset}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Tentar novamente
</button>
</div>
);
}2. Loading States com Streaming
// app/acoes/[ticker]/loading.tsx
export default function Loading() {
return (
<div className="container mx-auto py-8">
<div className="animate-pulse space-y-6">
<div className="h-8 bg-gray-200 rounded w-1/4" />
<div className="h-64 bg-gray-200 rounded" />
<div className="h-96 bg-gray-200 rounded" />
</div>
</div>
);
}3. Otimização de Imagens
// Usar next/image para logos
import Image from 'next/image';
<Image
src={stock.logourl}
alt={stock.shortName}
width={48}
height={48}
className="rounded-full"
/>Deploy para Produção
Vercel
# Deploy automático
git push origin main
# Ou deploy manual
vercel --prodVariáveis de Ambiente na Vercel
BRAPI_TOKEN=seu_token_de_producao
NEXT_PUBLIC_BRAPI_URL=https://brapi.dev/api
REVALIDATION_SECRET=sua_chave_secretaConclusão
Neste tutorial, construímos uma aplicação completa de dashboard de ações com:
- Server Components para fetch otimizado
- Streaming para UI progressiva
- Cache inteligente com revalidação
- TypeScript para segurança de tipos
- Componentes reutilizáveis e bem estruturados
Próximos Passos
- Adicionar autenticação com NextAuth.js
- Implementar watchlist personalizada
- Adicionar alertas de preço
- Integrar análise técnica com gráficos avançados
Recursos
Pronto para construir seu dashboard de ações? Crie sua conta grátis e comece a usar a API brapi.dev hoje!
