Кратко, Яндекс-карты не совместимы с серверным режимом в Next. Поэтому их нужно вставлять через dynamic import с параметром ssr: false
. Вот так:
page.tsx
// инициализация и использование карты внутри, включая Script, загружающий код карт
const DynamicPageContent = dynamic(async () => import('./DynamicPageContent'), {
ssr: false,
});
export default function Page() {
return <DynamicPageContent />;
}
Сам скрипт вставляется через компонент Script
со стратегией strategy="beforeInteractive"
, что гарантирует то, что ваш код будет выполнен после загрузки скрипта.
DynamicPageContent.tsx
'use client';
import Script from 'next/script';
export function DynamicPageContent() {
const apiKey = process.env.NEXT_PUBLIC_YMAPS_API_KEY;
return (
<>
<Script src={`https://api-maps.yandex.ru/v3/?apikey=${apiKey}&lang=ru_RU`} strategy="beforeInteractive" />
<Компонент_вставляющий_саму_карту />
</>
);
}
Это приемлимый вариант, но если вы хотите сохранить отзывчивость next-а и не заставлять пользователя ждать (даже если это просто секунда), можно применить чуть более бессмысленно сложный вариант. Примерно такой:
(данный пример не использует react-обёртку, поскольку смысл её существования в текущей реализации автором не осилен; впрочем, пример для этого легко модифицируется)
types.ts
import * as ClustererModule from '@yandex/ymaps3-types/packages/clusterer';
export type YMaps = typeof ymaps3 & typeof ClustererModule;
context.ts
import React from 'react';
import type { DeferredResult } from '@/features/core/hooks/useDeferred';
import type { YMaps } from './types';
export const YMapsContext = React.createContext<DeferredResult<YMaps> | null>(
null,
);
YMapsProvider.tsx
'use client';
import Script from 'next/script';
import React, { useEffect } from 'react';
import { useDeferred } from '@/features/core/hooks/useDeferred';
import { YMapsContext } from '@/features/map/cogs/YMapsProvider/context';
import { YMaps } from './types';
type Props = React.PropsWithChildren<{ apiKey?: string }>;
export function YMapsProvider({ children, apiKey }: Props) {
const scriptDeferred = useDeferred<true>();
const mapsDeferred = useDeferred<YMaps>();
useEffect(() => {
if (
scriptDeferred.state === 'resolved' &&
mapsDeferred.state === 'pending'
) {
Promise.all([
ymaps3.import('@yandex/ymaps3-clusterer@0.0.1'),
ymaps3.ready,
]).then(
([clustererModule]) =>
mapsDeferred.resolve({ ...ymaps3, ...clustererModule }),
(error) => mapsDeferred.reject(error),
);
} else if (
scriptDeferred.state === 'rejected' &&
mapsDeferred.state === 'pending'
) {
mapsDeferred.reject(scriptDeferred.error);
}
}, [scriptDeferred, mapsDeferred]);
const handleScriptLoaded = () => {
if (scriptDeferred.state === 'pending') {
scriptDeferred.resolve(true);
}
};
const handleScriptLoadingFailed = (error: Error) => {
if (scriptDeferred.state === 'pending') {
scriptDeferred.reject(error);
}
};
return (
<>
<Script
src={`https://api-maps.yandex.ru/v3/?apikey=${apiKey}&lang=en_RU`}
onReady={handleScriptLoaded}
onError={handleScriptLoadingFailed}
/>
<YMapsContext.Provider value={mapsDeferred}>
{children}
</YMapsContext.Provider>
</>
);
}
useYMaps.ts
import { useContext } from 'react';
import { YMapsContext } from './context';
import type { YMaps } from './types';
export function useYMaps(): YMaps {
const deferred = useContext(YMapsContext);
if (!deferred) {
throw new Error('useYMaps requires YMapsProvider installed');
}
if (deferred.state === 'resolved') {
return deferred.result;
}
if (deferred.state === 'rejected') {
throw deferred.error;
}
throw deferred.promise;
}
хелпер useDeferred.ts
import 'client-only';
import { useMemo, useRef, useState } from 'react';
type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: Error) => void;
};
export type DeferredResult<T> =
| { state: 'resolved'; result: T }
| { state: 'rejected'; error: Error }
| {
state: 'pending';
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: Error) => void;
};
const Empty = Symbol('empty');
export function useDeferred<T>(): DeferredResult<T> {
const deferred = useRef<Deferred<T> | null>(null);
const [result, setResult] = useState<T | Symbol>(Empty);
const [error, setError] = useState<Error | Symbol>(Empty);
if (!deferred.current) {
let deferredValue: Deferred<T> = {
promise: Promise.resolve('temp' as unknown as T),
resolve: () => {},
reject: () => {},
};
deferredValue.promise = new Promise<T>((resolve, reject) => {
deferredValue.resolve = resolve;
deferredValue.reject = reject;
});
deferred.current = deferredValue;
}
const resolvedState = useMemo(() => {
if (result === Empty) {
return null;
}
return { state: 'resolved' as const, result: result as T };
}, [result]);
const rejectedState = useMemo(() => {
if (error === Empty) {
return null;
}
return { state: 'rejected' as const, error: error as Error };
}, [error]);
const pendingState = useMemo(
() => ({
state: 'pending' as const,
promise: deferred.current!.promise,
resolve: (value: T) => {
setResult(value);
deferred.current!.resolve(value);
},
reject: (error: Error) => {
setError(error);
deferred.current!.reject(error);
},
}),
[],
);
return resolvedState || rejectedState || pendingState;
}
Далее в своих компонентах используете хук useYMaps
, не забыв предварительно установить Suspense
между провайдером и компонентом-пользователем карт.