Как стать автором
Обновить

SSG своими руками

Уровень сложностиСредний
Время на прочтение15 мин
Количество просмотров3.2K

Привет, Хабр!

Сегодня я хочу поделиться с вами руководством, как реализовать Static Site Generation (SSG) в React без использования сторонних фреймворков, таких как Next.js, TanStack Start, React Router as Framework и им подобных. Сразу оговорюсь: я не считаю их чем-то «плохим» и не агитирую против их применения. Всё гораздо проще: иногда по тем или иным причинам нет возможности использовать эти инструменты, или самостоятельная реализация оказывается предпочтительнее из-за количества изменений в кодовой базе.

Краткое предисловие

Опытные разработчики могут сразу перейти к реализации (раздел «Практика»). Остальным рекомендую ознакомиться с базовыми принципами.

Static Site Generation (SSG) — это метод создания веб-сайтов, при котором страницы генерируются на этапе сборки, а не на сервере или в браузере пользователя. Часто этот подход ещё называют pre-rendering. Если сравнить его с серверным (SSR) или клиентским (CSR), то можно сказать, что мы берём лучшее из каждого подхода, а именно:

  1. высокая скорость загрузки;

  2. готовая разметка для SEO и пользователей;

  3. дешевизна хостинга;

  4. надёжность и безопасность.

На иллюстрации ниже изображён пошаговый процесс рендеринга каждой стратегии.

Думаю, вы согласитесь, что визуально процесс SSG выглядит значительно проще, чем SSR и CSR. Ведь вам не нужно для каждого запроса:

  1. Запрашивать данные из API и обрабатывать ошибки — данные вы уже получили во время сборки, а так как собрались и выкатились, значит, ошибок нет.

  2. Рендерить приложение с нуля и перерисовывать его — разметка уже сгенерирована, остаётся только гидратация.

  3. Генерировать HTML — он уже был создан на этапе сборки.

С точки зрения инфраструктуры, вам не нужно:

  • Разворачивать Node.js-сервер для рендеринга React-приложения, оплачивая вычислительные ресурсы (процессор и память).

  • Беспокоиться о состоянии Node.js из-за большого количества запросов или утечки памяти, которые могут привести к простою приложения или увеличению задержек.

И, конечно, нельзя забывать о кибербезопасности. В SSR-проектах значительно повышается риск XSS-уязвимостей.

Как видите, все эти этапы не только требуют времени, но и создают дополнительные точки отказа.

Теперь, когда мы разобрались с преимуществами SSG, стоит упомянуть случаи, когда его использование нецелесообразно или невозможно:

  1. авторизованные зоны с ролевой моделью;

  2. высокодинамичный контент;

  3. данные реального времени, когда разметка должна обновляться мгновенно.

Типичные примеры таких сценариев:

  • административные панели и личные кабинеты;

  • крупные интернет-магазины с обширным ассортиментом;

  • новостные порталы с постоянно обновляемой лентой.

Практика

Для начала вам потребуется готовое приложение. Рекомендую использовать проект с уже настроенной сборкой на Webpack или Vite. В дальнейших примерах я буду использовать Vite — этот инструмент я также рекомендую и вам.

Если ваше приложение содержит несколько страниц, то необходимо использовать SSR‑совместимый роутер, такой как React Router. А вот TanStack Router (без использования TanStack Start) не поддерживает SSR и может вызывать проблемы с гидратацией.

Для работы с API‑данными при генерации страниц рекомендуется использовать TanStack Query или Redux Toolkit.

Если у вас нет подходящего проекта, то можете воспользоваться заранее подготовленным шаблоном.

Чтобы сосредоточиться на ключевых аспектах SSG, я не буду разбирать создание CSR-приложения с нуля и углубляться в базовые настройки сборки. Основное внимание уделю модификациям для реализации статической генерации.

Настройка package.json

Рассмотрим базовую конфигурацию файла package.json и скриптов сборки. В исходном виде он имеет следующую структуру:

"scripts": {
  "dev": "exec vite",
  "build": "vite build"
}

