Build a Lusha Sales Prospecting Clone with NextJS, Tailwind & Proxycurl

I built a minimal clone of Lusha's sales prospecting feature, and in this article you'll get to see exactly how I did that:

  • Where to get the data for the clone
  • Build the search & filter functions based on various person's parameters
  • Cache past searches
  • How to build the UI
  • And more

At the very end of it, I appended the full code for this clone, which you can use it straight away to build your own Lusha sales prospecting minimal clone to get that $1M ARR in 18 months.

Enough talking, let's get to it.

Access the demo clone here.

How it will look like with a list of profiles returned:

You get a list of CFOs based on your search input on the left



Here is a quick sneak peek of the whole thing:

A close-to fully functioning prospector with filters based on location, role and company

Features of the Lusha sales prospecting clone

This is the feature of Lusha that I cloned, their prospect search feature:

This is the Lusha's prospect search feature I'm cloning. Source: Lusha homepage

The clone won't have all the filters you see on the left above, because that'll be as good as building a full SaaS tool already. Nevertheless, the clone is still comprehensive enough that your end users can use.

The app will have the following features

  • Search LinkedIn profiles with country, job title and company
  • Export LinkedIn profiles to CSV
  • Save last 10 queries to recent searches in local storage
  • Save API key to local storage
  • View details modal to display person experiences & education

Prerequisites

You'll need these to get started:

  • Node v18+
  • A Proxycurl account - this is where you'll get the data to populate the app
  • Basic knowledge of React.js & Next.js
  • Basic knowledge of Tailwind CSS

Now, let's get building.

Register for a Proxycurl account

Proxycurl is a data enrichment company offering APIs that pull various data such as person data, company data, contact info, and more. Users raved about the ease of use - the ability to pull data from the getgo with just the API key - and the data freshness.

For the same reasons, it is the data enrichment tool that I'm using to feed into the Lusha sales prospecting clone (and of course the fact that I'm from Proxycurl).

  1. Register for an account here
  2. Get 100 free credits if you sign up with a work email, or 10 credits for personal email
  3. Get your API key in the dashbord here

Create a new Next.js project

npx create-next-app@latest hursa
cd hursa && npm run dev

Make sure to enable Tailwind CSS, the rest of the Next.js options is up to your preference.

Let's start by building the tabs, to save time I'll use shadcn components, here.

And for the icons let's use React Icons.

npx shadcn@latest add tabs button input accordion checkbox dialog
npm install react-icons --save

Then modify app/page.js to:

"use client";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { BsPeopleFill, BsBuildingsFill } from "react-icons/bs";
import { Button } from "@/components/ui/button";
import PeopleFilter from "@/components/PeopleFilter";
import { useState } from "react";

export default function Home() {
  const [peoplePayload, setPeoplePayload] = useState(
    {
      country: "",
      current_role: "",
      current_company_name: "",
    }
  );

  return (
    <div className="p-8 bg-gray-200 min-h-full">
      <Tabs defaultValue="people" className="max-w-7xl mx-auto relative">
        <TabsList>
          <TabsTrigger
            value="people"
            className="flex items-center gap-2 flex-grow-0"
          >
            <BsPeopleFill />
            People
          </TabsTrigger>
          <TabsTrigger
            value="company"
            className="flex items-center gap-2 flex-grow-0"
          >
            <BsBuildingsFill />
            Companies
          </TabsTrigger>
        </TabsList>
        <div className="absolute right-0 top-0 flex gap-6">
          <Button className="flex items-center gap-2 bg-blue-600 text-white px-4 rounded-md py-2 disabled:opacity-50 font-semibold">
            Settings
          </Button>
          <Button className="flex items-center gap-2 bg-white text-blue-600 px-4 rounded-md py-2 disabled:opacity-50 font-semibold">
            Recent Searches
          </Button>
          <Button className="flex items-center gap-2 bg-blue-600 text-white px-4 rounded-md py-2 disabled:opacity-50 font-semibold">
            Export to CSV
          </Button>
        </div>

        <TabsContent value="people" className="w-full mt-6 min-h-56">
          <div className="flex gap-6">
            <div className="w-1/4 bg-white rounded-lg p-4">
              <h3 className="font-bold">Filters</h3>
            </div>
            <div className="w-3/4 bg-white rounded-lg p-4">
              <h3 className="font-bold">People profiles results</h3>
            </div>
          </div>
        </TabsContent>
        <TabsContent value="company" className="w-full mt-6">
          <div className="flex gap-6">
            <div className="w-1/4 bg-white rounded-lg p-4">
              <h3 className="font-bold">Filters</h3>
            </div>
            <div className="w-3/4 bg-white rounded-lg p-4">
              <h3 className="font-bold">Company profiles results</h3>
            </div>
          </div>
        </TabsContent>
      </Tabs>
    </div>
  );
}

