Vibe coding with AI course · Module 7: LinkedIn Clone

Professional Feed and Networking - Career Updates

7 min read
In this lesson4

On Twitter people post about their breakfast, on LinkedIn about their promotion to first mate. A professional feed works like any other, but the content is about work: new positions, work anniversaries, articles and industry insights. It's a difference in tone, not in mechanics. LinkedIn is professional content, job offers and CVs, while Twitter is personal matters and casual conversation.

Post types

A post can be plain text or something special, such as a job change announcement. A union type lists every possibility:

type PostType = 'text' | 'article' | 'image' | 'video' | 'job_change' | 'work_anniversary';

interface FeedPost {
  id: string;
  type: PostType;
  author: {
    id: string;
    name: string;
    avatar: string;
    headline: string;
  };
  content: string;
  media?: {
    type: 'image' | 'video' | 'document';
    url: string;
    thumbnail?: string;
  };
  likes: number;
  comments: number;
  reposts: number;
  timestamp: Date;

  // Special fields for job change posts
  jobChange?: {
    previousCompany: string;
    newCompany: string;
    newPosition: string;
  };
}

type PostType = 'text' | 'article' | ... is a union type: a value of this type can only be one of six names, from text, article, image and video to job_change and work_anniversary. The jobChange field is optional and only makes sense in job_change posts. The type itself displays nothing, but it protects you from typos.

The post card

FeedPostCard has an author header, content, stats and a row of actions:

'use client';

import { useState } from 'react';

function FeedPostCard({ post }: { post: FeedPost }) {
  const [liked, setLiked] = useState(false);
  const [showComments, setShowComments] = useState(false);

  return (
    <div className="bg-white rounded-lg shadow mb-3">
      {/* Header */}
      <div className="flex items-start gap-3 p-6 pb-3">
        <img
          src={post.author.avatar}
          alt={post.author.name}
          className="w-12 h-12 rounded-full"
        />
        <div className="flex-1">
          <div className="flex items-start justify-between">
            <div>
              <h3 className="font-bold hover:underline cursor-pointer">
                {post.author.name}
              </h3>
              <p className="text-sm text-gray-600">{post.author.headline}</p>
              <p className="text-xs text-gray-500 mt-1">
                {formatTimeAgo(post.timestamp)} •
              </p>
            </div>
            <button className="text-gray-500 hover:bg-gray-100 rounded-full p-2">
              •••
            </button>
          </div>
        </div>
      </div>

      {/* Content */}
      <div className="px-6 pb-3">
        {/* Job Change Celebration */}
        {post.type === 'job_change' && post.jobChange && (
          <div className="mb-3 bg-gradient-to-r from-blue-50 to-green-50 border border-blue-200 rounded-lg p-4">
            <p className="font-semibold mb-2">Started a new position</p>
            <p className="text-sm text-gray-700">
              {post.jobChange.newPosition} at {post.jobChange.newCompany}
            </p>
          </div>
        )}

        {/* Text Content */}
        <p className="text-gray-900 whitespace-pre-wrap mb-3">{post.content}</p>

        {/* Media */}
        {post.media && post.media.type === 'image' && (
          <img
            src={post.media.url}
            alt="Post media"
            className="w-full rounded-lg"
          />
        )}
      </div>

      {/* Stats */}
      <div className="px-6 py-2 border-t border-b flex items-center justify-between text-sm text-gray-600">
        <button className="hover:underline">
          {post.likes} likes
        </button>
        <div className="flex gap-3">
          <button className="hover:underline">
            {post.comments} comments
          </button>
          <button className="hover:underline">
            {post.reposts} reposts
          </button>
        </div>
      </div>

      {/* Actions */}
      <div className="px-6 py-2 flex items-center justify-around">
        <button
          onClick={() => setLiked(!liked)}
          className={`flex items-center gap-2 px-4 py-2 rounded hover:bg-gray-100 transition ${
            liked ? 'text-blue-600 font-semibold' : 'text-gray-600'
          }`}
        >
          <span className="text-xl">{liked ? 'Liked' : 'Like'}</span>
          Like
        </button>
        <button
          onClick={() => setShowComments(!showComments)}
          className="flex items-center gap-2 px-4 py-2 rounded hover:bg-gray-100 text-gray-600"
        >
          <span className="text-xl">Comment</span>
          Comment
        </button>
        <button className="flex items-center gap-2 px-4 py-2 rounded hover:bg-gray-100 text-gray-600">
          <span className="text-xl"></span>
          Repost
        </button>
        <button className="flex items-center gap-2 px-4 py-2 rounded hover:bg-gray-100 text-gray-600">
          <span className="text-xl">Share</span>
          Send
        </button>
      </div>

      {/* Comment Input */}
      {showComments && (
        <div className="px-6 pb-4 border-t pt-4">
          <div className="flex gap-3">
            <img
              src="/current-user-avatar.jpg"
              alt="You"
              className="w-10 h-10 rounded-full"
            />
            <input
              type="text"
              className="flex-1 px-4 py-2 border rounded-full hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500"
            />
          </div>
        </div>
      )}
    </div>
  );
}

