The Daily Newsstand · Free, Always
Thursday, September 17, 2026

Next.js, внешний API, Zod, нормализация и controlled fallback

Translate

Внешний API возвращает данные в своей модели. Названия полей, вложенные объекты, изображения, цены и правила пагинации принадлежат provider. UI приложения обычно работает с другой моделью. Если компонент читает event._embedded.venues[0].city.name напрямую, структура внешнего ответа распространяется по интерфейсу. Изменение API затрагивает карточки, detail page, search, metadata и тесты.

В App Router внешний запрос можно выполнять на сервере. Server Components работают с fetch, а серверный код не попадает в клиентский bundle. API key при такой схеме остаётся вне браузера. Документация Next.js описывает server‑side data fetching для App Router. В учебном проекте EventMap внешний источник событий проходит через server API слой.

Server boundary

Ключ внешнего сервиса не нужен Client Component. Его читает серверная функция:

export function getTicketmasterApiKey(): string | null {
  const key = process.env.TICKETMASTER_API_KEY;

  return key && key.trim().length > 0
    ? key
    : null;
}
                  

Переменная не имеет префикса NEXT_PUBLIC_.

URL внешнего запроса тоже собирается на сервере:

const TICKETMASTER_EVENTS_URL =
  "https://app.ticketmaster.com/discovery/v2/events.json";

export function buildEventsUrl({
  apiKey,
  locale,
  city,
  keyword,
}: BuildEventsUrlInput): string {
  void locale;

  const url = new URL(TICKETMASTER_EVENTS_URL);

  url.searchParams.set("apikey", apiKey);
  url.searchParams.set("size", "12");
  url.searchParams.set("sort", "date,asc");
  url.searchParams.set("locale", "*");
  url.searchParams.set(
    "startDateTime",
    getLiveEventsStartDateTime(),
  );

  if (city) {
    url.searchParams.set("city", city);
  }

  if (keyword) {
    url.searchParams.set("keyword", keyword);
  }

  return url.toString();
}
                  

UI не получает API key и не собирает Ticketmaster URL.

JSON после response.json()

TypeScript не проверяет данные, пришедшие по сети. Тип в исходном коде исчезает после компиляции, а внешний сервер может вернуть другую структуру.

После response.json() проект хранит результат как unknown:

const data: unknown = await response.json();
                  

Дальше данные проходят runtime‑проверку.

Zod разделяет успешный и неуспешный результат через safeParse. Метод не бросает ZodError, а возвращает объект с success и data либо error. Такая форма подходит для source boundary, где невалидный ответ переводится в другой сценарий. Документация Zod показывает тот же контракт safeParse.

Схема внешнего события

Схема описывает только те части ответа, которые нужны приложению:

import { z } from "zod";

export const TicketmasterEventSchema = z.object({
  id: z.string(),
  name: z.string(),

  info: z.string().optional(),
  pleaseNote: z.string().optional(),
  url: z.string().optional(),

  images: z
    .array(
      z.object({
        url: z.string(),
        width: z.number().optional(),
        height: z.number().optional(),
      }),
    )
    .optional(),

  dates: z
    .object({
      start: z
        .object({
          localDate: z.string().optional(),
          localTime: z.string().optional(),
        })
        .optional(),
    })
    .optional(),

  classifications: z
    .array(
      z.object({
        genre: z
          .object({
            name: z.string().optional(),
          })
          .optional(),

        segment: z
          .object({
            name: z.string().optional(),
          })
          .optional(),
      }),
    )
    .optional(),
});
                  

id и name обязательны. Изображения, venue, дата, classification и price могут отсутствовать.

Ответ search endpoint проверяется отдельной схемой:

export const TicketmasterEventsResponseSchema =
  z.object({
    _embedded: z
      .object({
        events:
          z.array(TicketmasterEventSchema).optional(),
      })
      .optional(),

    page: z
      .object({
        totalElements: z.number().optional(),
        totalPages: z.number().optional(),
        number: z.number().optional(),
        size: z.number().optional(),
      })
      .optional(),
  });
                  

