Vamos construir o Onda, um player de música com Next.js no frontend e NestJS no backend. O projeto busca duas faixas na API, permite escolher o áudio e usa controles nativos para reproduzir, pausar, ajustar o volume e avançar na linha do tempo.
O objetivo é aprender uma arquitetura pequena e clara: contrato compartilhado, responsabilidade separada por camada, dependências injetadas e validação dos dados recebidos. Você terá todos os arquivos necessários para reproduzir o exemplo; os blocos de código são texto selecionável, prontos para copiar.
Escopo: MVP local, catálogo em memória e duas demos sintetizadas pelo próprio projeto. Não implementa login, upload de músicas, playlists persistentes, DRM ou streaming adaptativo. A capa é uma ilustração; as capturas das etapas mostram o aplicativo real deste tutorial.
1. Entenda a arquitetura antes de começar

Etapa 1 — fluxo de metadados e áudio; o repositório em memória pode ser substituído por persistência.
O Server Component do Next.js carrega os metadados na API e entrega uma lista serializável ao Client Component do player. A variável API_URL permanece no servidor. O navegador recebe apenas NEXT_PUBLIC_MEDIA_ORIGIN, um endereço público de mídia, e solicita o arquivo quando o usuário dá play.
No backend, o controller traduz HTTP, o serviço coordena a consulta e o repositório guarda a implementação de acesso aos dados. A porta abstrata TrackRepository permite trocar a implementação sem reescrever o controller. É uma separação pragmática inspirada em portas e adaptadores, sem tentar apresentar este MVP como uma plataforma completa.
2. Prepare o ambiente e as pastas
Use uma versão LTS suportada do Node.js, por exemplo Node 24 LTS, com npm. Este exemplo fixa Next.js 16.4.3, React 19.2.9 e NestJS 11.2.8; essas são as versões usadas na validação. Confira a compatibilidade antes de atualizar. As portas são 4310 para o frontend e 4311 para a API. Os comandos abaixo são para o terminal; no Windows, use npm.cmd se a política do PowerShell bloquear npm.
node --version
npm --version
mkdir music-player
cd music-player Crie as pastas com este comando, que funciona em shells diferentes porque a criação é feita pelo Node:
node -e "['apps/api/src/tracks/domain','apps/api/src/tracks/application','apps/api/src/tracks/infrastructure','apps/api/src/tracks/presentation','apps/api/media','apps/web/src/app','apps/web/src/features/tracks','apps/web/src/features/player','packages/contracts','scripts'].forEach(p=>require('node:fs').mkdirSync(p,{recursive:true}))" music-player/
package.json
packages/contracts/ # contrato TypeScript sem dependências de framework
apps/api/src/tracks/
domain/ # porta do repositório
application/ # consultas do catálogo
infrastructure/ # armazenamento em memória
presentation/ # endpoints HTTP
apps/api/media/ # somente arquivos públicos autorizados
apps/web/src/app/ # páginas e layout do App Router
apps/web/src/features/
tracks/ # integração HTTP no servidor
player/ # interação com o áudio no navegador
scripts/ # geração de áudio e verificação Crie primeiro os três manifests package.json e o contrato abaixo. Só execute npm install depois de criar também os manifests da API e do frontend apresentados nos próximos passos. O npm precisa enxergar todos os workspaces.
Arquivo: package.json
{
"name": "music-player-tutorial",
"private": true,
"workspaces": ["apps/*", "packages/*"],
"overrides": { "@nestjs/platform-express": { "multer": "2.5.0" } },
"scripts": {
"audio": "node scripts/generate-audio.mjs",
"build": "npm run build -w @music/api && npm run build -w @music/web",
"api": "npm run start -w @music/api",
"web": "npm run start -w @music/web",
"verify": "node scripts/verify.mjs"
}
}Arquivo: .gitignore
node_modules/
.next/
dist/
.env
.env.local
apps/api/media/*.wavArquivo: packages/contracts/package.json
{"name":"@music/contracts","version":"1.0.0","private":true,"types":"index.d.ts"}Arquivo: packages/contracts/index.d.ts
export interface Track {
id: string;
title: string;
artist: string;
src: string;
}O overrides na raiz fixa Multer 2.5.0, corrigindo uma dependência transitiva do adaptador Express. A versão anterior é afetada por negação de serviço com fileFilter assíncrono, conforme o aviso oficial. O tutorial não usa upload; a dependência foi corrigida mesmo assim. O audit de produção retornou zero alertas conhecidos na validação, sem que isso substitua uma revisão de segurança.
3. Configure a API NestJS
Vamos criar os arquivos diretamente, sem depender de opções interativas de geradores. Não é necessário instalar a CLI globalmente. O TypeScript compila a API em CommonJS; os decorators e seus metadados ficam habilitados para a injeção do Nest.
Arquivo: apps/api/package.json
{
"name": "@music/api",
"private": true,
"scripts": {"build":"tsc -p tsconfig.json", "start":"node --env-file=.env dist/main.js"},
"dependencies": {
"@music/contracts":"1.0.0",
"@nestjs/common":"11.2.8",
"@nestjs/core":"11.2.8",
"@nestjs/platform-express":"11.2.8",
"reflect-metadata":"0.2.2",
"rxjs":"7.8.2"
},
"devDependencies": {"@types/node":"^22.0.0","typescript":"^5.9.0"}
}Arquivo: apps/api/tsconfig.json
{
"compilerOptions": {
"target":"ES2022", "module":"commonjs", "moduleResolution":"node",
"outDir":"dist", "rootDir":"src", "strict":true,
"experimentalDecorators":true, "emitDecoratorMetadata":true,
"esModuleInterop":true, "skipLibCheck":true
},
"include":["src/**/*.ts"]
}Arquivo: apps/api/.env.example
PORT=4311
WEB_ORIGIN=http://localhost:4310Arquivo: apps/api/src/main.ts
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'node:path';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.disable('x-powered-by');
app.setGlobalPrefix('v1');
app.enableCors({
origin: process.env.WEB_ORIGIN ?? 'http://localhost:4310',
methods: ['GET', 'HEAD'],
exposedHeaders: ['Accept-Ranges', 'Content-Range'],
});
app.useStaticAssets(join(process.cwd(), 'media'), {
prefix: '/media/', dotfiles: 'deny', index: false,
});
app.enableShutdownHooks();
await app.listen(Number(process.env.PORT ?? 4311), '127.0.0.1');
}
void bootstrap();Arquivo: apps/api/src/app.module.ts
import { Module } from '@nestjs/common';
import { TracksModule } from './tracks/tracks.module';
@Module({ imports: [TracksModule] })
export class AppModule {}Copie apps/api/.env.example para apps/api/.env. useStaticAssets() usa a implementação de arquivos estáticos do Express, que oferece suporte a requisições Range. Isso permite buscar trechos do áudio sem carregar o arquivo inteiro em memória no controller. O prefixo /v1 vale para o catálogo; a mídia usa /media.
Somente o diretório media é público. Nunca aponte esse diretório para a raiz do projeto ou guarde segredos nele. A configuração de CORS restringe a origem do navegador, mas CORS não é autenticação. A API escuta em loopback porque o tutorial é local.
4. Implemente as camadas do catálogo
O contrato compartilhado é uma declaração TypeScript: não vira uma biblioteca de runtime nem depende de React ou Nest. O repositório mantém duas faixas de demonstração e retorna cópias dos registros, evitando que consumidores alterem o catálogo original por referência.
Arquivo: apps/api/src/tracks/domain/track.repository.ts
import type { Track } from '@music/contracts';
export abstract class TrackRepository {
abstract findAll(): Promise<Track[]>;
abstract findById(id: string): Promise<Track | null>;
}Arquivo: apps/api/src/tracks/infrastructure/in-memory-track.repository.ts
import type { Track } from '@music/contracts';
import { TrackRepository } from '../domain/track.repository';
export class InMemoryTrackRepository extends TrackRepository {
private readonly tracks: Track[] = [
{ id: '11111111-1111-4111-8111-111111111111', title: 'Noite digital',
artist: 'Demo sintetizada', src: '/media/noite.wav' },
{ id: '22222222-2222-4222-8222-222222222222', title: 'Órbita azul',
artist: 'Demo sintetizada', src: '/media/orbita.wav' },
];
async findAll(): Promise<Track[]> {
return this.tracks.map(track => ({ ...track }));
}
async findById(id: string): Promise<Track | null> {
const track = this.tracks.find(item => item.id === id);
return track ? { ...track } : null;
}
}Arquivo: apps/api/src/tracks/application/tracks.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { TrackRepository } from '../domain/track.repository';
@Injectable()
export class TracksService {
constructor(private readonly repository: TrackRepository) {}
list() { return this.repository.findAll(); }
async find(id: string) {
const track = await this.repository.findById(id);
if (!track) throw new NotFoundException('Faixa não encontrada');
return track;
}
}Arquivo: apps/api/src/tracks/presentation/tracks.controller.ts
import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common';
import { TracksService } from '../application/tracks.service';
@Controller('tracks')
export class TracksController {
constructor(private readonly service: TracksService) {}
@Get()
list() { return this.service.list(); }
@Get(':id')
find(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.service.find(id);
}
}Arquivo: apps/api/src/tracks/tracks.module.ts
import { Module } from '@nestjs/common';
import { TrackRepository } from './domain/track.repository';
import { InMemoryTrackRepository } from './infrastructure/in-memory-track.repository';
import { TracksService } from './application/tracks.service';
import { TracksController } from './presentation/tracks.controller';
@Module({
controllers: [TracksController],
providers: [TracksService,
{ provide: TrackRepository, useClass: InMemoryTrackRepository }],
})
export class TracksModule {}ParseUUIDPipe rejeita identificadores fora do formato esperado. Uma faixa inexistente produz 404. Em um endpoint futuro de criação, acrescente DTOs e validação do corpo; este exemplo só expõe leitura. O catálogo usa uma lista explícita de arquivos, sem montar caminhos a partir de texto enviado pelo usuário.
5. Gere áudio para testar sem serviços externos
O script a seguir cria dois arquivos WAV mono de 12 segundos. São melodias sintéticas simples para validar o player, não músicas comerciais nem amostras retiradas de plataformas. WAV facilita o exemplo; em produção, codecs e taxas de compressão precisam considerar qualidade, banda e compatibilidade.
Arquivo: scripts/generate-audio.mjs
import { mkdir, writeFile } from 'node:fs/promises';
const folder = new URL('../apps/api/media/', import.meta.url);
await mkdir(folder, { recursive: true });
const rate = 22050;
const seconds = 12;
for (const [name, notes] of [
['noite', [261.63, 329.63, 392, 523.25]],
['orbita', [220, 277.18, 329.63, 440]],
]) {
const samples = rate * seconds;
const out = Buffer.alloc(44 + samples * 2);
out.write('RIFF'); out.writeUInt32LE(out.length - 8, 4); out.write('WAVE', 8);
out.write('fmt ', 12); out.writeUInt32LE(16, 16); out.writeUInt16LE(1, 20);
out.writeUInt16LE(1, 22); out.writeUInt32LE(rate, 24);
out.writeUInt32LE(rate * 2, 28); out.writeUInt16LE(2, 32); out.writeUInt16LE(16, 34);
out.write('data', 36); out.writeUInt32LE(samples * 2, 40);
for (let i = 0; i < samples; i++) {
const t = i / rate;
const note = notes[Math.floor(t * 2) % notes.length];
const phase = (t * 2) % 1;
const envelope = Math.sin(Math.PI * phase) ** 2;
const fade = Math.min(1, t * 8, (seconds - t) * 8);
const value = Math.sin(2 * Math.PI * note * t) * envelope * fade * 0.18;
out.writeInt16LE(Math.round(value * 32767), 44 + i * 2);
}
await writeFile(new URL(`${name}.wav`, folder), out);
}
console.log('Duas demos de 12 segundos geradas em apps/api/media.');6. Configure o frontend Next.js
O App Router separa a página executada no servidor do componente interativo. Não adicione 'use client' à aplicação inteira. A fronteira do navegador fica no player, onde precisamos de estado, eventos e referência ao elemento de áudio.
Arquivo: apps/web/package.json
{
"name":"@music/web", "private":true,
"scripts":{"dev":"next dev -p 4310","build":"next build","start":"next start -p 4310"},
"dependencies":{"@music/contracts":"1.0.0","next":"16.4.3","react":"19.2.9","react-dom":"19.2.9","zod":"^4.0.0","server-only":"0.0.1"},
"devDependencies":{"typescript":"^5.9.0","@types/node":"^22.0.0","@types/react":"^19.0.0","@types/react-dom":"^19.0.0"}
}Arquivo: apps/web/tsconfig.json
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}Arquivo: apps/web/.env.local.example
API_URL=http://127.0.0.1:4311
NEXT_PUBLIC_MEDIA_ORIGIN=http://localhost:4311Arquivo: apps/web/src/features/tracks/api.ts
import 'server-only';
import { z } from 'zod';
import type { Track } from '@music/contracts';
const tracksSchema = z.array(z.object({
id: z.uuid(), title: z.string().min(1), artist: z.string().min(1),
src: z.string().regex(/^\/media\/[a-z0-9-]+\.wav$/),
}));
export async function getTracks(): Promise<Track[]> {
const response = await fetch(`${process.env.API_URL}/v1/tracks`, {
cache: 'no-store', signal: AbortSignal.timeout(5000),
});
if (!response.ok) throw new Error('Não foi possível carregar o catálogo');
return tracksSchema.parse(await response.json());
}Arquivo: apps/web/src/app/layout.tsx
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import './globals.css';
export const metadata: Metadata = { title: 'Onda — player de música', description: 'Tutorial Next.js e NestJS: música com boa arquitetura.' };
export default function RootLayout({ children }: { children: ReactNode }) {
return <html lang="pt-BR"><body>{children}</body></html>;
}Arquivo: apps/web/src/app/page.tsx
import { getTracks } from '../features/tracks/api';
import { Player } from '../features/player/player';
import type { Track } from '@music/contracts';
export const dynamic = 'force-dynamic';
export default async function Home() {
let tracks: Track[] = [];
let failed = false;
try { tracks = await getTracks(); } catch { failed = true; }
return (
<main>
<header><a className="brand" href="/">onda<span>.</span></a><span className="badge">NEXT.JS + NESTJS</span></header>
<div className="intro"><p className="eyebrow">MÚSICA, COM UMA BOA BASE.</p><h1>Seu som.<br /><span>Sua frequência.</span></h1>
<p>Um player simples. Uma arquitetura preparada para evoluir.</p></div>
{failed ? <div className="failure" role="alert"><h2>Catálogo indisponível</h2><p>Verifique a API na porta 4311 e tente novamente.</p><a href="/">Tentar novamente</a></div> : <Player tracks={tracks} />}
<footer>Projeto didático · catálogo em memória · sem serviços pagos</footer>
</main>
);
}Copie apps/web/.env.local.example para apps/web/.env.local. O prefixo NEXT_PUBLIC_ torna o valor visível no bundle do navegador: use-o apenas para configuração pública. O host da API não precisa ser exposto por esse prefixo, pois a consulta do catálogo acontece no servidor. Valores públicos são incorporados no build; recompile depois de alterá-los.
O fetch usa timeout de cinco segundos e cache: 'no-store'. Zod valida o JSON em runtime; uma interface TypeScript sozinha não valida uma resposta HTTP. A página mostra um estado de erro se a API estiver indisponível, e o player informa quando o catálogo está vazio.
7. Crie o player com controles acessíveis