The header shows the avatar, the name (author.name), the author's headline and the time of publication from formatTimeAgo(timestamp). When post.type === 'job_change', a celebration banner with the new position and company (newPosition, newCompany) appears above the content. The stats line up in a row of likes • comments • reposts, and below them sit the Like, Comment, Repost and Send actions. Clicking Like calls setLiked(!liked), and the button gets the text-blue-600 class. The post.likes counter doesn't change here, the server will count the real number. Repost shares someone else's post further, in your network, and Send passes it on in a private message. The whitespace-pre-wrap class keeps the paragraphs of the post. As for media, the card only displays images for now (post.media.type === 'image'), while video and documents wait their turn.

Creating a post

At the top of the feed, just like on LinkedIn, there is a Start a post field. Clicking it opens the post creation modal:

<button onClick={() => setShowCreatePost(true)}>Start a post</button>
{showCreatePost && (
  <CreatePostModal onClose={() => setShowCreatePost(false)} onSubmit={addPost} />
)}

The showCreatePost state and the addPost function belong to the feed page, and the modal receives them through props. The modal itself looks like this:

'use client';

import { useState } from 'react';

export default function CreatePostModal({ onClose, onSubmit }: {
  onClose: () => void;
  onSubmit: (content: string) => void;
}) {
  const [content, setContent] = useState('');

  return (
    <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
      <div className="bg-white rounded-lg max-w-2xl w-full">
        <div className="flex items-center justify-between p-4 border-b">
          <h2 className="text-xl font-bold">Create a post</h2>
          <button
            onClick={onClose}
            className="text-gray-500 hover:text-gray-700 text-2xl"
          >
            ×
          </button>
        </div>

        <div className="p-4">
          <div className="flex gap-3 mb-4">
            <img
              src="/current-user-avatar.jpg"
              alt="You"
              className="w-12 h-12 rounded-full"
            />
            <div>
              <h3 className="font-bold">Your Name</h3>
              <button className="text-sm text-gray-600 hover:underline">
                Post to anyone
              </button>
            </div>
          </div>

          <textarea
            value={content}
            onChange={(e) => setContent(e.target.value)}
            className="w-full p-3 text-lg resize-none outline-none"
            rows={8}
            autoFocus
          />

          <div className="flex items-center gap-2 text-gray-500 mt-4">
            <button className="p-2 hover:bg-gray-100 rounded text-xl">Photo</button>
            <button className="p-2 hover:bg-gray-100 rounded text-xl">Video</button>
            <button className="p-2 hover:bg-gray-100 rounded text-xl">Document</button>
            <button className="p-2 hover:bg-gray-100 rounded text-xl">Poll</button>
          </div>
        </div>

        <div className="p-4 border-t flex justify-end">
          <button
            onClick={() => {
              onSubmit(content);
              onClose();
            }}
            disabled={!content.trim()}
            className="px-6 py-2 bg-blue-600 text-white rounded-full font-bold hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed"
          >
            Post
          </button>
        </div>
      </div>
    </div>
  );
}

