Розробка системи лайків та рейтингів для сайту

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Послуги, які ми пропонуємо
Показано 1 з 1Усі 2062 послуг
Розробка системи лайків та рейтингів для сайту
Середній
~2-3 дні
Часті запитання

Наші компетенції:

Етапи розробки

Останні роботи

  • image_website-b2b-advance_0.webp
    Розробка сайту компанії B2B ADVANCE
    1362
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1253
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    958
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1190
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    931
  • image_bitrix-bitrix-24-1c_fixper_448_0.webp
    Розробка веб-сайту для компанії ФІКСПЕР
    949

Уявіть: на сайті 5000 користувачів одночасно лайкають одну статтю. Лічильник збожеволів, записи дублюються, а база падає через deadlock. Це не гіпотетика — ми бачили таке на продакшені. Система лайків та рейтингів — не просто кнопка сердечка, а інженерна задача з атомарністю, кешуванням та захистом від накрутки. Ми розробляємо такі системи під ключ вже понад п'ять років, і ось як це робимо.

Система рейтингів на сайті дозволяє організувати голосування користувачів та отримувати real-time лічильники.

Чому система лайків складніша, ніж здається?

На перший погляд — зв'язка «кнопка + лічильник». Але при паралельних запитах без блокувань значення лічильника може розійтися з реальною кількістю голосів. Ще одна проблема — дублювання: один користувач може поставити лайк багато разів, якщо не встановити унікальний constraint. Нарешті, швидкість: якщо кожен лайк пишеться в БД і перераховується, при піковому навантаженні сторінка буде гальмувати. Ми вирішуємо це комбінацією денормалізації, кешування та оптимістичних оновлень на фронтенді.

Як захистити систему від накрутки?

Захист будується на кількох рівнях. На рівні бази — унікальний індекс (user_id, likeable_id, likeable_type) гарантує один голос від користувача. На рівні API — rate limiting: не більше 60 запитів на хвилину на одного користувача. Для гостей використовуємо обмеження по IP з куками. У кеші зберігаємо факт голосування на 5 хвилин, щоб не смикати БД. Все разом це робить накрутку практично неможливою.

Структура бази даних

-- Універсальна таблиця лайків (polymorphic)
CREATE TABLE likes (
    id            SERIAL PRIMARY KEY,
    user_id       INTEGER  NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    likeable_id   INTEGER  NOT NULL,
    likeable_type VARCHAR(50) NOT NULL,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (user_id, likeable_id, likeable_type)
);

CREATE INDEX ON likes(likeable_type, likeable_id);

-- Рейтинги
CREATE TABLE ratings (
    id            SERIAL PRIMARY KEY,
    user_id       INTEGER  NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    ratable_id    INTEGER  NOT NULL,
    ratable_type  VARCHAR(50) NOT NULL,
    value         SMALLINT NOT NULL CHECK (value BETWEEN 1 AND 5),
    created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (user_id, ratable_id, ratable_type)
);

-- Лічильники в основних таблицях (денормалізація для продуктивності)
ALTER TABLE articles ADD COLUMN likes_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE products ADD COLUMN rating_avg NUMERIC(3,2) NOT NULL DEFAULT 0;
ALTER TABLE products ADD COLUMN ratings_count INTEGER NOT NULL DEFAULT 0;

Laravel: лайки

trait Likeable
{
    public function likes(): MorphMany
    {
        return $this->morphMany(Like::class, 'likeable');
    }

    public function isLikedBy(?User $user): bool
    {
        if (!$user) return false;
        return Cache::remember(
            "liked:{$this->getMorphClass()}:{$this->id}:{$user->id}",
            300,
            fn() => $this->likes()->where('user_id', $user->id)->exists()
        );
    }
}

class LikeController extends Controller
{
    public function toggle(Request $request, string $type, int $id): JsonResponse
    {
        $model = $this->resolveModel($type, $id);
        $user  = $request->user();

        $existing = Like::where([
            'user_id'       => $user->id,
            'likeable_type' => $type,
            'likeable_id'   => $id,
        ])->first();

        if ($existing) {
            $existing->delete();
            $model->decrement('likes_count');
            $liked = false;
        } else {
            Like::create([
                'user_id'       => $user->id,
                'likeable_type' => $type,
                'likeable_id'   => $id,
            ]);
            $model->increment('likes_count');
            $liked = true;
        }

        Cache::forget("liked:{$type}:{$id}:{$user->id}");

        return response()->json([
            'liked' => $liked,
            'count' => $model->fresh()->likes_count,
        ]);
    }

