A Comprehensive Guide to Building a Like and Rating System for Websites

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.

Development and maintenance of all types of websites:

Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Showing 1 of 1All 2062 services
A Comprehensive Guide to Building a Like and Rating System for Websites
Medium
~2-3 days
Frequently Asked Questions

Our competencies:

Development stages

Latest works

  • image_website-b2b-advance_0.webp
    B2B ADVANCE company website development
    1362
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1253
  • image_websites_belfingroup_462_0.webp
    Website development for BELFINGROUP
    958
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1190
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    931
  • image_bitrix-bitrix-24-1c_fixper_448_0.webp
    Website development for FIXPER company
    949

Imagine: 5,000 users simultaneously like an article. The counter goes crazy, records duplicate, and the database crashes under a deadlock. This isn't hypothetical—we've seen it in production. A like and rating system is not just a 'heart button'; it's an engineering challenge involving atomicity, caching, and spam protection. We've been building such systems for over five years, and here's how we do it. Over 10,000 likes per second are possible with proper indexing.

Why a like system is more complex than it seems?

At first glance—'button + counter.' But under parallel requests without locks, the counter value can diverge from the actual vote count. Another issue is duplication: one user can like multiple times if no unique constraint is in place. Finally, speed: if every like is written to the DB and recalculated, the page will lag under peak load. We solve this with a combination of denormalization, caching, and optimistic frontend updates.

How to protect the system from spam?

Protection is built on multiple levels. User voting is controlled via unique constraints. At the database level, a unique index on (user_id, likeable_id, likeable_type) guarantees one vote per user. At the API level—rate limiting: no more than 60 requests per minute per user. For guests, we use IP-based restrictions with cookies. In the cache, we store the voting fact for 5 minutes to avoid hitting the DB. Together, this makes spam practically impossible.

The following schema supports polymorphic likes and ratings:

-- Universal polymorphic likes table
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);

-- Ratings table
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)
);

-- Denormalized counters in main tables
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;

As per Laravel's official documentation, the morphMany relation is used for polymorphic associations. Below is the Laravel trait for likeable models:

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}"),
        };
    }
}

The rating controller below handles 1–5 star ratings:

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 components for the like button and star rating are shown below:

// Like button
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 ? 'Remove like' : 'Like'}
      aria-pressed={liked}
    >
      <HeartIcon filled={liked} />
      <span>{count.toLocaleString('en-US')}</span>
    </button>
  );
}

// Star rating
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="Rating">
        {[1, 2, 3, 4, 5].map(star => (
          <button
            key={star}
            role="radio"
            aria-checked={selected === star}
            aria-label={`${star} star${star > 1 ? 's' : ''}`}
            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('en-US')} ratings)
      </span>
    </div>
  );
}

Here's a comparison of synchronous versus optimistic update approaches:

Criteria Synchronous (wait for response) Optimistic (instant)
Perceived speed Slow, 200–500 ms delay Instant, UX 3x better
Implementation complexity Low Medium (rollback on error)
Counter reliability Absolute Possible temporary mismatch
Server load High (each click = request) Medium (same requests, but UX unaffected)

How to ensure performance under high load?

We use a combination of denormalized counters and Redis caching. For likes: each vote atomically increments likes_count via UPDATE ... SET likes_count = likes_count + 1—faster than COUNT(*). For ratings: aggregates are recalculated only when a rating changes, not on every view. Additionally, optimistic updates are applied on the frontend: the user sees the change instantly, while the request goes asynchronously. On error, the state is rolled back. This approach can reduce server load by 30% and improve perceived response time by 200ms. Real-time counters are updated via Redis pub/sub.

For the database, PostgreSQL is recommended due to partial indexes and better concurrent update support. Here's a comparison:

PostgreSQL or MySQL?
Criteria PostgreSQL MySQL
Unique constraints Supported Supported
Partial indexes Yes (filter on status) No
JSON fields for metadata Excellent Limited
Performance at 1000 RPS High High
Recommendation For complex queries For simple schemas

Both DBMS can handle up to 10,000 likes per second with proper indexing. We usually use PostgreSQL for its partial indexes and better support for concurrent updates.

Implementation stages include: analysis (1 day), design (1–2 days), implementation (2–4 days), testing (1–2 days), deployment and training (1 day).

We deliver a complete package: source code with comments, API documentation (OpenAPI), deployment instructions, repository access, team training (1–2 hours), and support for 2 weeks after delivery. We guarantee the system passes load testing at 1000 RPS.

A basic like system starts at $2,500 and takes 2–3 days. Ratings add $1,500 and another 1–2 days. Typical budget for a full system ranges from $4,000 to $7,000. Contact us—we'll assess the task in one day.

Our team has five years of experience in developing interaction systems, over 120 completed projects, and a 98% client recommendation rate. We only use proven technologies: Laravel, React, PostgreSQL. We guarantee the system will work flawlessly under peak loads. If anything goes wrong, we'll fix it within 24 hours. Our ready-made solution halves the budget compared to building from scratch and reduces timelines by three times. Get a project estimate—we'll calculate the cost in one day. Contact us for a consultation, and we'll show you how the system works under your load.

CMS development: solving real editorial bottlenecks, not installing plugins

A news publisher had a WordPress site with 5 editors. Every article required 15 minutes of manual formatting because the WYSIWYG mangled pasted text. After 6 months, the database had 12 different font sizes and 7 custom colors. The redesign would cost $30k just to clean up the mess — and no one would admit it.

We develop content management systems (CMS) that prevent this from day one. Instead of free-form <textarea> hell, we design structured content models, custom WYSIWYG editors using ProseMirror, and media libraries that offload to S3+CDN within two sprints. This is CMS development without shortcuts.