Etapa 7 — resultado real do frontend: catálogo integrado à API e player com controles do navegador.
Usamos um único <audio controls preload="none">. Os controles nativos oferecem reprodução, pausa, volume e navegação pelo áudio sem criar sliders personalizados. Botões com nome acessível selecionam a faixa. A reprodução nasce de um clique, respeitando as restrições de autoplay do navegador; a rejeição de play() é tratada.
Arquivo: apps/web/src/features/player/player.tsx
'use client';
import { useRef, useState } from 'react';
import type { Track } from '@music/contracts';
export function Player({ tracks }: { tracks: Track[] }) {
const audioRef = useRef<HTMLAudioElement>(null);
const [selected, setSelected] = useState<Track | null>(tracks[0] ?? null);
const [playing, setPlaying] = useState(false);
const [error, setError] = useState('');
const mediaOrigin = process.env.NEXT_PUBLIC_MEDIA_ORIGIN;
async function select(track: Track) {
const audio = audioRef.current;
if (!audio || !mediaOrigin) return;
setError('');
setSelected(track);
// Alterar a fonte e chamar play no clique preserva a interação do usuário.
audio.src = new URL(track.src, mediaOrigin).href;
audio.load();
try { await audio.play(); }
catch (cause) {
// Uma troca rápida de faixa pode cancelar a reprodução anterior.
if (cause instanceof DOMException && cause.name === 'AbortError') return;
setError('Não foi possível reproduzir. Use o controle de áudio para tentar novamente.');
}
}
if (!tracks.length) return <p role="status">Nenhuma faixa disponível.</p>;
if (!mediaOrigin) return <p role="alert">Configure NEXT_PUBLIC_MEDIA_ORIGIN.</p>;
return (
<div className="player-grid">
<section className="library" aria-labelledby="library-title">
<p className="eyebrow">SUA BIBLIOTECA</p>
<h2 id="library-title">Escolha o próximo som.</h2>
<p className="muted">Duas demos originais, sintetizadas para este tutorial.</p>
<ol className="tracks">
{tracks.map((track, index) => (
<li key={track.id}>
<button type="button" className="track" onClick={() => void select(track)}
aria-pressed={selected?.id === track.id} aria-label={`Reproduzir ${track.title}`}>
<span className="number">0{index + 1}</span>
<span><strong>{track.title}</strong><small>{track.artist}</small></span>
<span className="track-action" aria-hidden="true">Ouvir →</span>
</button>
</li>
))}
</ol>
<p className="library-note">Catálogo via API NestJS · áudio sob demanda</p>
</section>
<section className="now-playing" aria-label="Player de música">
<div className="album" aria-hidden="true"><div className="record"><span /></div></div>
<p className="eyebrow">{playing ? 'REPRODUZINDO AGORA' : 'PRONTO PARA O PLAY'}</p>
<h2>{selected?.title}</h2><p className="muted">{selected?.artist}</p>
<audio ref={audioRef} controls preload="none"
src={selected ? new URL(selected.src, mediaOrigin).href : undefined}
aria-label={`Áudio: ${selected?.title ?? 'faixa'}`}
onPlay={() => { setPlaying(true); setError(''); }}
onPause={() => setPlaying(false)} onEnded={() => setPlaying(false)}
onError={() => { setPlaying(false); setError('Falha ao carregar o arquivo de áudio.'); }} />
<p className="play-status" role="status">{playing ? 'Reproduzindo' : 'Pausado'}</p>
{error && <p className="error" role="alert">{error}</p>}
</section>
</div>
);
}Os eventos play, pause, ended e error sincronizam o texto de status. Não usamos um temporizador para simular progresso. Quando o usuário troca de faixa rapidamente, AbortError pode indicar que uma reprodução foi substituída; esse caso é tratado separadamente.
8. Aplique o visual e a responsividade
O CSS abaixo produz a interface mostrada nas capturas: duas colunas em telas maiores e uma coluna no celular. O disco é desenhado com CSS, sem depender de imagens externas. O foco de teclado permanece visível.
Arquivo: apps/web/src/app/globals.css
:root { color-scheme: dark; --bg:#0b0d16; --panel:#141827; --muted:#a8b0c5; --accent:#ad9bff; }
* { box-sizing:border-box; }
body { margin:0; background:radial-gradient(ellipse at 80% 5%,#262044 0%,transparent 50%),var(--bg); color:#f7f6ff; font-family:Arial,Helvetica,sans-serif; }
main { width:min(1120px,calc(100% - 48px)); margin:auto; }
header { padding:30px 0; display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid #ffffff16; }
a { color:var(--accent); } .brand { text-decoration:none; font-size:34px; font-weight:800; color:white; letter-spacing:-2px; } .brand span{color:var(--accent);}
.badge { color:#c9c2e6; font-size:11px; letter-spacing:2px; border:1px solid #ffffff25; padding:10px 14px; border-radius:20px; }
.intro { margin:42px 0 34px; } .eyebrow { color:var(--accent); font-size:11px; font-weight:700; letter-spacing:2px; }
h1 { font-size:clamp(38px,5vw,66px); letter-spacing:-3px; line-height:1.06; margin:18px 0; } h1 span{color:#aaa5c5;} .intro>p:last-child{color:var(--muted);font-size:16px;}
.player-grid { display:grid; grid-template-columns:1.2fr 1fr; gap:24px; align-items:start; }
.library,.now-playing,.failure { background:linear-gradient(130deg,#1a1d2e,#111521); border:1px solid #ffffff16; border-radius:24px; padding:30px; }
h2 { font-size:25px; letter-spacing:-.7px; margin:12px 0; } .muted{color:var(--muted);line-height:1.65;font-size:14px;}
.tracks { list-style:none; padding:0; margin:30px 0; display:grid; gap:12px; }
.track { width:100%; display:flex; gap:16px; align-items:center; background:#ffffff05; border:1px solid #ffffff12; border-radius:14px; padding:19px 16px; color:inherit; text-align:left; cursor:pointer; }
.track[aria-pressed=true] { background:#aa8cff14; border-color:#ab95fb80; } .number { font-size:12px; color:var(--muted); } .track strong{display:block;font-size:15px;} .track small{display:block;margin-top:6px;color:var(--muted);} .track-action{margin-left:auto;color:var(--accent);font-size:12px;white-space:nowrap;}
button:focus-visible,a:focus-visible,audio:focus-visible { outline:3px solid #b9a9ff;outline-offset:5px; }
.library-note { border-top:1px solid #ffffff15;padding-top:22px;color:var(--muted);font-size:12px; }
.now-playing { text-align:center; } .album { width:min(230px,100%);aspect-ratio:1;margin:0 auto 25px;background:linear-gradient(140deg,#ae89ff,#3c45a4 50%,#48b6cd);border-radius:18px;display:grid;place-items:center;box-shadow:0 15px 45px #06081480; }
.record { width:75%;aspect-ratio:1;border-radius:50%;background:repeating-radial-gradient(#18182c 0px,#18182c 3px,#393247 4px,#161627 5px);display:grid;place-items:center; }.record span{width:27%;aspect-ratio:1;border-radius:50%;background:#bda2ff;border:12px solid #9d7bed;}
audio { width:100%; margin-top:15px; } .play-status { color:var(--muted);font-size:12px;min-height:16px; }.error{color:#ffc4c4;line-height:1.5;}.failure a{display:inline-block;padding:10px;}
footer { color:#9098b0;font-size:12px;padding:35px 0 25px; }
@media(max-width:700px) { main{width:calc(100% - 32px);} .player-grid{grid-template-columns:1fr;} .library,.now-playing{padding:24px;} .badge{font-size:9px;letter-spacing:1px;} h1{letter-spacing:-2px;} }9. Instale, compile e execute
Depois de criar todos os arquivos acima, execute os comandos abaixo na raiz music-player. Não rode instalações simultâneas na mesma pasta. O npm cria o package-lock.json; mantenha-o no Git e use npm ci nas próximas instalações reproduzíveis.
npm install
npm run audio
npm run build Em um terminal, ainda na raiz:
npm run api Em outro terminal, na mesma raiz:
npm run web Abra http://localhost:4310. O catálogo está em http://localhost:4311/v1/tracks. Se uma porta estiver ocupada, ajuste os scripts e as variáveis de ambiente em conjunto; frontend, API e origem da mídia precisam continuar apontando para os destinos corretos.
10. Verifique o fluxo, inclusive as falhas

Etapa 10 — troca de faixa e reprodução verificadas no navegador.
Com os dois servidores ativos, adicione o verificador abaixo e rode npm run verify. Ele confere respostas HTTP, consulta por ID, validação, faixa inexistente, resposta parcial de áudio, cabeçalho CORS e HTML renderizado pelo Next.js.
Arquivo: scripts/verify.mjs
import assert from 'node:assert/strict';
const api = 'http://127.0.0.1:4311';
const response = await fetch(`${api}/v1/tracks`);
assert.equal(response.status, 200);
const tracks = await response.json();
assert.equal(tracks.length, 2);
const known = await fetch(`${api}/v1/tracks/${tracks[0].id}`);
assert.equal(known.status, 200);
assert.equal((await known.json()).title, tracks[0].title);
assert.equal((await fetch(`${api}/v1/tracks/invalido`)).status, 400);
assert.equal((await fetch(`${api}/v1/tracks/33333333-3333-4333-8333-333333333333`)).status, 404);
const range = await fetch(`${api}${tracks[0].src}`, { headers: { Range: 'bytes=0-43' } });
assert.equal(range.status, 206);
assert.equal((await range.arrayBuffer()).byteLength, 44);
assert.match(range.headers.get('content-range'), /^bytes 0-43\//);
const cors = await fetch(`${api}/v1/tracks`, { headers: { Origin: 'http://localhost:4310' } });
assert.equal(cors.headers.get('access-control-allow-origin'), 'http://localhost:4310');
const web = await fetch('http://localhost:4310');
assert.equal(web.status, 200);
const html = await web.text();
assert.match(html, /Noite digital/);
assert.match(html, /<audio/);
console.log('OK: catálogo, consulta, 400, 404, Range 206, CORS e renderização Next.js.');Faça também a verificação manual: selecione cada faixa; pause e retome; mova a posição do áudio; percorra os botões com Tab; abra a interface no celular. Para testar a falha de catálogo, pare a API, recarregue a página e confira a mensagem de indisponibilidade. Depois, reinicie a API. Evite transformar falhas em uma lista vazia silenciosa.
Validação deste tutorial: builds da API e do Next.js concluídos; verificador HTTP aprovado; reprodução real de uma demo de 12 segundos confirmada no Chrome; estado de catálogo indisponível confirmado com a API parada; layout de 390 px sem transbordamento horizontal. O ambiente usado foi Node.js 25.0.0. Para novos projetos, prefira uma versão LTS suportada. Outros navegadores e produção precisam de validação própria.
11. Como evoluir essa arquitetura
- Persistência: crie um adaptador de banco que implemente
TrackRepository. IDs, título e artista ficam no banco; os arquivos de áudio permanecem no armazenamento de objetos. - Distribuição: em produção, use storage e CDN com suporte a Range; mantenha o Nest responsável por metadados e autorização. Não transporte áudio em JSON ou Base64.
- Conteúdo privado: autentique o usuário e autorize o acesso à faixa antes de emitir uma URL assinada com expiração. O diretório estático deste MVP é público.
- Operação: HTTPS, limites de requisição, logs estruturados, métricas e monitoramento de erros. Se a implantação usar containers, configure o endereço de escuta e exponha apenas as portas necessárias.
- Qualidade: acrescente testes unitários dos casos de uso e testes de navegador em CI. Teste formatos de áudio e dispositivos usados pelo público.
- Direitos de uso: só distribua faixas próprias ou autorizadas. Não extraia áudio de serviços de terceiros.
Resultado: um ponto de partida executável, com responsabilidades explícitas e espaço para crescer. Trocar o armazenamento ou a distribuição da mídia não exige concentrar todas as regras dentro do componente visual.
Documentação oficial
- Next.js — instalação
- Next.js — Server e Client Components
- Next.js — variáveis de ambiente
- NestJS — custom providers e injeção de dependências
- NestJS — arquivos estáticos
- NestJS — CORS
- MDN — elemento audio
- MDN — play() e políticas de reprodução
Ilustração de capa gerada com IA. Diagrama elaborado para este tutorial; capturas do aplicativo real. Código e validações locais descritos no artigo.