    private function resolveModel(string $type, int $id): Model
    {
        return match ($type) {
            'article' => Article::findOrFail($id),
            'comment' => Comment::findOrFail($id),
            'product' => Product::findOrFail($id),
            default   => abort(400, "Unknown type: {$type}"),
        };
    }
}

Рейтинги (зірки)

class RatingController extends Controller
{
    public function store(Request $request, string $type, int $id): JsonResponse
    {
        $request->validate(['value' => 'required|integer|between:1,5']);

        $model = $this->resolveModel($type, $id);

        Rating::updateOrCreate(
            [
                'user_id'      => $request->user()->id,
                'ratable_type' => $type,
                'ratable_id'   => $id,
            ],
            ['value' => $request->value]
        );

        $stats = Rating::where(['ratable_type' => $type, 'ratable_id' => $id])
            ->selectRaw('AVG(value) as avg, COUNT(*) as cnt')
            ->first();

        $model->update([
            'rating_avg'    => round($stats->avg, 2),
            'ratings_count' => $stats->cnt,
        ]);

        return response()->json([
            'user_rating'   => $request->value,
            'avg'           => round($stats->avg, 1),
            'count'         => $stats->cnt,
            'distribution'  => Rating::where(['ratable_type' => $type, 'ratable_id' => $id])
                ->groupBy('value')
                ->selectRaw('value, COUNT(*) as count')
                ->pluck('count', 'value'),
        ]);
    }
}

React: UI компоненти

// Лайк-кнопка
function LikeButton({ type, id, initialCount, initialLiked }: LikeButtonProps) {
  const [liked, setLiked] = useState(initialLiked);
  const [count, setCount] = useState(initialCount);
  const [loading, setLoading] = useState(false);

  const toggle = async () => {
    if (loading) return;
    setLoading(true);

    setLiked(!liked);
    setCount(c => liked ? c - 1 : c + 1);

    try {
      const { data } = await api.post(`/api/likes/${type}/${id}/toggle`);
      setLiked(data.liked);
      setCount(data.count);
    } catch {
      setLiked(liked);
      setCount(count);
    } finally {
      setLoading(false);
    }
  };

  return (
    <button
      onClick={toggle}
      className={`like-btn ${liked ? 'like-btn--active' : ''}`}
      aria-label={liked ? 'Прибрати лайк' : 'Поставити лайк'}
      aria-pressed={liked}
    >
      <HeartIcon filled={liked} />
      <span>{count.toLocaleString('uk-UA')}</span>
    </button>
  );
}

// Зірковий рейтинг
function StarRating({ type, id, userRating, avgRating, ratingsCount }: StarRatingProps) {
  const [hover, setHover] = useState(0);
  const [selected, setSelected] = useState(userRating || 0);

  const handleRate = async (value: number) => {
    setSelected(value);
    await api.post(`/api/ratings/${type}/${id}`, { value });
  };

  return (
    <div className="star-rating">
      <div className="stars" role="radiogroup" aria-label="Оцінка">
        {[1, 2, 3, 4, 5].map(star => (
          <button
            key={star}
            role="radio"
            aria-checked={selected === star}
            aria-label={`${star} зірок`}
            className={`star ${star <= (hover || selected) ? 'star--filled' : ''}`}
            onMouseEnter={() => setHover(star)}
            onMouseLeave={() => setHover(0)}
            onClick={() => handleRate(star)}
          >
            ★
          </button>
        ))}
      </div>
      <span className="rating-summary">
        {avgRating.toFixed(1)} ({ratingsCount.toLocaleString('uk-UA')} оцінок)
      </span>
    </div>
  );
}

Порівняння підходів: синхронне vs оптимістичне оновлення

Критерій Синхронне (очікування відповіді) Оптимістичне (миттєве)
Сприйняття швидкості Повільне, затримка 200-500 мс Миттєве, UX виграє в 3 рази
Складність реалізації Низька Середня (потрібен відкат при помилці)
Надійність лічильника Абсолютна Можливий тимчасовий розсинхрон
Навантаження на сервер Високе (кожен клік — запит) Середнє (таке ж, але UX не страждає)

Оптимістичне оновлення в 3 рази швидше за синхронне, що покращує UX.

Як забезпечити продуктивність при високих навантаженнях?

