|
Grant Skaggs
Grant SkaggsFebruary 13, 2025
When we set out to make the new and improved Phind model, we knew it was going to be a big step forward. We also knew we needed an improved user experience to match.
The previous version of phind.com was uh... functional. Users mostly knew how to navigate the site, and it felt snappy. Featuring a Bootstrap CSS package we bought for $50, it served us well for almost two years.
Phind's old look was modern but indistinctive
Phind's old look was modern but indistinctive
But it had its problems. The site had slow page load times, was buggy, and didn't quite have the look we wanted. It also wasn't set up to stream the rich Generative UI we'd worked so hard to enable with the new Phind model.
This blog post is about how we overcame those problems by scrapping the old frontend code base and building the site you see today. Our team managed to:
  • Give the entire site a face lift.
  • Reduce P75 page load times (Largest Contentful Paint) by almost 25%.
  • Enable the richest Generative UI our new model could create.

Looksmaxing 101: Get Help from a Pro

That Bootstrap theme was only getting us so far. It was time to call in the professionals, and we did so by contacting a number of design agencies and freelancers.
After some trial and error, we found Putri Karunia: a designer, software engineer, and the co-founder of Typedream. We loved her taste and her startup experience proved invaluable in understanding Phind's needs.
We brought Putri on as a contractor and gave her a difficult task: We had a complicated product that needed to feel clean, elegant, soft—and above all, understandable.
It needed to feel human, even if the user was talking to a robot.
What she came up with knocked our socks off:
Phind had a glow up
Phind had a glow up
Home run.

Performance Improvements

Now that we knew what the website was going to look like, it was time to make it work.
And to make it work fast.

Layout and Rendering Issues

When Edwin Catmull (co-founder of Pixar and Turing Award winner) was at the New York Institute of Technology, he and his team had a motto:
No Jaggies!
This was back in the 70s, when computer graphics was in its infancy, and that motto referred to the team's commitment to avoid the infamous stair-step pattern of pixels at the boundaries of lines and curves.
For the pioneers of computer graphics and hardware, this motto became a battle cry and guiding principle for decades to come.
On a less dramatic note, my personal battle cry when implementing the new Phind frontend was:
No Flashies!
This commitment was to avoid any flashes which the user might see when refreshing the page on Phind.
On page refresh, the color theme should not flash from light to dark. The sidebar should not pop open on page load. The logo should not flash in the top-left corner. The AI model selected in the search box options should not change as it's read from local storage.
And of course, there could be only the most necessary Content Layout Shift on page reload.
To accomplish this, we identified all the user-specific information needed for the first render of our page routes. These were things like the selected theme (light, dark, system), the currently selected model, and if the sidebar was open. We made cookies for these values and read them in the server, so they could be available for the pages' first render.
Some of these values are stored as a ground truth in our database, so upon page load, we make sure to query these values and update the cookie silently in the background if necessary.

UI Streaming

There are some data which are needed to populate our pages which are too big to be put in a cookie. These are things like the user's search history and the contents of a particular saved search.
In these cases, we relied on a feature of React called UI Streaming. This term basically refers to increasingly fancy ways to show loading indicators when you are waiting for a React promise (e.g. fetching User data) to resolve.
The most manual implementation of this is literally just setting a React useState hook to indicate that something is loading and then conditionally rendering a loading indicator if this is the case. If the data is needed for the pages first render, then you'll need to set that state to loading by default in order to avoid the frontend flashing ("No flashies!").
1
2
3
4
5
6
7
const [isLoading, setIsLoading] = useState(true);

if (isLoading) {
  return <LoadingIndicator />;
}

return <DataHeavyComponent />;
Using the React Suspense boundary is slightly more sophisticated.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { Suspense } from 'react';

function Page() {
  return (
    <div>
      <Suspense fallback={<LoadingSkeleton />}>
        <DataHeavyComponent />
      </Suspense>
      <OtherContent />
    </div>
  );
}

async function DataHeavyComponent() {
  const data = await fetchData();
  return <div>{/* Render data */}</div>;
}
And if you want to get even fancier with it, you can use the loading.tsx pattern available in the Next.js /app router model. Read more about this approach here.
1
2
3
4
5
6
7
8
9
10
// app/page/loading.tsx
export default function Loading() {
  return <div>Loading...</div>;
}

// app/page/page.tsx
export default async function Page() {
  const data = await fetchData();
  return <YourComponent data={data} />;
}
We used each of these three approaches at various points in our code base.

Lazy Loading

In addition to UI Streaming for user data and routes, we also went out of our way to lazily load components which were not needed for the initial page load.
1
2
3
4
5
6
7
8
9
10
11
import { lazy, Suspense } from 'react';

const HeavyComponent = lazy(() => import('./HeavyComponent'));

function MyComponent() {
  return (
    <Suspense fallback={<LoadingSkeleton />}>
      <HeavyComponent />
    </Suspense>
  );
}
This allows us to reduce the initial page load size by deferring the loading of non-critical components to when they are needed.

Search Experience Optimization

