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

Внешний 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.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.