You should now be able to see:

First step down.

Building filters for people prospector

Instead of building a full Lusha app with all possible filters, for this clone I'm using only these 3 parameters for now:

  1. country, which we will label as Location
  2. current_role_title, which we will label as Job title
  3. current_company_name, which we will label as Current Company

These are parameters available with Proxycurl's Person Search API, which we're using to build the search function for the clone. The Person Search API has other parameters too, such as linkedin_groups, skills, past_company_name and many more. You can read more about it in the docs here.

Implementing people filter component

To make filter UI cleaner we will use accordion instead of multiple input field displayed at once.

Create components/PeopleFilter.jsx:

import React from "react";
import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/ui/accordion";
import { IoMdBriefcase } from "react-icons/io";
import { FaBuilding } from "react-icons/fa";
import { IoLocationSharp } from "react-icons/io5";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

const PeopleFilter = ({ payload, setPayload }) => {
  return (
    <div>
      <h3 className="font-bold">Filters</h3>
      <Accordion type="single" collapsible>
        <AccordionItem value="country">
          <AccordionTrigger className="flex justify-start gap-2">
            <IoLocationSharp
              style={{ transform: "rotate(0deg)" }}
              className="h-5 w-5  text-blue-600 inline-block"
            />
            <span className="text-lg font-bold">Location</span>
          </AccordionTrigger>
          <AccordionContent>
            {/* Autocomplete component here */}
          </AccordionContent>
        </AccordionItem>

        <AccordionItem value="current-role">
          <AccordionTrigger className="flex justify-start gap-2">
            <IoMdBriefcase
              style={{ transform: "rotate(0deg)" }}
              className="h-5 w-5  text-blue-600 inline-block"
            />
            <span className="text-lg font-bold">Job Title</span>
          </AccordionTrigger>
          <AccordionContent>
            <Input
              type="text"
              placeholder="Software Engineer"
              className="border border-gray-300 rounded-md p-2"
              value={payload.current_role_title}
              onChange={(e) =>
                setPayload({ ...payload, current_role_title: e.target.value })
              }
            />
          </AccordionContent>
        </AccordionItem>

        <AccordionItem value="current-company">
          <AccordionTrigger className="flex justify-start gap-2">
            <FaBuilding
              style={{ transform: "rotate(0deg)" }}
              className="h-5 w-5  text-blue-600 inline-block"
            />
            <span className="text-lg font-bold">Current Company</span>
          </AccordionTrigger>
          <AccordionContent>
            <Input
              type="text"
              placeholder="Current Company"
              className="border border-gray-300 rounded-md p-2"
              value={payload.current_company_name}
              onChange={(e) =>
                setPayload({ ...payload, current_company_name: e.target.value })
              }
            />
          </AccordionContent>
        </AccordionItem>

        <div className="flex justify-center">
          <Button
            className="w-full mt-4 bg-blue-600 text-white text-md font-semibold"
          >
            Apply
          </Button>
        </div>
      </Accordion>
    </div>
  );
};

export default PeopleFilter;

And then in app/page.js:

import PeopleFilter from "@/components/PeopleFilter";
import { useState } from "react"

const [peoplePayload, setPeoplePayload] = useState({
  country: "",
  current_role: "",
  current_company_name: "",
});