Добавим несколько новых команд в конфигурацию:

  1. очистка директории сборки для предотвращения побочных эффектов;

  2. сборка серверного бандла;

  3. запуск скрипта генерации HTML-страниц.

А также модифицируем процесс production-сборки таким образом, чтобы он выполнял последовательно следующие команды:

  • очистку директории сборки;

  • сборку клиентского и серверного кода;

  • запуск скрипта генерации HTML-страниц.

  "scripts": {
    "dev": "exec vite",
    "clean": "rm -rf dist/",
    "build:client": "vite build",
    "build:server": "vite build --ssr src/entry-server.tsx",
    "ssg": "node dist/server/entry-server.js",
    "build": "run-s clean build:* ssg"
  }

vite.config.ts

Типичная конфигурация Vite для CSR-приложений может включать в себя следующие базовые настройки:

import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react-swc';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';

export default defineConfig({
  plugins: [tsconfigPaths(), tailwindcss(), react()]
});

Необходимо добавить в конфигурацию обработку серверного кода. В предыдущей главе мы уже настроили команду сборки серверного кода с флагом --ssr, что позволяет использовать переменную isSsrBuild. Для оптимальной конфигурации серверной сборки рекомендую настроить следующие параметры:

  1. outDir: укажите отдельную директорию, отличную от клиентской сборки. Это нужно для изоляции от клиентских статических файлов;

  2. copyPublicDir: отключите копирование папки с публичными файлами, так как она уже копируется в рамках сборки клиентского кода.

import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react-swc';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';

export default defineConfig(({ isSsrBuild }) => {
  if (isSsrBuild) {
    return {
      build: {
        outDir: 'dist/server',
        copyPublicDir: false
      },
      plugins: [tsconfigPaths()]
    };
  } else {
    return {
      plugins: [tsconfigPaths(), tailwindcss(), react()]
    };
  }
});

index.html

Для корректной работы SSG необходимо модифицировать исходный HTML-файл, добавив специальные якорные точки:

  1. head‑outlet — место для вставки: мета‑тегов, предзагрузки ресурсов, других служебных тегов <head>;

  2. ssg‑outlet — якорь для вставки отрендеренного React‑приложения;

  3. rqs‑outlet (опционально) — требуется, если приложение работает с API.

Пример разметки:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="referrer" content="strict-origin-when-cross-origin" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="icon" href="/favicon.ico" sizes="48x48" />
    <link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml" />
    <link rel="preconnect" href="https://cdn.dummyjson.com" />
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap"
      rel="stylesheet"
    />
    <!--head-outlet-->
  </head>
  <body class="font-roboto antialiased">
    <div id="root"><!--ssg-outlet--></div>
    <script>
      <!--rqs-outlet-->
    </script>
    <script type="module" src="/src/entry-client.tsx"></script>
  </body>
</html>

Обратите внимание, что точкой входа в приложение указан файл entry-client.tsx. Его мы будем использовать в дальнейшей работе с клиентским кодом.

entry-client.tsx

Перейдём к конфигурации приложения и его рендерингу.

import { QueryClientProvider, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { StrictMode } from 'react';
import { createRoot, hydrateRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router/dom';
import { createBrowserRouter } from 'react-router';

import createRoutes from '@/createRoutes.tsx';

import '@/index.css';

declare global {
  interface Window {
    __REACT_QUERY_STATE__: string;
  }
}

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: Infinity
    }
  }
});

const routes = createRoutes(queryClient);
const router = createBrowserRouter(routes);
const dehydratedState = window.__REACT_QUERY_STATE__;
const container = document.getElementById('root');

if (container) {
  const app = (
    <StrictMode>
      <QueryClientProvider client={queryClient}>
        <HydrationBoundary state={dehydratedState}>
          <RouterProvider router={router} />
          <ReactQueryDevtools />
        </HydrationBoundary>
      </QueryClientProvider>
    </StrictMode>
  );

  if (container.children.length) {
    hydrateRoot(container, app);
  } else {
    const root = createRoot(container);
    root.render(app);
  }
} else {
  throw new Error('Container not found, must be HTMLElement');
}