When is headless CMS justified and when not?

Headless CMS (Strapi, Contentful, Sanity) decouples content management from frontend rendering — the API serves content to any client: website, mobile app, smart display. You get omnichannel delivery and a React/Vue frontend that never touches the admin panel. But if your editors need “save and see” preview and you have no separate frontend team, headless costs extra: you must build a preview layer or use a service like Vercel’s preview deployments.

Sanity customises Studio down to the field level — each field is a React component you can replace. Portable Text (its rich content format) ports to any renderer via custom serializers. For complex editorial workflows with multiple authors, Sanity is the best choice. Contentful offers stable cloud infrastructure with a marketplace of extensions, but monthly bills scale with content volume — typical enterprise plans are $500–$2,000/month. Strapi is self-hosted, open source, with a TypeScript API and custom fields via plugins, but you manage the hosting and backups.

Traditional CMS (WordPress, Craft CMS) works when editors need a familiar admin UI and the frontend is rendered server-side. Craft CMS provides Matrix fields, flexible entry structures, and built-in localization — it’s a professional tool for content teams that need granular permissions and versioning.

How do we build a WYSIWYG editor that doesn’t break layout?

The editor is the most complex component — not a <textarea>. The sweet spot is Tiptap, built on ProseMirror. Every element (headings, lists, tables, code blocks, images) is an extension. Collaborative editing via Yjs works out of the box. Lexical (Meta) is more performant (>60fps typing on mobile) but harder to extend. TinyMCE is a corporate standard at 300KB bundle, but it generates dirty HTML on paste — inline styles, nested <span>, &nbsp; everywhere.

The root cause: pasting from Word. font-family, mso-* properties, empty <span> tags — all leak into the page unless you sanitize. We configure ProseMirror’s pasteRule with DOMPurify to strip everything except allowed tags. Result: clean, semantic HTML that survives a redesign without manual cleanup. Editors save 2–4 hours per week per person.

Media library: from upload to CDN with transformation

Saving files to the server disk is the classic mistake. The disk fills, scaling fails, and CDN becomes impossible. The correct pipeline: upload to S3-compatible storage (AWS S3, Cloudflare R2, MinIO) → CDN (CloudFront, Cloudflare) → on‑the‑fly transformations.

Imgproxy or Thumbor generate any size and format dynamically: https://img.example.com/resize:800:600/format:webp/plain/s3://bucket/photo.jpg. The original lives once, derivatives never occupy disk. Cloudflare Images costs $5 per 100k images, including transformations. Video uploads use Cloudflare Stream or Mux — encode to HLS, adaptive streaming for any bandwidth. Without this, a 1080p video (500MB) loads entirely before play, causing a 5–8 second delay on 3G.

What’s included in media library development

Component Technology Timeline (weeks)
Upload and storage in S3 AWS SDK / MinIO 1–2
Image transformations Imgproxy / Thumbor 1–2
Video streaming Cloudflare Stream / Mux 1–2
Upload and sorting UI React + @dnd-kit/sortable 1–3
Migration of existing files Custom script 0.5–1

Why structured content outperforms free-form HTML

Free-form WYSIWYG leads to chaos in a year: 7 font sizes, 12 colors, random margins. Redesign requires manual cleanup of thousands of posts. Structured content stores “what” instead of “how”: not <p style="font-size:24px; color:red">Important!</p>, but a callout block with variant: warning. The CMS stores the structure; the frontend decides rendering. Sanity Portable Text, Contentful Rich Text, and Strapi Dynamic Zones all follow this pattern — and it reduces rework by 70% during redesigns.

Typical editorial time savings with structured content
  • A news site with 50 articles per week: editors save 10 hours/week on formatting.
  • A corporate portal with 1000 existing pages: migration from free-form to structured content takes 3–5 days, cutting page load by 40% (cleaner HTML).

Work process

  1. Analysis of editorial workflows — who edits, how often, what content (articles, landing pages, product data), whether localization is needed.
  2. CMS selection — based on scenarios, not trends. We compare headless vs traditional with a weighted matrix.
  3. Content model design — record types, fields, relationships, validation rules.
  4. Implementation — frontend integration, editor customization, media library, previews.
  5. Testing — real‑world scenarios: paste from Word, upload 100+ files simultaneously, load test the API (200 req/s target).
  6. Deployment and documentation — editor guide (text + video), API description, access credentials, 1 month support.

Timelines and budget

Type of work Timeline Budget
Integration of headless CMS (Strapi/Sanity) into existing Next.js project 2–5 weeks Discussed individually
Custom WYSIWYG editor with Tiptap and specific blocks 2–4 weeks Discussed individually
Media library with S3 + transformations 1–3 weeks Discussed individually
Full CMS system from scratch 4–10 weeks Discussed individually

Budget is calculated individually after an audit. Client examples: a mid‑sized media site saved $40k/year by eliminating manual formatting; an e‑commerce platform reduced time‑to‑publish by 60% with a headless Sanity setup. Contact us for a free project estimate.

What you get after delivery

  • Working CMS with configured access rights (admin, editor, reviewer)
  • Full content model documentation and API reference
  • Editor training documentation (text + video)
  • Code covered by tests (PHPUnit for Laravel, Jest for JS)
  • 1 month post‑launch support with SLA

Our experience and guarantees

Over 40 completed CMS projects — from small editorial sites to enterprise media portals with 200k daily unique visitors. We use licensed tools (Sentry for error monitoring, SonarCloud for code quality) and guarantee zero critical bugs at launch. All code is version‑controlled and deployable via CI/CD.

For your specific needs, contact us to discuss requirements. We’ll provide a technical proposal within 2 business days.