Full Stack Personal Blog 11: Post Like Feature Design
cz2025.06.03 15:05

By implementing the post like feature with Prisma and GraphQL, you can learn how to design efficient database models, handle simple relationships (such as the one-to-many relationship between posts and likes), and manage data queries and mutations in GraphQL.

1. Like Logic

  • A post can be liked by multiple users
  • Each user can only like a post once; clicking again will unlike the post

2. Like Model Design

Add a Like model to prisma.schema and sync the database with npx prisma migrate dev --name add_like

model Like {
  id          String   @id @default(cuid())
  postId      String
  post        Post     @relation(fields: [postId], references: [id])
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
  createdBy   User     @relation(fields: [createdById], references: [id])
  createdById String

  @@unique([createdById, postId]) // Ensure each user can only like a post once
}

3. Add Like APIs in GraphQL

Add likePost and unlikePost APIs:

// graphql/types/Like.ts
import { builder } from '../builder'

builder.prismaObject('Like', {
  fields: (t) => ({
    id: t.exposeID('id'),
    postId: t.exposeID('postId'),
    post: t.relation('post'),
    createdAt: t.expose('createdAt', { type: 'DateTime' }),
    updatedAt: t.expose('updatedAt', { type: 'DateTime' }),
    createdById: t.exposeID('createdById'),
    createdBy: t.relation('createdBy'),
  }),
})

builder.mutationType({
  fields: (t) => ({
    likePost: t.field({
      type: 'Like' as any,
      args: {
        postId: t.arg.id(),
        userId: t.arg.id(),
      },
      resolve: async (_root: any, args: any, ctx: any) => {
        const { postId, userId } = args
        const like = await ctx.prisma.like.create({
          data: {
            postId,
            createdById: userId,
          },
        })
        return like
      },
    }),
    unlikePost: t.field({
      type: 'Like' as any,
      args: {
        postId: t.arg.id(),
        userId: t.arg.id(),
      },
      resolve: async (_root: any, args: any, ctx: any) => {
        const { postId, userId } = args
        const like = await ctx.prisma.like.delete({
          where: {
            createdById_postId: {
              createdById: userId,
              postId,
            },
          },
        })
        return like
      },
    }),
  }),
})

4. Get Like Data in Post API

  • likeCount: count the number of likes for a post
  • isLikedByUser: check if the current user has liked the post
  • When deleting a post, also delete its likes
// graphql/types/Post.ts
import prisma from '@/lib/prisma'
import { builder } from '../builder'

builder.prismaObject('Post', {
  fields: (t: any) => ({
    //...
    likes: t.relation('likes'),
    likeCount: t.int({
      resolve: async (parent: any, _args: any, ctx: any) => {
        const likeCount = await ctx.prisma.like.count({
          where: {
            postId: parent.id,
          },
        })
        return likeCount
      },
    }),
    isLikedByUser: t.boolean({
      args: {
        userId: t.arg.id(),
      },
      resolve: async (parent: any, _args: any, ctx: any) => {
        const { userId } = _args
        if (!userId) {
          return false
        }
        const like = await ctx.prisma.like.findUnique({
          where: {
            createdById_postId: {
              createdById: userId,
              postId: parent.id,
            },
          },
        })
        return like !== null
      },
    }),
  }),
})

// Define mutation type for deleting a post
builder.mutationType({
  fields: (t: any) => ({
    deletePost: t.field({
      type: 'Post' as any,
      args: {
        id: t.arg.id(),
      },
      resolve: async (_root: any, args: any, ctx: any) => {
        const { id } = args

        // Delete Like records related to the Post
        await ctx.prisma.like.deleteMany({
          where: { postId: id },
        })

        // Then delete the Post
        await ctx.prisma.post.delete({
          where: { id },
        })

        return true
      },
    }),
  }),
} as any)

5. Frontend Like Component

// LikeAction.tsx
'use client'
import { Button } from '@/components/ui/button'
import { gql, useMutation } from '@apollo/client'
import { Heart } from 'lucide-react'
import { useLocale } from 'next-intl'
import { useState } from 'react'
import { toast } from 'sonner'

type Props = {
  post: any
  currentUserId?: string
}

const LikeAction = ({ post, currentUserId }: Props) => {
  const locale = useLocale()
  const [isLiked, setIsLiked] = useState<boolean>(post.isLikedByUser)
  const [likeCount, setLikeCount] = useState<number>(post.likeCount)

  const [likePost, { loading: likingPost }] = useMutation(
    gql`
      mutation LikePost($postId: ID!, $userId: ID!) {
        likePost(postId: $postId, userId: $userId) {
          id
        }
      }
    `,
    {
      onCompleted: (data) => {
        setIsLiked(true)
        setLikeCount((prev) => prev + 1)
      },
    },
  )

  const [unlikePost, { loading: unlikingPost }] = useMutation(
    gql`
      mutation UnlikePost($postId: ID!, $userId: ID!) {
        unlikePost(postId: $postId, userId: $userId) {
          id
        }
      }
    `,
    {
      onCompleted: (data) => {
        setIsLiked(false)
        setLikeCount((prev) => prev - 1)
      },
    },
  )

  const handleLikeToggle = () => {
    if (!currentUserId) {
      toast.info('Please login to like this post')
    }
    if (isLiked) {
      unlikePost({
        variables: { postId: post.id, userId: currentUserId },
      })
    } else {
      likePost({
        variables: { postId: post.id, userId: currentUserId },
      })
    }
  }
  return (
    <Button
      variant="ghost"
      size="sm"
      onClick={handleLikeToggle}
      disabled={likingPost || unlikingPost}
      className={`flex cursor-pointer items-center gap-1 ${isLiked ? 'text-red-500' : 'text-gray-500'}`}
    >
      <Heart className={`h-4 w-4 ${isLiked ? 'fill-current' : ''}`} />
      <span>{likeCount}</span>
    </Button>
  )
}

export default LikeAction

Comments