Ключевые аспекты:

  • Конфигруация QueryClient параметра staleTime: Infinity позволяет избежать повторных запросов к API — это одно из основных преимуществ SSG перед другими стратегиями рендеринга, о котором я писал ранее.

  • window.__REACT_QUERY_STATE__ в предыдущем пункте мы добавляли якорные точки — вот эта как раз одна из них. Необходима для инициализации полученных ранее данных при сборке статической страницы. Чуть позже я покажу, как из якорной точки это становится объектом с данными.

  • Чтобы приложение могло работать как в SSG, так и в CSR‑режиме, мы проверяем наличие готовой разметки в контейнере. При обнаружении существующей разметки выполняется гидратация, в противном случае инициируется полный рендеринг приложения с нуля.

Остальная часть кода представляет собой стандартное использование API соответствующих инструментов без дополнительных модификаций.

createRoutes.tsx

Как вы могли заметить ранее, в нашем коде применяется функция createRoutes. В этой главе мы разберём её логику и принцип работы.

import type { RouteObject } from 'react-router';
import type { QueryClient } from '@tanstack/react-query';

import Layout from '@/components/layout';
import MainPage from '@/pages/main';
import ProductPage from '@/pages/product';
import { productsQuery, productQuery } from '@/queries.ts';

const createRoutes = (queryClient: QueryClient): RouteObject[] => {
  return [
    {
      path: '/',
      Component: Layout,
      children: [
        {
          index: true,
          Component: MainPage,
          loader: async () => {
            if (import.meta.env.SSR) {
              await queryClient.prefetchQuery(productsQuery());
            }
          }
        },
        {
          path: '/products/:id',
          Component: ProductPage,
          loader: async ({ params }) => {
            if (import.meta.env.SSR) {
              await queryClient.prefetchQuery(productQuery(params.id));
            }
          }
        }
      ]
    }
  ];
};

export default createRoutes;

Используя функциональность, предоставляемую React Router, мы настраиваем маршрутизацию нашего приложения. При этом мы не используем динамические импорты, так как при их применении компоненты выносятся в отдельные чанки. Зачастую, если мы говорим о небольших лендингах, это не имеет особого смысла, поскольку соединение с сервером будет устанавливаться гораздо дольше, чем загрузятся условные 5 килобайтов. Но есть ещё одна проблема: метод серверного рендеринга renderToString не умеет работать с динамическими импортами, поэтому для такого рода сборки, как правило, используют библиотеку loadable components. Однако мы не будем применять её — чуть позже я покажу, как избежать её использования.

Для предварительной загрузки данных мы используем стандартный API React Router. Переменная окружения import.meta.env.SSR задаётся флагом --ssr, который ранее был установлен в package.json. Чтобы запросы к API выполнялись только на этапе сборки, мы обернули соответствующий код в проверку условия import.meta.env.SSR.

Обратите внимание на компонент Layout в этом файле. В нём реализован небольшой, но важный технический приём, который нам необходимо применить. Рассмотрим этот компонент.

components/layout/index.tsx

import { Outlet } from 'react-router';
import { HTML_DIVIDER } from '@/constants.ts';

const Layout = () => (
  <>
    {import.meta.env.SSR && HTML_DIVIDER}
    <header className="p-20 text-center">THIS IS TOPLINE</header>
    <Outlet />
    <footer className="p-20 text-center">THIS IS FOOTER</footer>
  </>
);

export default Layout;

Константа HTML_DIVIDER содержит текстовое значение !--pre--. Её необходимо разместить в самом начале JXS-разметки вашего приложения. Чтобы понять назначение этого элемента, давайте проанализируем JSX-разметку одного из компонентов.

function MainPage() {
  const { data, isPending, isError } = useQuery(productsQuery());

  if (isPending) {
    return <div className="animate-pulse p-50 font-bold text-pink-500">Loading...</div>;
  }

  if (isError) {
    return <div className="p-50 font-bold text-red-400">Something went wrong...</div>;
  }

  return (
    <>
      <title>Top products</title>
      <meta name="description" content={`Count of top products ${data.length}`} />
      <div className="px-20">
        {data?.map(({ title, brand, price, id }) => (
          <NavLink key={id} to={`/products/${id}`} className="cursor-pointer block hover:opacity-70">
            <span>{title}</span>
            <span className="ml-14">{brand}</span>
            <span className="ml-14">{price}</span>
          </NavLink>
        ))}
      </div>
    </>
  );
}