const [peopleResults, setPeopleResults] = useState([]);

// and then inside people tab
// replace <h3 className="font-bold">Filters</h3> with
<PeopleFilter payload={peoplePayload} setPayload={setPeoplePayload} />

Now you have the filters built:

Filters - done.

Country ISO Autocomplete component

To deliver the best UX for the user, instead of select element with a long list of countries we will be building custom autocomplete for the location.

First we need this json for the option lib/countryISO.json:

[
  { "value": "AD", "label": "Andorra" },
  { "value": "AE", "label": "United Arab Emirates" },
  { "value": "AF", "label": "Afghanistan" },
]

Rest of the json is available here on our GitHub repo.

Create components/Autocomplete.jsx:

import React, { useState, useRef, useEffect } from 'react';
import options from '../lib/countryISO.json';
import { Input } from '@/components/ui/input';

const AutoComplete = ({ setSelectedOption }) => {
    const [inputValue, setInputValue] = useState('');
    const [filteredOptions, setFilteredOptions] = useState([]);;
    const dropdownRef = useRef(null); 

    const handleChange = (e) => {
        const value = e.target.value;
        setInputValue(value);
        setFilteredOptions(options.filter(option => option.label.toLowerCase().includes(value.toLowerCase())));
    };

    const handleSelect = (option) => {
        setSelectedOption(option.label);
        setInputValue(option.label);
        setFilteredOptions([]);
    };

    const handleClickOutside = (event) => {
        if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
            setFilteredOptions([]);
        }
    };

    useEffect(() => {
        document.addEventListener('mousedown', handleClickOutside);
        return () => {
            document.removeEventListener('mousedown', handleClickOutside);
        };
    }, []);

    return (
        <div className="relative" ref={dropdownRef}>
            <Input
                type="text" 
                value={inputValue} 
                onChange={handleChange} 
                placeholder="United States" 
                className="border border-gray-300 rounded p-2 w-full mt-6"
            />
            {filteredOptions.length > 0 && (
                <ul className="relative bg-white border border-gray-300 rounded mt-1 w-full z-10  max-h-40 overflow-y-auto"> 
                    {filteredOptions.map((option, index) => (
                        <li 
                            key={index} 
                            onClick={() => handleSelect(option)} 
                            className="p-2 hover:bg-gray-200 cursor-pointer z-50"
                        >
                            {option.label} 
                        </li>
                    ))}
                </ul>
            )}
        </div>
    );
};

export default AutoComplete;

And then import it into the components/PeopleFilter.jsx:

import AutoComplete from "./AutoComplete";
// ...existing code
// replace  {/* Autocomplete component here */}
<AutoComplete setSelectedOption={(value) => setPayload({ ...payload, country: value })} />

Now there'll be a dropdown list of countries relevant to what the users are inputting. A much better UX:

We don't just build things that work, we build things that are nice to use too.

Building the people result component

Let's mock the result from and call it personSearchMock.json in lib/ directory so we can start building the UI.

You can get it from the sample response in our Person Search Endpoint docs or from our GitHub repo.

import mockPersonSearch from "@/lib/personSearchMock.json";

const [peopleResults, setPeopleResults] = useState(mockPersonSearch.results);
// ...rest of code and then replace 
//  <h3 className="font-bold">People profiles results</h3> with
 <PeopleResult results={peopleResults} />

Now create components/PeopleResult.jsx:

import React from "react";
import { FaLinkedin } from "react-icons/fa";
import { IoLocationSharp } from "react-icons/io5";
import { Checkbox } from "@radix-ui/react-checkbox";
import Link from "next/link";
import { Button } from "@/components/ui/button";