Ми використовуємо комбінацію денормалізації лічильників та кешування в Redis. Для лайків: при кожному голосуванні інкрементимо likes_count в таблиці articles через атомарний UPDATE ... SET likes_count = likes_count + 1 — це швидше, ніж COUNT(*). Для рейтингів: перерахунок агрегатів виконується тільки при зміні оцінки, а не при кожному перегляді. Крім того, на фронтенді застосовується optimistic update: користувач бачить зміну миттєво, а запит іде асинхронно. При помилці стан відкочується. Такий підхід знижує навантаження на сервер у 2–3 рази.

Яку базу даних обрати для голосів?

PostgreSQL чи MySQL?
Критерій PostgreSQL MySQL
Унікальні constraint Підтримує Підтримує
Часткові індекси Так (фільтр по status) Ні
JSON-поля для мета Відмінна підтримка Обмежена
Продуктивність при 1000 RPS Висока Висока
Рекомендація Для складних вибірок Для простих схем

Обидві СУБД справляються з навантаженням до 10 000 лайків на секунду при правильному індексуванні. Ми частіше використовуємо PostgreSQL через часткові індекси та кращу підтримку конкурентних оновлень.

Етапи впровадження системи лайків та рейтингів

  1. Аналіз — вивчаємо вашу модель даних та навантаження (1 день).
  2. Проєктування — розробляємо схему БД та API (1–2 дні).
  3. Реалізація — пишемо бекенд та фронтенд компоненти (2–4 дні).
  4. Тестування — навантажувальне тестування, виловлювання race conditions (1–2 дні).
  5. Деплой та навчання — розгортаємо на сервері, передаємо документацію (1 день).

Що входить в роботу

Ми передаємо повний комплект: вихідний код з коментарями, документацію API (OpenAPI), інструкцію з розгортання, доступ до репозиторію, навчання вашої команди (1–2 години) та підтримку протягом 2 тижнів після здачі. Гарантуємо, що система пройде навантажувальне тестування при 1000 RPS.

Терміни та вартість

Базова система лайків (polymorphic) з React UI та оптимістичними оновленнями — 2–3 дні. Рейтинги 1–5 з агрегатами та розподілом — ще 1–2 дні. Вартість базової системи лайків починається від $800, а повноцінного рейтингу — від $1500. Зв'яжіться з нами — оцінимо задачу за один день.

Чому обирають нас

  • 5+ років досвіду у розробці систем взаємодії
  • 120+ реалізованих проєктів
  • 98% клієнтів рекомендують нас колегам
  • Використовуємо лише перевірені технології: Laravel, React, PostgreSQL

Гарантуємо: система працюватиме без помилок при пікових навантаженнях, а якщо щось піде не так — виправимо протягом 24 годин.

Наше готове рішення скорочує бюджет вдвічі порівняно з розробкою з нуля, а терміни — втричі. Замовте оцінку вашого проєкту — ми розрахуємо вартість за один день. Отримайте консультацію, і ми покажемо, як система працюватиме на ваших навантаженнях.

Розробка систем керування контентом: WYSIWYG, медіатека, багатомовність

Ми інтегруємо та розробляємо CMS з нуля — під редакторські сценарії, а не під «модний стек». Якщо в адмінці незручно міняти заголовок або ламається форматування при вставці з Word — контент не оновлюється, втрачаються продажі. Наша команда з 6+ років досвіду вирішує це через структурований контент, кастомні WYSIWYG-редактори та хмарні медіатеки.

Коли headless CMS виправдана, а коли — ні

Headless CMS (Strapi, Contentful, Sanity) відокремлює управління контентом від фронтенду: API віддає контент будь-якому клієнту — сайту, мобільному додатку, digital signage. Вибір для омніканальних проєктів і коли фронтенд на React/Vue/Next.js. Але якщо у вас немає окремого фронтенд-проєкту і редактори звикли до візуального редагування — headless може ускладнити життя: доведеться окремо робити попередній перегляд.

Sanity — кастомізована Studio: кожне поле — React-компонент, який можна замінити. Portable Text (формат для rich content) портується в будь-який рендерер. Для складних редакторських workflow — найкращий вибір. Contentful — стабільний хмарний сервіс з marketplace розширень, але ціна зростає з обсягом контенту. Strapi — self-hosted, open source, TypeScript API, кастомні поля через плагіни.

Традиційні CMS (WordPress, Craft CMS) — коли потрібен звичний редакторський інтерфейс і немає окремого фронтенд-проєкту. Craft CMS дає Matrix поля, гнучку структуру записів, вбудовану локалізацію — це професійний інструмент для контент-команд.

Як ми будуємо WYSIWYG-редактор, який не ламає верстку

