How I Reduced Onboarding Time By 90% With Document Scanning

How I Reduced Onboarding Time By 90% With Document Scanning

Featured on Hashnode

The Problem

CoverCraft is a web app that writes customized cover letters using AI. With this project, I am trying to stretch my skills to make a fully functioning SaaS product with a top-notch user experience. I want users to feel taken care of from start to finish, the full 5-star service.

Previous user flow:

  1. Sign in / Sign up (<1 minute).

  2. Enter CV information in the "CV" tab (5-15 minutes).

  3. Add a job description in the "Jobs" tab (<1 minute).

  4. Get your cover letter (<1 minute).

I was happy with the speed at which existing users can create new cover letters. With their CV added, users could repeat step 3 and add job descriptions to generate more cover letters in less than a minute.

The problem is getting them to that point.

The biggest hurdle to start using CoverCraft is the tedious process of entering your CV information by hand. It can take up to 10 minutes (or more if you have lots of experience and education). The amount of investment required from the user is highly frontloaded. Wouldn't it be nice if all your CV data appeared in the app without filling out forms?

A quick note from Noël: I will not go into as much detail as I usually would because there are many lines of code and insufficient attention span. Consider it more of a bird's-eye view of the architecture. Please let me know if you want me to cover certain parts in more detail! :)

The Idea

I want 3 things:

  1. I want new users to be able to upload their existing CV and pull out all the relevant information in one go. This will get users started extremely quickly, significantly reducing the time to create their first cover letter.

  2. I want users to be able to edit the scanned information step-by-step. I got inspired by how TypeForm lets you fill out forms one field at a time. This guided approach feels much more manageable than slogging through an entire page of forms simultaneously. Bite-sized, pre-filled forms, that's the goal.

  3. I want to make this feature available pre-signup. Once users invest a short amount of time uploading and editing their CV, they are more likely to follow through with a signup. I could have locked this feature behind a paywall to make the premium subscription more attractive, but my focus is to get users and feedback to improve the product.

  4. I want CV data seamlessly synced across auth boundaries. This user flow has to be seamless to feel good. Once the user logs in, everything should be available and ready to go. Users should also have access to this feature post-signup.

Routes & Pages

Currently, CoverCraft is split into two main routes: the homepage "/" and a dashboard "/dashboard". The dashboard is only accessible to signed-in users. Because I want the upload feature to be accessible to users, regardless of auth status, I created a new route, "/upload".

The upload route will use the state to render different components for every step of the user flow. The components will be rendered in the following order:

  1. Upload CV

  2. Personal Info

  3. Education

  4. Experience

  5. Skills

  6. Done

  7. Pricing

Upload CV

I thought about implementing my own scanning functionality, but something tells me this already exists. I found that Eden.ai offers a service for resume parsing. I can extract structured resume information from a PDF file with a simple HTTP request. Perfect!

But first, we need the user's CV file. I used react-dropzone to create a drag'n'drop input field.

export default function UploadCV() {
    const [file, setFile] = useState<File>();
    const [cvData, setCvData] = useAtom(cvDataAtom);
    const [, setCurrentStep] = useAtom(currentStepAtom);

    const onDrop = useCallback((acceptedFiles: File[]) => {
    setFile(acceptedFiles[0]);
    }, []);

    const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop });

    const upload = async (e: MouseEvent) => {
        e.stopPropagation();
        e.preventDefault();
        if (!file) return;
        setLoading(true);

        try {
            const form = new FormData();
            form.append("file", file);

            const res = await fetch("/api/parseResume", {
            method: "POST",
            body: form,
            });

            const data = (await res.json()) as IParseResponse;

            setCvData(data.data);
            setLoading(false);
            setCurrentStep(1);
        } catch (e) {
            console.error(e);
        }
    };

    return (
    // HTML stuff is too long to fit. 
    // You're not missing out on much. 
    // It's simply an <input> element with dropzone props and conditional rendering.
    // If a file is in state -> show the upload button.
    // Upload button calls the upload function. Bob's your uncle.
)}

I add the file to the components state using useState() and then let the user hit "upload".

I could make a direct HTTP request to Eden.AI's API, but that would reveal my API key. Therefore, I must create a NextJS API route to handle this request. Notice how I use FormData to send the file over http. This allows us to omit the Content-Type as the protocol will infer it. I fiddled around too long trying to figure this out, so there you go. :)

import { NextResponse, type NextRequest } from "next/server";
import type { Location, APIResponse } from "./types";
import { type CV } from "~/store";

const URL = "https://api.edenai.run/v2/ocr/resume_parser";
const KEY = process.env.EDENAI_API_KEY;
const APP_URL = process.env.APP_URL;

export interface IFullAPIResponse {
  "eden-ai": {
    extracted_data: APIResponse;
    success: boolean;
  };
}

export interface IRelevantData {
  personalInfos: {
    firstName: string;
    lastName: string;
    email: string;
    phone: string;
    selfSummary: string;
    address: Location;
  };
  education: {
    description: string;
    school: string;
    title: string;
    gpa: string;
    start: string;
    end: string;
    location: string;
  }[];
  experience: {
    company: string;
    description: string;
    title: string;
    start: string;
    end: string;
    location: string;
    industry: string;
  }[];
  skills: string[];
}