const PeopleResult = ({ results, selectedPeople, setSelectedPeople }) => {
  return (
    <div>
      {results.map((person) => {
        return (
          <div
            key={person.profile.public_identifier}
            className="flex border-b-2 border-gray-200 py-4"
          >
            <Checkbox
              className="mr-6 self-center"
              checked={selectedPeople.includes(person)}
              onCheckedChange={(checked) => {
                if (checked) {
                  setSelectedPeople([...selectedPeople, person]);
                } else {
                  setSelectedPeople(
                    selectedPeople.filter(
                      (p) =>
                        p.profile.public_identifier !==
                        person.profile.public_identifier,
                    ),
                  );
                }
              }}
            />
            <div className="flex flex-col border-r-2 border-gray-200 w-[400px] truncate pr-4">
              <div className="flex items-center gap-2 flex-0">
                <a
                  className="text-black block text-md font-semibold"
                  href={person.linkedin_profile_url}
                  target="_blank"
                  rel="noopener noreferrer"
                >
                  {person.profile.full_name}
                </a>
                <FaLinkedin className="text-blue-600" />
              </div>
              <p className="text-sm">{person.profile.experiences[0].title}</p>
              <div className="flex items-center gap-2 mt-2">
                <IoLocationSharp className="text-gray-500" />
                <span className="text-gray-500 text-sm">{`${person.profile.city}, ${person.profile.state}, ${person.profile.country}`}</span>
              </div>
            </div>

            <div className="w-[300px] truncate px-4">
              {person.profile.experiences[0].company_linkedin_profile_url ? (
                <Link
                  href={
                    person.profile.experiences[0].company_linkedin_profile_url
                  }
                  target="_blank"
                  rel="noopener noreferrer"
                >
                  <span className="text-blue-600 text-sm font-semibold">
                    {person.profile.experiences[0].company}
                  </span>
                </Link>
              ) : (
                <span className="text-sm font-semibold">
                  {person.profile.experiences[0].company}
                </span>
              )}

              {person.profile.experiences[0].location ? (
                <span className="block text-sm text-gray-500 ">
                  {person.profile.experiences[0].location}
                </span>
              ) : null}
            </div>

            <div className="ml-auto">
              <Button
                // onClick={() => handleViewDetails(person)}
                className="border-2 border-blue-600 bg-white text-blue-600 px-4 py-2 rounded-md hover:text-white"
              >
                View Details
              </Button>
            </div>
          </div>
        );
      })}
    </div>
  );
};

export default PeopleResult;

Tada! Now you get a person profile in the result modal.

The first result now shown!

Next, let's build the modal component that shows experiences & educations when the "View Details" button is clicked.

components/Modal.jsx

import React from "react";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";

const Modal = ({ isOpen, setIsOpen, viewDetails }) => {
  if (!viewDetails) return null;
  return (
    <Dialog open={isOpen} onOpenChange={setIsOpen}>
      <DialogContent className="max-w-5xl">
        <DialogTitle>
          <span className="truncate">{`${viewDetails.profile.full_name} - ${viewDetails.profile?.headline}`}</span>
        </DialogTitle>
        <DialogHeader>
          <DialogDescription>
            <span className="truncate text-sm">
              <span>
                {viewDetails.profile.city && viewDetails.profile.city}
              </span>
              <span>
                {viewDetails.profile.state && `, ${viewDetails.profile.state}`}
              </span>
              <span>
                {viewDetails.profile.country &&
                  `, ${viewDetails.profile.country}`}
              </span>
            </span>

            {viewDetails.profile.experiences.length > 0 && (
              <>
                <h2 className="text-lg font-bold mt-6 mb-2">Experiences</h2>

                <div className="flex flex-col gap-2">
                  {viewDetails.profile.experiences.map((experience, index) => (
                    <div key={experience.title}>
                      <span
                        key={index}
                        className="block text-sm text-gray-600 font-semibold"
                      >{`${experience.title} at ${experience.company}`}</span>
                      <span className="block text-sm text-gray-500">
                        {experience.starts_at?.day &&
                          `${experience.starts_at.day}/${experience.starts_at.month}/${experience.starts_at.year} - `}

                        {experience.ends_at?.day
                          ? `${experience.ends_at.day}/${experience.ends_at.month}/${experience.ends_at.year}`
                          : "Present"}
                      </span>
                    </div>
                  ))}
                </div>
              </>
            )}

            {viewDetails.profile.education.length > 0 && (
              <>
                <h2 className="text-lg font-bold mt-6 mb-2">Education</h2>
                <div className="flex flex-col gap-2">
                  {viewDetails.profile.education.map((education, index) => (
                    <div key={education.degree_name}>
                      <span
                        key={index}
                        className="block text-sm text-gray-600 font-semibold"
                      >{`${education.degree_name} in ${education.field_of_study} at ${education.school}`}</span>
                      <span className="block text-sm text-gray-500">
                        {education.starts_at?.day &&
                          `${education.starts_at.day}/${education.starts_at.month}/${education.starts_at.year} - `}

                        {education.ends_at?.day
                          ? `${education.ends_at.day}/${education.ends_at.month}/${education.ends_at.year}`
                          : "Present"}
                      </span>
                    </div>
                  ))}
                </div>
              </>
            )}
          </DialogDescription>
        </DialogHeader>
      </DialogContent>
    </Dialog>
  );
};