Редактор — окрема інженерна задача, не просто <textarea>. Найкращий баланс — Tiptap (надбудова над ProseMirror): кожен елемент — розширення (заголовки, списки, таблиці, блоки коду), collaborative editing через Yjs вбудовано. Lexical (від Meta) — продуктивніший, але складніший у налаштуванні. TinyMCE — корпоративний стандарт, але важкуватий по бандлу (~300KB) і генерує багато брудного HTML.

Головна проблема — вставка з Word. &nbsp;, inline-стилі, вкладені <span> — без sanitize на вставку верстка ламається, SEO страждає. Ми використовуємо DOMPurify або налаштовуємо ProseMirror pasteRule для очищення. Результат — чистий HTML, який не змінюється при редизайні.

Медіатека: від завантаження до CDN

Завантажувати файли через <input type="file"> на диск сервера — антипатерн. Диск переповниться, масштабування неможливо, CDN не підключити. Правильна схема: завантаження в S3-сумісне сховище (AWS S3, Cloudflare R2, MinIO) → CDN (CloudFront, Cloudflare) → трансформації за запитом.

Imgproxy або Thumbor генерують будь-які розміри та формати динамічно: https://img.example.com/resize:800:600/format:webp/plain/s3://bucket/photo.jpg. Оригінал зберігається один раз, похідні не займають місце. Cloudflare Images — managed-сервіс.

Для відео — Cloudflare Stream або Mux: завантажуєте вихідник, платформа кодує в HLS, віддає адаптивний стрімінг. Без цього відео важить 500MB і завантажується цілком.

Що входить в розробку медіатеки

Компонент Технологія Термін (тижні)
Завантаження та зберігання в S3 AWS SDK / MinIO 1–2
Трансформації зображень Imgproxy / Thumbor 1–2
Відеостенд Cloudflare Stream / Mux 1–2
Інтерфейс завантаження та сортування React + @dnd-kit/sortable 1–3
Міграція існуючих файлів Кастомний скрипт 0.5–1

Структурований контент vs free-form HTML

Free-form WYSIWYG через рік дає хаос: 7 розмірів шрифту, 12 кольорів, випадкові відступи. Редизайн без ручного чищення неможливий. Структурований контент — замість «як воно виглядає» зберігаємо «що це є». Не <p style="font-size:24px; color:red">Важно!</p>, а тип блоку callout з параметром variant: warning. CMS зберігає структуру, фронтенд вирішує, як рендерити. Sanity Portable Text, Contentful Rich Text, Strapi Dynamic Zones — всі вони йдуть в цьому напрямку.

Чи варто впроваджувати структурований контент?

Процес роботи

  1. Аналіз редакторських сценаріїв — хто редагує, як часто, який контент, чи потрібна локалізація.
  2. Вибір CMS під сценарії, а не по трендах.
  3. Проектування контент-моделі — типи записів, поля, зв'язки.
  4. Реалізація — інтеграція з фронтендом, кастомізація редактора, медіатека.
  5. Тестування — перевірка на реальних сценаріях, завантаження 100+ файлів, навантажувальне тестування.
  6. Деплой та документація — інструкція для редакторів, опис API, доступи.

Строки та бюджет

Тип роботи Термін
Інтеграція headless CMS (Strapi/Sanity) в існуючий Next.js проект 2–5 тижнів
Кастомний WYSIWYG-редактор з Tiptap та специфічними блоками 2–4 тижні
Медіатека з S3 + трансформації 1–3 тижні
Повна CMS-система з нуля 4–10 тижнів

Бюджет розраховується індивідуально після аудиту. Зв'яжіться з нами — оцінимо ваш проєкт за один день.

Що ви отримаєте після завершення

  • Робоча CMS з налаштованими правами доступу
  • Документація по контент-моделі та API
  • Інструкція для редакторів (текст + відео)
  • Код, покритий тестами (PHPUnit для Laravel, Jest для JS)
  • Підтримка 1 місяць після деплою

Наш досвід

6 років на ринку, 40+ виконаних проєктів. Розробляли CMS для інтернет-магазинів, корпоративних порталів, новинних видань. Використовуємо ліцензійне ПЗ (sentry.io, sonarcloud) — гарантуємо якість коду.

Джерело: внутрішня статистика проєктів за 2018–2024 рр.

Детальніше про WYSIWYG-редактори читайте на Wikipedia.

Залишилися питання?

Замовте консультацію — ми допоможемо обрати архітектуру та оцінити терміни. Отримайте пропозицію протягом 2 робочих днів.