Collection response и отдельное событие имеют разные схемы.

safeParse перед нормализацией

Search получает JSON и проверяет его до обращения к вложенным полям:

const data: unknown = await response.json();

const parsed =
  TicketmasterEventsResponseSchema.safeParse(data);

if (!parsed.success) {
  console.warn(
    "Ticketmaster search response shape is invalid",
  );

  return {
    ok: false,
    reasonKey: "invalid-live-response",
  };
}
                  

Невалидный JSON не попадает в normalizer и React components.

Detail request использует схему одного события:

const data: unknown = await response.json();

const parsed =
  TicketmasterEventSchema.safeParse(data);

if (!parsed.success) {
  console.warn(
    "Ticketmaster detail response shape is invalid",
  );

  return null;
}

return normaliseTicketmasterEvent(parsed.data);
                  

После успешного safeParse TypeScript получает тип, выведенный из Zod schema.

Внутренняя модель

UI использует свой тип:

export type EventCardData = {
  id: string;
  title: string;
  city: string;
  country: string;
  category: string;
  dateLabel: string;
  venue: string;
  description: string;
  imageUrl: string;
  genreLabel?: string;
  priceLabel?: string;
  ticketUrl?: string;
  timeLabel?: string;
};
                  

В нём нет _embedded, classifications, priceRanges и других Ticketmaster structures.

Normalizer читает provider model и собирает EventCardData:

export function normaliseTicketmasterEvent(
  ticketmasterEvent: TicketmasterEvent,
): EventCardData {
  const venue =
    ticketmasterEvent._embedded?.venues?.[0];

  const classification =
    ticketmasterEvent.classifications?.[0];

  const category =
    classification?.segment?.name;

  const genre =
    classification?.genre?.name;

  const description =
    ticketmasterEvent.info?.trim() ||
    ticketmasterEvent.pleaseNote?.trim() ||
    "Event details are coming soon.";

  return {
    id: ticketmasterEvent.id,
    title: ticketmasterEvent.name,

    city:
      venue?.city?.name ??
      "Unknown city",

    country:
      venue?.country?.name ??
      "Unknown country",

    category:
      category ??
      "Event",

    dateLabel:
      ticketmasterEvent.dates?.start?.localDate ??
      "Date TBA",

    venue:
      venue?.name ??
      "Venue TBA",

    description,

    imageUrl:
      pickBestImage(ticketmasterEvent.images),

    genreLabel: genre,

    ticketUrl:
      ticketmasterEvent.url,

    timeLabel:
      ticketmasterEvent.dates?.start?.localTime
        ?.slice(0, 5),
  };
}
                  

Карточка получает одинаковую форму независимо от структуры provider response.

Неполные данные

Внешняя запись может быть валидной по schema и при этом не содержать venue, description или image. Schema оставляет такие поля optional. Normalizer решает, что получит UI.

Description выбирается последовательно:

const description =
  ticketmasterEvent.info?.trim() ||
  ticketmasterEvent.pleaseNote?.trim() ||
  "Event details are coming soon.";
                  

Venue и дата получают текстовые fallback:

venue:
  venue?.name ?? "Venue TBA",

dateLabel:
  ticketmasterEvent.dates?.start?.localDate ??
  "Date TBA",
                  

Компоненту не требуется проверять каждый вложенный provider field.

Выбор изображения

API может вернуть несколько изображений одного события. Первое изображение в массиве не обязательно подходит карточке. Проект фильтрует пустые URL, затем ищет широкое изображение шириной от 600 px:

export const EVENT_PLACEHOLDER_IMAGE =
  "/event-placeholder.svg";