Обратите внимание на теги <title> и <meta> — после обработки React вынесет их в верхнюю часть сгенерированной разметки. Однако этого недостаточно, чтобы они попали в <head> документа. Здесь нам как раз и пригодится HTML_DIVIDER.

Схематично процесс выглядит так:

Вся эта логика реализована в файле entry-server.tsx, куда мы скоро перейдём.

Существует и альтернативный подход — полностью генерировать HTML с помощью React. Однако в этом случае вам потребуется дополнительно настроить Vite для корректной работы, включая поддержку Hot Module Replacement (HMR), чтобы избежать возможных сбоев в процессе разработки. На мой взгляд, предложенный мной способ в данном случае надёжнее.

Если перечисленные подходы вам не подходят или вы работаете с React 18, обратите внимание на библиотеку react-helmet-async — она предлагает удобный способ управления метаданными.

entry-server.tsx

Мы подошли к заключительному этапу работы с файлом, который будет генерировать готовые HTML‑страницы с контентом. В основном, здесь используется стандартный API библиотек TanStack Query и React Router — их базовые возможности хорошо описаны в официальной документации. В этом руководстве я сосредоточусь исключительно на наиболее интересных и неочевидных аспектах реализации. Давайте начнём с детального рассмотрения кода.

import { QueryClientProvider, dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import fs from 'node:fs';
import path from 'node:path';
import { StrictMode } from 'react';
import { renderToString } from 'react-dom/server';
import serialize from 'serialize-javascript';
import { createStaticHandler, createStaticRouter, StaticRouterProvider } from 'react-router';
import type { StaticHandlerContext } from 'react-router';

import createRoutes from '@/createRoutes.tsx';
import { HTML_DIVIDER } from '@/constants';

const outDir = 'dist';

const routesForSSG = [
  {
    routePath: '/',
    fileName: '/index.html'
  },
  ...[1, 2, 3, 4, 5].map((id) => ({
    routePath: `/products/${id}`,
    fileName: `/products/${id}/index.html`
  }))
];

function render() {
  const template = fs.readFileSync(`${dist}/index.html`, 'utf-8');

  routesForSSG.forEach(async ({ routePath, fileName }) => {
    console.info(`♻️ generating ${fileName}`);

    const t0 = performance.now();

    const request = new Request(`https://whatever.com${routePath}`);
    const queryClient = new QueryClient();
    const routes = createRoutes(queryClient);
    const { query, dataRoutes } = createStaticHandler(routes);
    const context = (await query(request)) as StaticHandlerContext;
    const router = createStaticRouter(dataRoutes, context);
    const rqs = dehydrate(queryClient);

    const htmlString = renderToString(
      <StrictMode>
        <QueryClientProvider client={queryClient}>
          <HydrationBoundary state={rqs}>
            <StaticRouterProvider router={router} context={context} />
            <ReactQueryDevtools />
          </HydrationBoundary>
        </QueryClientProvider>
      </StrictMode>
    );

    const [head, body] = htmlString.split(HTML_DIVIDER);

    const html = template
      .replace('<!--head-outlet-->', head)
      .replace('<!--ssg-outlet-->', body)
      .replace('<!--rqs-outlet-->', `window.__REACT_QUERY_STATE__ = ${serialize(rqs)};`);

    const folder = path.resolve(`${outDir}${fileName.replace('index.html', '')}`);

    if (!fs.existsSync(folder)) {
      fs.mkdirSync(folder, { recursive: true });
    }

    fs.writeFileSync(path.resolve(`${outDir}${fileName}`), html);

    const t1 = performance.now();

    console.info(`✅  ${fileName} processed in ${Math.round(t1 - t0)}ms`);
  });
}

render();

Первое, что бросается в глаза, это библиотека serialize‑javascript, которая решает критически важную задачу: обеспечивает безопасную передачу данных при серверном рендеринге. В нашем случае её основное назначение — предотвращение XSS‑атак с помощью автоматического экранирования опасных символов и специальной обработки данных.

Обратите внимание на массив routesForSSG, именно в него мы добавляем страницы, предназначенные для предварительного рендеринга. В поле fileName необходимо указывать полный путь к файлу вместе с его именем. Такой подход специально разработан для удобной интеграции с NGINX: благодаря точному соответствию путей, сервер сможет обрабатывать URL без дополнительной модификации исходного запроса.

Если требуется обработать все страницы приложения, то можно автоматически сгенерировать их, проанализировав структуру папок. Для этого достаточно пройтись по всем вложенным директориям на первом уровне внутри папки pages и создать страницы на основе этой структуры.

Мы разделяем на две части полученную из renderToString разметку с помощью HTML_DIVIDER:

  1. Первый фрагмент содержит мета-теги и другие элементы, которые помещаются в <head> страницы.

  2. Второй фрагмент содержит разметку приложения и будет помещён в предназначенный для него контейнер.

Подробная схема этого процесса представлена выше.

Перейдём к происхождению данных в window.__REACT_QUERY_STATE__ (о чём я упоминал в главе про entry-client.tsx):

  1. При переходе по URL срабатывает loader (рассмотренный в главе про createRoutes.tsx).

  2. queryClient.prefetchQuery выполняет предзагрузку необходимых данных.

  3. Роутер ожидает разрешения промиса от loader.

  4. dehydrate создаёт сериализуемое представление кеша запросов.

  5. serialize-javascript обрабатывает данные для безопасной вставки.

  6. Результат добавляется в HTML-разметку.

  7. HydrationBoundary будет использоваться для восстановления этого состояния на клиенте.

Завершающий этап: осталось выполнить команду npm run build — и ваше приложение готово к развёртыванию!

Весь код из примера выше можно найти здесь.

Code splitting

Что делать, если ваши страницы весят много (например, более 50 КБ) или используют тяжёлые зависимости? В таком случае стоит рассмотреть разделение кода на чанки, которые будут загружаться динамически — только когда пользователь переходит на соответствующие страницы.

Давайте разберём необходимые изменения в коде для реализации этого подхода. Кроме того, вы узнаете, как отказаться от loadable components, включая сценарии с серверным рендерингом.

Настройка динамических импортов

Для начала перейдём в файл createRoutes.tsx. Первым шагом нужно импортировать функцию lazy из React. Далее потребуется заменить все обычные импорты компонентов страниц на динамические с использованием lazy. Это позволит разделить код на чанки, которые будут загружаться по мере необходимости. Пример:

import type { RouteObject } from 'react-router';
import type { QueryClient } from '@tanstack/react-query';
import { lazy } from 'react';

import Layout from '@/components/layout';
import { productsQuery, productQuery } from '@/queries.ts';

const createRoutes = (queryClient: QueryClient): RouteObject[] => {
  return [
    {
      path: '/',
      Component: Layout,
      children: [
        {
          index: true,
          Component: lazy(() => import('@/pages/main')),
          loader: async () => {
            if (import.meta.env.SSR) {
              await queryClient.prefetchQuery(productsQuery());
            }
          }
        },
        {
          path: '/products/:id',
          Component: lazy(() => import('@/pages/product')),
          loader: async ({ params }) => {
            if (import.meta.env.SSR) {
              await queryClient.prefetchQuery(productQuery(params.id));
            }
          }
        }
      ]
    }
  ];
};

export default createRoutes;

Генерация страниц с помощью renderToPipeableStream

Поскольку renderToString не поддерживает потоковую передачу данных и асинхронное ожидание, мы заменяем его на renderToPipeableStream. Однако в нашем случае мы не будем использовать потоковую передачу в чистом виде, вместо этого мы:

  1. полностью сохраним поток в памяти;

  2. выполним те же операции, что и с обычным рендерингом.

import { QueryClientProvider, dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import fs from 'node:fs';
import path from 'node:path';
import { Writable } from 'node:stream';
import { StrictMode } from 'react';
import { renderToPipeableStream } from 'react-dom/server';
import serialize from 'serialize-javascript';
import { createStaticHandler, createStaticRouter, StaticRouterProvider } from 'react-router';
import type { StaticHandlerContext } from 'react-router';

import createRoutes from '@/createRoutes.tsx';
import { HTML_DIVIDER } from '@/constants';

const outDir = 'dist';

const routesForSSG = [
  {
    routePath: '/',
    fileName: '/index.html'
  },
  ...[1, 2, 3, 4, 5].map((id) => ({
    routePath: `/products/${id}`,
    fileName: `/products/${id}/index.html`
  }))
];

function render() {
  const template = fs.readFileSync(`${dist}/index.html`, 'utf-8');

  routesForSSG.forEach(async ({ routePath, fileName }) => {
    console.info(`♻️ generating ${fileName}`);

    const t0 = performance.now();

    const request = new Request(`https://whatever.com${routePath}`);
    const queryClient = new QueryClient();
    const routes = createRoutes(queryClient);
    const { query, dataRoutes } = createStaticHandler(routes);
    const context = (await query(request)) as StaticHandlerContext;
    const router = createStaticRouter(dataRoutes, context);
    const state = dehydrate(queryClient);

    const { pipe } = renderToPipeableStream(
      <StrictMode>
        <QueryClientProvider client={queryClient}>
          <HydrationBoundary state={state}>
            <StaticRouterProvider router={router} context={context} />
            <ReactQueryDevtools />
          </HydrationBoundary>
        </QueryClientProvider>
      </StrictMode>,
      {
        onAllReady() {
          const chunks: string[] = [];
          const writeable = new Writable({
            write(chunk, _encoding, callback) {
              chunks.push(chunk.toString());
              callback();
            }
          });

          writeable.on('finish', () => {
            const [head, body] = chunks.join('').split(HTML_DIVIDER);
            const rqs = serialize(state);

            const html = template
              .replace('<!--head-outlet-->', head)
              .replace('<!--ssg-outlet-->', body)
              .replace('<!--rqs-outlet-->', `window.__REACT_QUERY_STATE__ = ${rqs};`);

            const folder = path.resolve(`${outDir}${fileName.replace('index.html', '')}`);

            if (!fs.existsSync(folder)) {
              fs.mkdirSync(folder, { recursive: true });
            }

            fs.writeFileSync(path.resolve(`${outDir}${fileName}`), html);

            const t1 = performance.now();

            console.info(`✅  ${fileName} processed in ${Math.round(t1 - t0)}ms`);
          });

          pipe(writeable);
        }
      }
    );
  });
}

render();

Ура. Нам удалось реализовать функциональность без подключения дополнительных зависимостей — в частности, без использования библиотеки loadable components.

Весь код из примера выше можно найти здесь.

Итог

Реализация Static Site Generation (SSG) с помощью React и Vite (webpack тоже подойдет) позволяет создавать быстрые, SEO‑дружественные статические сайты с минимальными накладными расходами.

Как вы могли убедиться, реализация получилась простой и гибкой. И если в будущем у вас возникнут вопросы:

Можно ли добавить SEO и UX оптимизации, не переписывая приложение с CSR на SSR? А сделать SSG не переезжая на какой-нибудь framework?

Вы сможете уверенно ответить: «Да».

Если же у вас уже размещён простой лендинг без динамики на SSR, это тоже повод задуматься о том, чтобы высвободить ресурсы (процессор и память) и ускорить отдачу вашего приложения.

Спасибо за внимание!

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Используете ли вы SSG?
32% Да, через Next.js.8
0% Да, через TanStack Start или React Router as Framework (ex Remix).0
4% Да, через Gatsby.1
8% Да, через Vike.2
28% Да, другое.7
32% Нет.8
Проголосовали 25 пользователей. Воздержались 2 пользователя.
Теги:
Хабы:
+16
Комментарии0

Публикации

Информация

Сайт
domclick.ru
Дата регистрации
Дата основания
Численность
501–1 000 человек
Местоположение
Россия
Представитель
Dangorche