Pular para o conteúdo
TypeScript

Tipar Fetch no Next.js com TypeScript

Domine data fetching tipado no Next.js. De fetch wrapper genérico até cache, revalidação e tratamento de erro, tudo com TypeScript garantindo segurança de tipos.

Por que isso é importante

Tipar Fetch no Next.js com TypeScript. Domine data fetching tipado no Next.js. De fetch wrapper genérico até cache, revalidação e tratamento de erro, tudo com TypeScript garantindo segurança de tipos.

O Problema do Fetch Sem Tipagem

O fetch nativo do JavaScript (e do Next.js) não sabe nada sobre o formato dos dados que a API retorna. Quando você faz const data = await response.json(), o TypeScript infere data como any. A partir daí, qualquer acesso é permitido: data.nome, data.xyz, data.o_que_quiser. Nenhum erro. Nenhum autocomplete.

No Next.js App Router, fetch ganhou superpoderes: cache automático, revalidação por tempo, revalidação por tag. Mas esses recursos também precisam de tipagem pra funcionar bem. As opções de next.revalidate, next.tags e cache estão no tipo RequestInit estendido do Next.js.

O caminho certo é criar um wrapper de fetch que aceita generics. Você passa o tipo esperado e o TypeScript garante que o retorno bate com o que a API promete. Se a API mudar, o build quebra no lugar certo.

Galera costuma fazer as any ou JSON.parse sem tipo pra resolver rápido. Funciona no momento, mas cria dívida técnica que cobra caro depois. Um wrapper tipado resolve de vez.

Passo a Passo: Fetch Tipado no Next.js

Vamos construir um sistema de data fetching tipado do zero. Cada passo adiciona segurança.

  1. Passo 1 - Defina os tipos da sua API: Crie interfaces que espelham exatamente o que a API retorna. Um type por endpoint. Se a API tem documentação OpenAPI/Swagger, extraia os tipos de lá.
  2. Passo 2 - Crie um fetch wrapper genérico: Uma função async que aceita URL, options e um generic T. Ela faz fetch, checa o status, faz response.json() e retorna T. Todo o tratamento de erro fica centralizado.
  3. Passo 3 - Tipe as options do Next.js: O Next.js estende RequestInit com next.revalidate (number), next.tags (string[]) e cache ('force-cache' | 'no-store'). Tipe essas options no seu wrapper.
  4. Passo 4 - Use o wrapper nos Server Components: Chame o wrapper com o tipo genérico: const posts = await api('/posts'). Posts agora tem tipo Post[] com autocomplete completo.
  5. Passo 5 - Adicione tratamento de erro tipado: Crie um tipo Result com success/error. O wrapper retorna { data, error } ao invés de lançar exceção. O componente sabe exatamente como tratar cada caso.
  6. Passo 6 - Configure revalidação tipada: Use next.revalidate pra cache por tempo e next.tags pra invalidação on-demand. Tipe as tags como union literal pra evitar typos.

Exemplos Práticos de Fetch Tipado

Do wrapper básico ao avançado. Cada exemplo resolve um problema real.

Fetch Wrapper Genérico

// lib/api.ts

// Tipo de erro padronizado
type ApiError = {
  message: string;
  status: number;
};

// Tipo de resultado genérico
type ApiResult<T> =
  | { data: T; error: null }
  | { data: null; error: ApiError };

// Options estendidas do Next.js
type FetchOptions = RequestInit & {
  next?: {
    revalidate?: number | false;
    tags?: string[];
  };
};

const BASE_URL = process.env.API_URL ?? "https://api.example.com";

export async function api<T>(
  endpoint: string,
  options: FetchOptions = {}
): Promise<ApiResult<T>> {
  try {
    const response = await fetch(`${BASE_URL}${endpoint}`, {
      headers: {
        "Content-Type": "application/json",
        ...options.headers,
      },
      ...options,
    });

    if (!response.ok) {
      return {
        data: null,
        error: {
          message: `API error: ${response.statusText}`,
          status: response.status,
        },
      };
    }

    const data: T = await response.json();
    return { data, error: null };
  } catch (err) {
    return {
      data: null,
      error: { message: "Network error", status: 0 },
    };
  }
}

Tipos da API Centralizados

// types/api.ts

export type Post = {
  id: string;
  title: string;
  content: string;
  slug: string;
  author: Author;
  tags: string[];
  publishedAt: string;
  updatedAt: string;
};

export type Author = {
  id: string;
  name: string;
  avatar: string;
};

export type PaginatedResponse<T> = {
  data: T[];
  total: number;
  page: number;
  pageSize: number;
  totalPages: number;
};

export type PostFilters = {
  category?: string;
  tag?: string;
  page?: number;
  limit?: number;
};

Fetch em Server Components com Cache

// app/blog/page.tsx
import { api } from "@/lib/api";
import type { Post, PaginatedResponse } from "@/types/api";

export default async function BlogPage() {
  // Fetch tipado com cache de 1 hora e tag pra invalidação
  const result = await api<PaginatedResponse<Post>>("/posts", {
    next: {
      revalidate: 3600,
      tags: ["posts"],
    },
  });

  if (result.error) {
    return <p>Erro: {result.error.message}</p>;
  }

  // result.data tem tipo PaginatedResponse<Post>
  const { data: posts, totalPages } = result.data;

  return (
    <div>
      {posts.map((post) => (
        // post tem autocomplete completo: title, slug, author...
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>Por {post.author.name}</p>
        </article>
      ))}
    </div>
  );
}