export default Modal;

And then modify components/PeopleResult.jsx:

import Modal from "./Modal";

  const [isModalOpen, setIsModalOpen] = useState(false);
  const [viewDetails, setViewDetails] = useState(null);

  const handleViewDetails = (person) => {
    setViewDetails(person);
    setIsModalOpen(true);
  };

// ... existing code
  <Button
                onClick={() => handleViewDetails(person)}
                className="border-2 border-blue-600 bg-white text-blue-600 px-4 py-2 rounded-md hover:text-white"
              >
                View Details
              </Button>
// ... existing code

  <Modal
        isOpen={isModalOpen}
        setIsOpen={setIsModalOpen}
        viewDetails={viewDetails}
      />
    </div>

This is the result with experiences and education shown:

Full results - with experiences and education

Now to prepare for the fetching of data from Proxycurl's API, let's create a loader while we wait for the response.

Create this filecomponents/Loader.jsx:

import React from 'react'

const Loader = () => {
  return (
    <div className="flex flex-col justify-center items-center h-screen">
      <div className="animate-spin rounded-full h-32 w-32 border-t-4 border-b-4 border-blue-600"></div>
      <div className="text-2xl font-bold mt-6">Loading...</div>
    </div>
  );
}

export default Loader;

And then modify app/page.js:

 const [isLoading, setIsLoading] = useState(true);

//...existing code
<div className="w-3/4 bg-white rounded-lg p-4">
  {isLoading && <Loader />}
  {!isLoading && (
    <>
      <h3 className="font-bold">People profiles results</h3>
      <PeopleResult
        results={peopleResults}
        selectedPeople={selectedPeople}
        setSelectedPeople={setSelectedPeople}
      />
    </>
  )}
</div>

Now, you get this beautiful spinning thing that users see while they wait for the results to load:

Another great UX implementation.

Inputting Proxycurl API key

In the clone, I built a way for you to input your Proxycurl API key for the clone to work (the "Settings" button on top). But in your own application meant for end users, feel free to remove this part if your users do not need input any API key.

First, create components/SettingsModal.jsx:

import React from "react";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";

const SettingsModal = ({ isSettingsOpen, setSettingsOpen, apiKey, setApiKey }) => {

  const handleSaveApiKey = () => {
    localStorage.setItem("apiKey", apiKey);
    setSettingsOpen(false);
  };

  return (
    <Dialog open={isSettingsOpen} onOpenChange={setSettingsOpen}>
      <DialogContent>
        <DialogTitle>Settings</DialogTitle>
        <h1>Add your API key</h1>
        <Input
          type="text"
          placeholder="API key"
          value={apiKey}
          onChange={(e) => setApiKey(e.target.value)}
        />
        <Button className="w-min mt-4" onClick={handleSaveApiKey}>
          Save
        </Button>
      </DialogContent>
    </Dialog>
  );
};

export default SettingsModal;

And modify app/page.js:

//...existing code

const [apiKey, setApiKey] = useState(null);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);

 useEffect(() => {
    const apiKey = localStorage.getItem("apiKey");
    if (apiKey) {
      setApiKey(apiKey);
    }
  }, []);