Typing calls setContent in onChange, and the Post button becomes active only when content.trim() isn't empty, so spaces alone won't get through. Clicking Post calls onSubmit(content), and then onClose() closes the modal. The row of buttons under the text field is the place for optional media: a photo, a video or a document.

The connections network

The last page is the list of your connections:

interface Connection {
  id: string;
  name: string;
  avatar: string;
  headline: string;
  mutualConnections: number;
  connectedDate: Date;
}

function ConnectionCard({ connection }: { connection: Connection }) {
  return (
    <div className="bg-white border rounded-lg p-4 hover:shadow-lg transition">
      <div className="flex items-start gap-3">
        <img
          src={connection.avatar}
          alt={connection.name}
          className="w-16 h-16 rounded-full"
        />
        <div className="flex-1">
          <h3 className="font-bold hover:underline cursor-pointer mb-1">
            {connection.name}
          </h3>
          <p className="text-sm text-gray-600 mb-2 line-clamp-2">
            {connection.headline}
          </p>
          <p className="text-xs text-gray-500">
            {connection.mutualConnections} mutual connections
          </p>
        </div>
      </div>

      <div className="flex gap-2 mt-4">
        <button className="flex-1 py-2 border-2 border-blue-600 text-blue-600 rounded-full font-semibold hover:bg-blue-50">
          Message
        </button>
        <button className="px-4 py-2 border border-gray-300 rounded-full hover:bg-gray-50">
          •••
        </button>
      </div>
    </div>
  );
}

export default function ConnectionsPage({ connections }: { connections: Connection[] }) {
  return (
    <div className="max-w-6xl mx-auto p-4">
      <div className="bg-white rounded-lg shadow p-6 mb-4">
        <h1 className="text-2xl font-bold mb-4">
          {connections.length} Connections
        </h1>
        <input
          type="text"
          className="w-full px-4 py-2 border rounded-lg"
        />
      </div>

      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        {connections.map(conn => (
          <ConnectionCard key={conn.id} connection={conn} />
        ))}
      </div>
    </div>
  );
}

From the top, a connection card has the avatar, the name and the headline, and below them the number of mutual connections and the Message button. mutualConnections tells you how many connections you share, which helps you judge whether you know someone. The search field isn't wired up yet, you'll add that with filter on the connections array.

Captain Redbeard's tip: before you add a seventh post type, ask whether you really need a new look. Every value in the union is a separate branch in the card that someone has to maintain.

Remember: a professional feed is the same posts and reactions, only about work, and the connections network shows who you know in common. In the editor below you have a professional feed with post cards, likes and comments, and the tasks will check post types, actions and connections.

Code for this lesson: App.jsx
// Professional Feed Post with Job Change
import { useState } from 'react';

const posts = [
  {
    id: 1,
    type: 'job_change',
    author: {
      name: 'Jack Sparrow',
      avatar: '',
      headline: 'Navigator at Black Pearl'
    },
    content: `Excited to announce that I've joined the Black Pearl crew as First Mate! Looking forward to new adventures on the Seven Seas.`,
    jobChange: {
      previousCompany: 'Flying Dutchman',
      newCompany: 'Black Pearl Crew',
      newPosition: 'First Mate'
    },
    likes: 142,
    comments: 23,
    reposts: 8,
    timestamp: '2h ago'
  },
  {
    id: 2,
    type: 'text',
    author: {
      name: 'Elizabeth Swan',
      avatar: '',
      headline: "Captain at Queen Anne's Revenge"
    },
    content: `Just completed my first successful treasure hunt as captain. Key lessons learned:

1. Trust your crew
2. Always verify the map
3. Never underestimate a storm

#Leadership #TreasureHunting #PirateLife`,
    likes: 89,
    comments: 12,
    reposts: 5,
    timestamp: '5h ago'
  }
];