export interface IParseResponse {
  success: boolean;
  data: CV;
}

export async function POST(request: NextRequest) {
  if (!KEY) {
    return NextResponse.json({ success: false, message: "No key" });
  }
  if (!APP_URL) {
    return NextResponse.json({ success: false, message: "No app url" });
  }
  if (!request.headers.get("referer")?.includes(APP_URL)) {
    console.log(request.headers.get("referer"));
    console.log(APP_URL);
    return NextResponse.json({ success: false, message: "Invalid referrer" });
  }

  const form = await request.formData();
  form.append("providers", "hireability");

  const res = await fetch(URL, {
    method: "POST",
    headers: {
      authorization: `Bearer ${KEY}`,
    },
    body: form,
  });

  const scan = (await res.json()) as IFullAPIResponse;
  const extracted = scan["eden-ai"]["extracted_data"];
  const relevantData = {
    id: "1",
    personalInfo: {
      firstName: extracted.personal_infos.name.first_name,
      lastName: extracted.personal_infos.name.last_name,
      email: extracted.personal_infos.mails[0],
      location: extracted.personal_infos.address.city,
    },
    education: extracted.education.entries.map((entry) => {
      return {
        degree: `${entry.title || ""} in ${entry.description}`,
        school: entry.establishment,
        title: entry.title,
        description: entry.description,
        achievements: [],
        startDate: entry.start_date,
        endDate: entry.end_date,
      };
    }),
    experience: extracted.work_experience.entries.map((entry) => {
      return {
        title: entry.title,
        company: entry.company,
        startDate: entry.start_date,
        endDate: entry.end_date,
        description: entry.description,
        achievements: [],
      };
    }),
    skills: extracted.skills.map((entry) => {
      return entry.name;
    }),
  } as CV;

  if (res.ok) {
    return NextResponse.json({
      success: true,
      data: relevantData,
    } as IParseResponse);
  }
  return NextResponse.json({
    success: false,
    data: relevantData,
  } as IParseResponse);
}

Eden.AI returns structured data, but I must only extract the relevant information. This would require writing type definitions and mapping the API output to new objects. I would have put all this logic in a separate file anyway, so using an API route makes our code a bit cleaner.

From there, we return the data in our response and handle the rest in the UploadCV component.

Handling State / Persisting Data

I can't simply store the data using useState(). The problem is that all the data is lost when the component re-renders. The data will be gone if a user leaves the "/upload" route to sign in or go to the dashboard.

Another option would be to save it in the Firestore database. The problem is that the user may not be signed in yet and, therefore, has no existing document in the "users" collection. Besides, I want to limit database access to authenticated users for security reasons.

My solution is to persist the scan data in the user's local storage. The browser will remember the scanned data and the flow step across sessions. This way, users can leave and return to complete the flow without losing their work. Once the user signs in, I can check the localStorage for scan data and copy it into my database.

I am using Jotai for state management already, and luckily there is a way to save state Jotai state to localStorage using atomWithStorage. It works just like you would expect. Create an atom using atomWithStorage and call useAtom to get the getter and setter functions. Jotai will keep track of the current upload step and the scanned data.

Forms forms forms

Steps 2-5 allow the user to edit the extracted data. I mainly used the same forms as on the dashboard. There's only a little to say here besides that I use react-hook-form to manage the forms.

Clicking the "Next" button increments the currentStep state, which causes the parent component to render the next page.

Crossing the auth boundary

Once the user reaches step 6, they complete the forms and populate the local storage state with all the correct information. Here, there are several paths a user could follow based on their login status.

  1. If they're logged in, I can simply navigate them to the dashboard.

  2. If they're not logged in, I can prompt them to create an account by showing the pricing page.

The trick here is that the data remains in localStorage until cleared. The "/dashboard", "/login", and "/upload" routes render entirely different components, but navigating from one to another won't make the user lose their work. So, how do we save the data once the user logs in?

On the layout of the dashboard page, I create a useEffect() that will check if the localStorage contains CV data and if all the steps have been completed. If so, I update my Firestore database and set the localStorage data to null. I then open a model to notify users that their data is securely stored and ready to go.

const [cvData, setCvData] = useAtom(cvDataAtom);

useEffect(() => {
    if (cvData) {
        await updateCV("1", cvData, true); // <- Store data in firebase
        setCvData(null);
        setCurrentStep(0);

        setModal({
            title: "Welcome to CoverCraft!",
            elementName: "Info",
            message:
                "Your CV is ready to go! You can view it in the CV tab. To start writing cover letters, just add a job in the Jobs tab.",
        });
}, [])

Done!

With all that, users can upload their CV from anywhere in the app, logged in or out. Entering information on CoverCraft has never been less tedious!

Conclusion

This differed from my usual format, focusing more on documenting my process than teaching a concept. I hope using an example to share my thought process helps you make decisions for your apps!

Thank you for reading ❤

Did you find this article valuable?

Support Noël's Blog by becoming a sponsor. Any amount is appreciated!