export function pickBestImage(
  images: TicketmasterImage[] | undefined,
): string {
  if (!images || images.length === 0) {
    return EVENT_PLACEHOLDER_IMAGE;
  }

  const validImages =
    images.filter(
      (image) =>
        image.url.trim().length > 0,
    );

  if (validImages.length === 0) {
    return EVENT_PLACEHOLDER_IMAGE;
  }

  const wideImage = validImages
    .filter(
      (image) =>
        typeof image.width === "number" &&
        image.width >= 600 &&
        (image.height ?? 0) <= image.width,
    )
    .sort(
      (a, b) =>
        (b.width ?? 0) -
        (a.width ?? 0),
    )[0];

  if (wideImage) {
    return wideImage.url;
  }

  const imagesWithWidth =
    validImages.filter(
      (image) =>
        typeof image.width === "number",
    );

  if (imagesWithWidth.length === 0) {
    return validImages[0].url;
  }

  return (
    imagesWithWidth.sort(
      (a, b) =>
        (b.width ?? 0) -
        (a.width ?? 0),
    )[0]?.url ??
    EVENT_PLACEHOLDER_IMAGE
  );
}
                  

Если подходящей картинки нет, UI получает локальный placeholder.

HTTP status до Zod

Zod проверяет JSON structure. HTTP status проверяется раньше.

const response = await fetch(url, {
  next: {
    revalidate:
      cachePolicy.liveEventsRevalidate,

    tags: [
      cachePolicy.tags.liveEvents,
    ],
  },
});

if (!response.ok) {
  console.warn(
    "Ticketmaster search request failed",
    response.status,
  );

  return {
    ok: false,
    reasonKey: "api-error",
  };
}
                  

401, 429 или 500 не передаются в response.json() как ожидаемый events response.

Ticketmaster документирует quota для Discovery API и возвращает HTTP 429, когда quota превышена. Документация Ticketmaster описывает этот ответ отдельно. В проекте 429 не имеет отдельной runtime‑ветки. Любой !response.ok превращается в api-error, status остаётся в server log.

Ошибка сети

fetch может завершиться исключением до получения HTTP response. Ошибка может возникнуть и во время чтения JSON. Серверная функция перехватывает оба случая:

try {
  const response = await fetch(url, {
    next: {
      revalidate:
        cachePolicy.liveEventsRevalidate,

      tags: [
        cachePolicy.tags.liveEvents,
      ],
    },
  });

  if (!response.ok) {
    return {
      ok: false,
      reasonKey: "api-error",
    };
  }

  const data: unknown =
    await response.json();

  // parse + normalise
} catch {
  console.warn(
    "Ticketmaster search request failed before fallback",
  );

  return {
    ok: false,
    reasonKey: "api-error",
  };
}
                  

Ошибка provider не выходит из data function в Server Component.

Результат live source

Search service возвращает discriminated union:

type LiveSearchResult =
  | {
      ok: true;
      events: EventCardData[];
      totalCount: number;
      totalPages: number;
      currentPage: number;
    }
  | {
      ok: false;
      reasonKey:
        | "missing-api-key"
        | "api-error"
        | "empty-live-response"
        | "invalid-live-response"
        | "empty-normalised-response";
    };
                  

Успешная ветка всегда содержит внутренние EventCardData. Неуспешная ветка содержит причину перехода к следующему источнику.

Пустой ответ

Валидный response может не содержать событий:

const rawEvents =
  (parsed.data._embedded?.events ?? [])
    .filter(
      isTicketmasterEventOnOrAfterStartDate,
    );

if (rawEvents.length === 0) {
  return {
    ok: false,
    reasonKey: "empty-live-response",
  };
}
                  

Пустой массив отличается от invalid response. Оба состояния не требуют отдельной обработки в React component.

Controlled fallback

Fallback хранится в источнике, которым управляет приложение. Search сначала запрашивает live source:

export async function getSearchPageEvents({
  filters,
  locale,
}: {
  filters: SearchFilters;
  locale: Locale;
}): Promise<SearchPageEventsResult> {
  const liveResult =
    await searchLiveEvents({
      filters,
      locale,
    });

  if (liveResult.ok) {
    return {
      ...liveResult,

      hasPreviousPage:
        liveResult.currentPage > 1,

      hasNextPage:
        liveResult.currentPage <
        liveResult.totalPages,

      source:
        getSourceInfo({
          type: "live",
          reasonKey: "live-results",
        }),
    };
  }

  const filteredEvents =
    await searchControlledEvents({
      filters,
    });

  const pagination =
    paginateEvents({
      events: filteredEvents,
      page: filters.page,
    });

  return {
    ...pagination,

    source:
      getSourceInfo({
        type: "fallback",
        reasonKey:
          liveResult.reasonKey,
      }),
  };
}
                  

Live source и fallback возвращают одну SearchPageEventsResult. React component не меняет EventCard для двух источников.

Фильтрация controlled source

Fallback не обязан повторять полный внешний каталог. Он работает с ограниченным набором записей приложения. Категория, город и query применяются к controlled events:

export async function searchControlledEvents({
  filters,
}: {
  filters: SearchFilters;
}): Promise<EventCardData[]> {
  return controlledEventCards.filter(
    (event) =>
      matchesCategory(
        event,
        filters.category,
      ) &&
      matchesCity(
        event,
        filters.city,
      ) &&
      matchesQuery(
        event,
        filters.q,
      ),
  );
}
                  

URL поиска не меняется после перехода с live source на fallback.

Home и search

Одинаковая схема используется на главной странице:

export async function getHomeEvents({
  locale,
}: GetFeaturedEventsInput): Promise<HomeEventsResult> {
  const liveEvents =
    await getLiveTicketmasterEvents({
      locale,
    });

  if (liveEvents.length > 0) {
    return {
      source: "ticketmaster",
      events: liveEvents,
    };
  }

  return {
    source: "controlled",
    events:
      await getControlledFeaturedEvents({
        locale,
      }),
  };
}
                  

Live response с событиями идёт в UI. Пустой или недоступный source заменяется controlled events.

Detail page

У detail route порядок источников другой. Проект сначала ищет event среди controlled data:

export async function fetchEventById({
  id,
  locale,
}: {
  id: string;
  locale: Locale;
}): Promise<EventCardData | null> {
  const controlledEvent =
    await getControlledEventById({
      id,
      locale,
    });

  if (controlledEvent) {
    return controlledEvent;
  }

  return fetchLiveTicketmasterEventById({
    id,
    locale,
  });
}
                  

Известные controlled IDs не требуют запроса во внешний API. Если ID отсутствует в controlled source, сервер пробует live detail endpoint.

Static params

Controlled IDs используются и при генерации известных detail routes:

export function getControlledEventIds():
  string[] {
  return controlledEventCards.map(
    (event) => event.id,
  );
}
                  

Дальше locale и ID формируют static params:

export function getStaticEventParams():
  StaticEventParams[] {
  const eventIds =
    getControlledEventIds();

  return locales.flatMap(
    (locale) =>
      eventIds.map((id) => ({
        locale,
        id,
      })),
  );
}
                  

Static detail pages строятся из набора, которым управляет приложение. Live IDs остаются доступными через dynamic params.

429 и build

Для known detail pages fetchEventById находит controlled event до внешнего запроса. Такой route не зависит от Ticketmaster response при получении данных события. Home использует другую схему и пробует live source. Там !response.ok и catch возвращают пустой live result, после чего функция выбирает controlled events. Ошибка внешнего API не выбрасывается из этих функций наружу. 429 проходит через тот же код, что остальные non-2xx responses.

Cache policy

Live source не обязательно запрашивать при каждом обращении. Проект хранит интервалы отдельно:

export const cachePolicy = {
  liveEventsRevalidate: 300,
  liveEventDetailRevalidate: 600,

  tags: {
    liveEvents: "live-events",
    liveEventDetail:
      "live-event-detail",
  },
} as const;
                  

Search передаёт policy в server‑side fetch:

const response = await fetch(url, {
  next: {
    revalidate:
      cachePolicy.liveEventsRevalidate,

    tags: [
      cachePolicy.tags.liveEvents,
    ],
  },
});
                  

Next.js расширяет server‑side fetch параметрами next.revalidate и next.tags. revalidate задаёт срок жизни cached resource, tags используются для on‑demand revalidation. API reference fetch описывает оба параметра. Кэш относится к live source. Controlled events лежат внутри приложения и не используют внешний fetch.

Source reason

Data layer сохраняет причину fallback:

export type SearchSourceReason =
  | "live-results"
  | "missing-api-key"
  | "api-error"
  | "empty-live-response"
  | "invalid-live-response"
  | "empty-normalised-response"
  | "controlled-fallback";
                  

Сервис может различить отсутствующий API key, HTTP/network failure, пустой provider response и невалидную структуру. Эти значения не входят в EventCardData.

Raw data для проверки Zod

В проекте есть отдельный controlled набор raw records. Часть записей намеренно сломана. Одна запись не содержит name:

{
  id: "broken-without-name",

  location: {
    city: "Berlin",
    country: "Germany",
  },

  category: "Design",

  date: {
    label: "30 July",
  },

  venue: {
    name: "Design Factory",
  },

  description:
    "This raw item should not reach UI.",

  price: "free",
}
                  

Другая запись получает numeric id. Ещё одна не содержит location.city. Все они проходят через:

const parsed =
  RawEventSchema.safeParse(rawEvent);

if (!parsed.success) {
  return [];
}

return [
  normaliseEvent(parsed.data),
];
                  

Запись, которая не прошла schema, не превращается в карточку.

Tests нормализатора

Нормализатор тестируется отдельно от React components. Один тест проверяет обычный Ticketmaster event:

expect(
  normaliseTicketmasterEvent(
    ticketmasterEvent,
  ),
).toEqual({
  id: "tm-1",
  title:
    "Ticketmaster Music Night",

  city: "London",
  country: "United Kingdom",

  category: "Music",
  dateLabel: "2026-06-12",

  venue: "Roundhouse",

  description:
    "Live show from Ticketmaster.",

  imageUrl: "/tm-wide.jpg",

  genreLabel: "Rock",
  priceLabel: "from 25 GBP",

  ticketUrl:
    "https://example.com/tickets",

  timeLabel: "19:30",
});
                  

Другие тесты проверяют pleaseNote, fallback description, time и image selection.

Tests изображений

pickBestImage имеет отдельный набор случаев:

it(
  "returns the placeholder for undefined images",
  () => {
    expect(
      pickBestImage(undefined),
    ).toBe(
      EVENT_PLACEHOLDER_IMAGE,
    );
  },
);

it(
  "chooses the widest wide image",
  () => {
    expect(
      pickBestImage([
        {
          url: "/small.jpg",
          width: 300,
          height: 200,
        },
        {
          url: "/wide-600.jpg",
          width: 600,
          height: 300,
        },
        {
          url: "/wide-1000.jpg",
          width: 1000,
          height: 500,
        },
      ]),
    ).toBe("/wide-1000.jpg");
  },
);
                  

Выбор картинки проверяется без внешнего API.

Контур данных

Live path проходит через четыре шага:

Ticketmaster HTTP response
        ↓
      Zod
        ↓
   normaliser
        ↓
 EventCardData
        ↓
       UI
                  

Неуспешный search path идёт через controlled source:

missing API key
HTTP error / 429
network error
invalid response
empty response
        ↓
controlled events
        ↓
EventCardData
        ↓
       UI
                  

Server Component получает один тип данных в обеих ветках. API key остаётся на сервере. Provider JSON проверяется до normalizer. Normalizer не передаёт внешнюю структуру в компоненты. Search переключается на controlled source при недоступном live source. Known detail pages сначала читают controlled data. Cache policy задаётся рядом с server fetch.

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.