Search at Phind needs to be fast. It also needs to feel fast.
One critical piece of the UX in this line is going from the home screen to the answer screen when making a web search. In the old website, we made the home page and the answer page share a route: /search so that way they shared the same JS bundle and React state. In other words, when you went to phind.com it redirected you to /search and loaded in everything you need for your query to begin instantly when you hit "Enter."
Unfortunately, this wasn't quite what we were looking for in the new site. After all, https://phind.com's home page should just be https://phind.com right?
But if you hit the "Enter" button on the early iterations of the new frontend, the answer page at /search/<search-id> would be unreasonably slow to load. The user was stuck wondering if their search had even submitted.
The solution was to add a call to Next.js's prefetch function as in:
1
2
3
useEffect(() => {
  prefetch('/search/[...id]');
}, []);
Which is a prefetch of what is known in Next.js as a Dynamic Route. This prefetch tells Next.js to download the JavaScript bundle for the /search/[...id] route in the background while the user is still on the home page. When the user hits "Enter" to submit their search, the bundle is already available, making the transition to the answer page much faster. The [...id] part in the route is a catch-all parameter that matches any number of URL segments after /search/.
This sped up the load of the answer page, but there was still a problem. The answer wasn't streaming into the frontend as fast as it used to. This was because the answer's React state was created and mutated only in the /search/[...id] route... which was because the request to the backend to generate the results wasn't happening until after the page was loaded.
The solution: To put the answer state in a single React provider which wraps the entire application. This allowed us to kick of the answer stream, and keep track of the result even while we were loading in the rest of the answer page JS.
That design decision led to several frontend bugs and increased our code complexity non-trivially. After all, the beauty of associating the React state with a given page is that the router handles all the pesky complexity of clearing that state when you change pages. But hey, you gotta do what you gotta do.

Migrating to the /app Router

When I attended the Next.js Conf in October last year, I learned that the Next.js /app router paradigm had been met with some controversy. Introduced in October 2022 with Next 13, the new paradigm was supposed to replace the /pages router structure and promised:
  • Server-centric routing
  • Improved code locality
  • Increased performance
However, ultimately some devs I met questioned the increased performance, pointing out it depends on your app's specific workload. They also pointed out how the migration can suck up dev time.
We decided to use the new router anyways to see if it would work well for us. We also wanted our version of Next.js to be up to date so that we could use new Next.js features when they come out, like Partial Pre-Rendering.
This ended up being a good move. We only had one major hiccup: When we shipped the new frontend, we discovered that we couldn't accept payments through Stripe because our webhook was failing.
At Phind we like money and appreciate it when our users try to pay us, so naturally this caused some concern amongst the team.
That webhook was an API route which we'd left in the /pages directory since, at least in the development environment, it appeared we could get away with some endpoints still being in the old paradigm. The exact error had to do with an import "server-only"; in one of the route's dependencies (which we were using to keep sensitive information on the server). For some reason, even though the /pages API route existed on the server, the app thought it was a client component.
Migrating the Stripe routes to the /app router solved our issue and we were back to receiving payments.

Performance Metrics

We used the Vercel Speed Insights tool to monitor the performance of the new frontend.
Without revealing specific numbers, we can say that the new frontend reduced the P75 for the:
  • LCP (Largest Contentful Paint) by ~25%.
  • FCP (First Contentful Paint) by ~20%.
  • FID (First Input Delay) by ~25%.
  • TTFB (Time to First Byte) by ~13%.
Additionally, we reduced our Cumulative Layout Shift score from a 0.17 to a 0.01 (where lower is better).

Streaming the Generative UI

What got the team most excited about the new Phind model was that it could create a rich set of UI elements on demand: an approach called Generative UI or GenUI.
Once we got the model predictably creating this rich content, the challenge became streaming it from our backend servers and from there to our frontend.
Our previous answer pipeline had no notion of anything more complicated than markdown and metadata. The markdown was sent in raw text chunks to the frontend. The metadata was sent in raw text chunks enclosed in XML tags, like:
1
2
3
4
5
6
7
<!-- Fictional tag names used for demonstration -->
<WEB_CITATION_META_DATA>
  Information about web citations and sources used in the answer
</WEB_CITATION_META_DATA>
<ANSWER_FORMAT_META_DATA>
  Information about the formatting and structure of the answer
</ANSWER_FORMAT_META_DATA>
Metadata tags could not be nested and were each sent as one big chunk. This wasn't going to work. Our GenUI was complicated and could only be represented well as a tree datastructure, where the nodes were complex components like runnable tables, generated images / videos, and comparison cards. Several of these components could even contain children. For instance, a model's Warning card can contain headers, icons, and list items.
Example of two GenUI components, each with nested content
Example of two GenUI components, each with nested content
For the answer to be seamlessly streamed in, we took a tried and true approach: JSON.
Each chunk of the stream could be a JSON object which included
  • node_id: corresponding to an element in the GenUI
  • type: the kind of mutation to be applied to the element. This could be adding new attributes or adding text. It can also denote that a node is done streaming.
  • payload: the text associated with the mutation.
As these chunks are streamed to the frontend, we construct a tree representation of the data as a TypeScript data structure. We render the tree on the answer page, taking care to memoize components to avoid re-renders for complete components. We also make sure to have special cases in our components to ensure they look aesthetic even in intermediate states as the stream progresses.

Shoutout to the Next.js Team

Before we wrap up this post, we'd like to thank the Next.js team for putting out such a great product. We used Next.js for the original Phind frontend and we're glad to see it's only getting better over the years.
We'd like to give a special shoutout as well to the Vercel team for their advice last year as we made the new Phind frontend as fast as possible.

Found this Post Interesting? Come Work with Us

If you want to work with a small team changing the way people search the web, we're hiring:
  • ML Engineers
  • Full-Stack Engineers
  • Frontend Engineers
Take a look at our job postings and apply today!