Fetch com Query Parameters Tipados

// lib/api.ts - versão com query params

function buildQuery(params: Record<string, string | number | undefined>): string {
  const filtered = Object.entries(params)
    .filter(([_, v]) => v !== undefined)
    .map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`);

  return filtered.length ? `?${filtered.join("&")}` : "";
}

// Funções específicas por recurso
export async function getPosts(
  filters: PostFilters = {}
): Promise<ApiResult<PaginatedResponse<Post>>> {
  const query = buildQuery({
    category: filters.category,
    tag: filters.tag,
    page: filters.page,
    limit: filters.limit,
  });

  return api<PaginatedResponse<Post>>(`/posts${query}`, {
    next: { revalidate: 3600, tags: ["posts"] },
  });
}

export async function getPost(
  slug: string
): Promise<ApiResult<Post>> {
  return api<Post>(`/posts/${slug}`, {
    next: { revalidate: 3600, tags: ["posts", `post-${slug}`] },
  });
}

// Uso limpo no componente:
// const { data: posts } = await getPosts({ category: "react", page: 1 });
// const { data: post } = await getPost("meu-slug");

Fetch com Revalidação On-demand

// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
import { NextRequest, NextResponse } from "next/server";

// Tags tipadas como union literal
type CacheTag = "posts" | "products" | "users" | `post-${string}`;

type RevalidateBody = {
  tag: CacheTag;
  secret: string;
};

export async function POST(request: NextRequest) {
  const body: RevalidateBody = await request.json();

  if (body.secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  // revalidateTag invalida todas as requests com essa tag
  revalidateTag(body.tag);

  return NextResponse.json({
    revalidated: true,
    tag: body.tag,
    now: Date.now(),
  });
}

// Chamada externa:
// POST /api/revalidate { tag: "posts", secret: "xyz" }
// Todos os fetches com tag "posts" são invalidados

Fetch no Client Component com SWR Tipado

// components/SearchResults.tsx
"use client";

import useSWR from "swr";
import type { Post } from "@/types/api";

// Fetcher tipado pra SWR
const fetcher = async <T,>(url: string): Promise<T> => {
  const res = await fetch(url);
  if (!res.ok) throw new Error("Fetch failed");
  return res.json() as Promise<T>;
};

type SearchResultsProps = {
  query: string;
};

export function SearchResults({ query }: SearchResultsProps) {
  // SWR com generics: data tem tipo Post[]
  const { data, error, isLoading } = useSWR<Post[]>(
    query ? `/api/search?q=${query}` : null,
    fetcher<Post[]>
  );

  if (isLoading) return <p>Buscando...</p>;
  if (error) return <p>Erro na busca</p>;
  if (!data?.length) return <p>Nenhum resultado</p>;

  return (
    <ul>
      {data.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

O padrão é sempre o mesmo: defina o tipo, passe como generic, trate o retorno. Não importa se é Server Component com fetch nativo, Client Component com SWR ou route handler. O generic T é seu seguro contra dados inesperados.

Erros Comuns com Fetch Tipado

Armadilhas que passam batido

Confiar cegamente no tipo genérico: api('/posts') não valida que a API realmente retorna Post. O TypeScript confia em você. Se a API mudar e retornar { items: Post[] } ao invés de Post, o tipo não protege em runtime. Use Zod pra validação quando os dados são críticos.

Esquecer de tratar response.ok: fetch não lança erro pra status 4xx/5xx. Se você faz const data = await fetch(url).then(r => r.json()), um 404 retorna dados inesperados sem aviso. Sempre cheque response.ok antes de parsear.

Usar cache: 'force-cache' sem revalidate: Cache eterno significa que dados nunca atualizam. Sempre combine com next.revalidate ou next.tags pra ter controle sobre quando o cache expira.

Tipar response.json() como any e fazer as Type: Isso é type assertion, não tipagem. O TypeScript não valida nada. Use generics no wrapper pra manter consistência. Evite as ao máximo.

Fetch no Client Component sem loading/error: No server, se fetch falha o error.tsx captura. No client, você precisa tratar manualmente. Use SWR ou React Query que gerenciam loading, error e cache automaticamente.

Checklist de Fetch Tipado no Next.js

  • Tipos da API centralizados em types/ ou lib/types.ts
  • Fetch wrapper genérico com tratamento de erro padronizado
  • Retorno do wrapper como { data, error } ao invés de throw
  • Options do Next.js tipadas (next.revalidate, next.tags, cache)
  • Funções específicas por recurso (getPosts, getPost, getUser)
  • Cache tags tipadas como union literal pra evitar typos
  • Client Components usando SWR ou React Query com generics
  • Validação com Zod pra dados críticos vindos de API externa

Data Fetching Profissional no CrazyStack

Fetch tipado é o coração de qualquer app Next.js sério. No CrazyStack, você constrói um sistema completo de data fetching: wrapper genérico, cache inteligente, revalidação on-demand, loading states e error boundaries. Tudo integrado num SaaS real com TypeScript de ponta a ponta.

Se você quer parar de fazer fetch sem tipo e começar a ter controle total sobre os dados do seu app, esse projeto te leva lá.