function FeedPostCard({ post }) {
  const [liked, setLiked] = useState(false);
  const [showComments, setShowComments] = useState(false);

  return (
    <div className="bg-white rounded-lg shadow mb-3">
      {/* Header */}
      <div className="flex items-start gap-3 p-6 pb-3">
        <div className="text-4xl">{post.author.avatar}</div>
        <div className="flex-1">
          <h3 className="font-bold hover:underline cursor-pointer">{post.author.name}</h3>
          <p className="text-sm text-gray-600">{post.author.headline}</p>
          <p className="text-xs text-gray-500 mt-1">{post.timestamp} •</p>
        </div>
        <button className="text-gray-500 hover:bg-gray-100 rounded-full p-2">•••</button>
      </div>

      {/* Content */}
      <div className="px-6 pb-3">
        {/* Job Change Badge */}
        {post.type === 'job_change' && post.jobChange && (
          <div className="mb-3 bg-gradient-to-r from-blue-50 to-green-50 border border-blue-200 rounded-lg p-4">
            <p className="font-semibold mb-2">Started a new position</p>
            <p className="text-sm text-gray-700">
              {post.jobChange.newPosition} at {post.jobChange.newCompany}
            </p>
          </div>
        )}

        <p className="text-gray-900 whitespace-pre-wrap">{post.content}</p>
      </div>

      {/* Stats */}
      <div className="px-6 py-2 border-t border-b flex items-center justify-between text-sm text-gray-600">
        <button className="hover:underline">{post.likes + (liked ? 1 : 0)} likes</button>
        <div className="flex gap-3">
          <button className="hover:underline">{post.comments} comments</button>
          <button className="hover:underline">{post.reposts} reposts</button>
        </div>
      </div>

      {/* Actions */}
      <div className="px-6 py-2 flex items-center justify-around">
        <button
          onClick={() => setLiked(!liked)}
          className={`flex items-center gap-2 px-4 py-2 rounded hover:bg-gray-100 transition ${
            liked ? 'text-blue-600 font-semibold' : 'text-gray-600'
          }`}
        >
          Like
        </button>
        <button
          onClick={() => setShowComments(!showComments)}
          className="flex items-center gap-2 px-4 py-2 rounded hover:bg-gray-100 text-gray-600"
        >
          Comment
        </button>
        <button className="flex items-center gap-2 px-4 py-2 rounded hover:bg-gray-100 text-gray-600">
          Repost
        </button>
        <button className="flex items-center gap-2 px-4 py-2 rounded hover:bg-gray-100 text-gray-600">
          Send
        </button>
      </div>

      {showComments && (
        <div className="px-6 pb-4 border-t pt-4">
          <input
            type="text"
            placeholder="Add a comment..."
            className="w-full px-4 py-2 border rounded-full hover:bg-gray-50"
          />
        </div>
      )}
    </div>
  );
}

export default function App() {
  return (
    <div className="min-h-screen bg-gray-100 p-8">
      <div className="max-w-2xl mx-auto">
        <h1 className="text-3xl font-bold mb-6">Professional Feed</h1>
        {posts.map(post => (
          <FeedPostCard key={post.id} post={post} />
        ))}
      </div>
    </div>
  );
}

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. Which post types does the LinkedIn feed have?

  2. 2. What special thing does FeedPostCard render when type === 'job_change'?

These are 2 of 7 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Horizontal ordering

    Arrange in one line the FeedPostCard action buttons (left to right):

  • Code editor

    Professional feed post

  • Horizontal ordering

    Arrange the steps of creating a professional post:

  • Vertical ordering

    Order the steps of liking a post in FeedPostCard:

  • Vertical ordering

    Order the steps of publishing a post in CreatePostModal:

  • Horizontal ordering

    Arrange in one line the post stats from FeedPostCard: likes • comments • reposts

  • Code editor

    Professional network page

  • Click in order

    Arrange the start of the union type definition: type PostType = 'text' | 'article'

  • Click in order

    Arrange the call that toggles liking a post: setLiked(!liked)

  • Horizontal ordering

    Arrange in one line the post header meta from FeedPostCard: author.name • author.headline • timestamp

  • Vertical ordering

    Order the ConnectionCard elements from top to bottom:

Useful articles