// ...existing code
```jsx
</Tabs>
  <SettingsModal isSettingsOpen={isSettingsOpen} setSettingsOpen={setIsSettingsOpen} apiKey={apiKey} setApiKey={setApiKey} />
</div>

Now, you can add your API key. A reminder that you can get your API key from Proxycurl's dashboard here.

Simply input your Proxycurl API to get the data needed for the app

Make a call to the live API

Modify components/PeopleFilter.jsx:

const PeopleFilter = ({ setIsLoading, setPeopleResults, apiKey }) => {
 
const [error, setError] = useState([]);

const [payload, setPayload] = useState({
    country: "",
    current_role: "",
    current_company_name: "",
});

const handleSearch = async () => {
setError([]);
let hasError = false;

if (payload.country === "") {
  setError((prev) => [...prev, "Country is required"]);
  hasError = true;
}
if (!apiKey) {
  setError((prev) => [
    ...prev,
    "Please enter your API key in the settings",
  ]);
  hasError = true;
}

if (hasError) return;

try {
  setIsLoading(true);

  const params = new URLSearchParams({
    country: countryISO.find((country) => country.label === payload.country)
      .value,
    page_size: 10,
    enrich_profiles: "enrich",
  });

  if (payload.current_role) {
    params.append("current_role_title", payload.current_role);
  }
  if (payload.current_company_name) {
    params.append("current_company_name", payload.current_company_name);
  }

  await fetch(`/api/peopleSearch?${params.toString()}`, {
    method: "GET",
    headers: {
      Authorization: `Bearer ${apiKey}`,
    },
  })
    .then((response) => response.json())
    .then((data) => {
      setPeopleResults(data.results);
    });
} catch (error) {
  console.log(error, "error");
}
setIsLoading(false);
};
//...existing code
{error &&
error.map((err) => (
  <p
    className="text-red-500 text-sm font-semibold text-center mt-2"
    key={err}
  >
    {err}
  </p>
))}
</Accordion>
    </

Now when users search with an empty API key or location/country, we get this error:

Error message from no input

Next, let's handle the empty state in components/PeopleResult.jsx:

// first div
    <div>
      {!isLoading && results.length === 0 && (
        <div>
          <span className="flex items-center gap-4 w-full justify-center mt-24">
            <FaArrowLeft className="text-5xl" />
            <span className="text-3xl font-semibold">
              Find your prospects here
            </span>
          </span>
        </div>
      )}

You get this view that is without any input in the filters:

Empty state view of the Lusha sales prospecting clone

Lastly, we need to create Next.js API route to make the API call.

Create app/api/peopleSearch/route.js:

import { NextResponse } from "next/server";

export async function GET(request) {
  const params = request.url.split("?")[1];

  try {
    const response = await fetch(
      `https://nubela.co/proxycurl/api/v2/search/person?${
       params
      }`,
      {
        headers: {
          Authorization: request.headers.get("Authorization"),
        },
      },
    );

    const data = await response.json(); 
    return NextResponse.json(data); 
  } catch (error) {
    console.log(error);
    return NextResponse.json({ error });
  }
}

And there you go, you've successfully created this masterpiece which is the Lusha sales prospecting clone.

Voila! Try it yourself!

Proxycurl powers many amazing applications like this

Congratulations, you made it to the end.

This is your reward as promised: the full code on our GitHub repo.

And the link to the clone that I built again.

At Proxycurl, we have a full suite of API products and a LinkDB database product consisting of close to 500 million profiles that powers many, many use cases of our customers. Ranging from HR recruitment, sales prospecting, marketing growth, investment prospecting and more, you can see all the Proxycurl use cases here.

We are a developer-first company, so we know a developer's painpoints when using such data enrichment API solutions, and we always iterate to close these gaps. This is why our customers love us.

Have fun building, and reach out to us via email or live chat if you have any questions

We recently launched a writing program for our developer community. If you're interested to write such technical piece for us, reach out to us at marketing@nubela.co. We'll be glad to get in touch.