DEV Community

Joan Roucoux
Joan Roucoux

Posted on • Edited on

Building a Marvel Search Application with Qwik

Introduction

In this tutorial, we will see how to create an application that lets users search for Marvel characters using Qwik, Tailwind CSS and the Marvel API.

Qwik has been available for a while now and is an excellent choice for building web applications that are fast, scalable, and maintainable.

Before we start, you will need an API key to access Marvel’s resources:

  1. Go to https://developer.marvel.com/ and click on the “Get Started” button to create your account.
  2. Next, go to “/account” to retrieve your API public and private keys.
  3. Don't forget to add *.* for localhost support to your authorised domains like below. Marvel developer account page

We can now begin and build our Qwik app 👇

Step 1: Initialise the project

To get started with Qwik locally, you need the following:

  • Node.js v18.17 or higher
  • Your favorite IDE (I use VSCode personally)

Use the Qwik CLI command npm create qwik@latest to generate a blank starter application:

npm create qwik@latest
- Where would you like to create your new project? => ./qwik-marvel-demo-app
- Select a starter => Empty App (Qwik City + Qwik)
- Would you like to install npm dependencies? => Yes
- Initialise a new git repository? => As you want
- Finishing the install. Wanna hear a joke? => As you want (but they are pretty good 😆)
Enter fullscreen mode Exit fullscreen mode

Next, move to the new app folder and run the application to make sure everything is okay:

- cd ./qwik-marvel-demo-app
- npm start
Enter fullscreen mode Exit fullscreen mode

You should see the default app running in your browser:
Qwik default app running

Step 2: Configure Tailwind CSS and daisyUI

We will use Tailwind CSS and daisyUI to quickly build the UI for our components.

Run npm run qwik add tailwind to set up Tailwind:

npm run qwik add tailwind
- Ready to apply the tailwind updates to your app? => Yes
Enter fullscreen mode Exit fullscreen mode

Next, install daisyUI as a Tailwind CSS plugin by running npm i -D daisyui@latest.

Then add to tailwind.config.js the following configuration:

export default {
  //...
  plugins: [require("daisyui")],
  daisyui: {
    themes: [
      {
        dark: {
          ...require("daisyui/src/theming/themes")["dark"],
          primary: "#e62429",
          "primary-content": "#e0e0e0",
          "base-100": "#191919",
          "base-200": "#0c0c0c",
          "base-300": "#000000",
          "base-content": "#e0e0e0",
          "neutral-content": "#e0e0e0",
        },
      },
    ],
  },
};
Enter fullscreen mode Exit fullscreen mode

As you can see, we are only enabling the daisy “dark” theme and overriding some of the base attribute colors.

Step 3: Configure the Marvel API

I like to start my projects by setting up the services, which makes building the UI more efficient later on.

So let’s jump into it, let’s create a fetchMarvelAPI() method in src/services/marvel.ts: this method will call the Marvel API in a generic way, dynamically adding the necessary parameters to keep the code DRY (Don't Repeat Yourself). By doing this, it will be easier for you to create more services later if you want to add new functionalities.

import type { RequestEventBase } from "@builder.io/qwik-city";
import { Md5 } from "ts-md5";
import { buildSearchParams } from "~/utils";

export const getMarvelContext = (requestEvent: RequestEventBase) => {
  const baseURL = requestEvent.env.get("VITE_MARVEL_PUBLIC_BASE_URL");
  const publicApiKey = requestEvent.env.get("VITE_MARVEL_PUBLIC_API_KEY");
  const privateApiKey = requestEvent.env.get("VITE_MARVEL_PRIVATE_API_KEY");
  const ts = Date.now().toString();
  const hash = Md5.hashStr(ts + privateApiKey + publicApiKey);

  return {
    publicApiKey,
    privateApiKey,
    baseURL,
    ts,
    hash,
  };
};

type MarvelContext = ReturnType<typeof getMarvelContext>;

type FetchMarvelAPIArgs = {
  context: MarvelContext;
  path: string;
  query?: Partial<Record<string, string>>;
};

const fetchMarvelAPI = async <T = unknown>({
  context,
  path,
  query,
}: FetchMarvelAPIArgs): Promise<T> => {
  const params = buildSearchParams({
    apikey: context.publicApiKey,
    ts: context.ts,
    hash: context.hash,
    ...query,
  });

  const url = `${context.baseURL}/${path}?${params}`;
  const response = await fetch(url);

  if (!response.ok) {
    // eslint-disable-next-line no-console
    console.error(url);
    throw new Error(`[fetchMarvelAPI] An error occurred: ${response.statusText}`);
  }

  return response.json();
};
Enter fullscreen mode Exit fullscreen mode

Quick note: in order to call the API, we will have to generate a hash using MD5, so make sure to install the ts-md5 package by running npm i ts-md5.

As you can see, we are also importing our API keys as server-side variables that can only be accessed in resources that expose the RequestEvent object.
So make sure to create at the root of the application a .env.local file with both your keys as well as the Marvel public base URL:

VITE_MARVEL_PUBLIC_BASE_URL=https://gateway.marvel.com/v1/public
VITE_MARVEL_PUBLIC_API_KEY=YOUR_PUBLIC_KEY_HERE
VITE_MARVEL_PRIVATE_API_KEY=YOUR_PRIVATE_KEY_HERE
Enter fullscreen mode Exit fullscreen mode

What about this buildSearchParams() import? This method builds a URLSearchParams object from a key/value pairs object, so that query params can be easily added to our service call.

In a new file src/utils/index.ts, add the following method:

export const buildSearchParams = (
  query?: Record<string, unknown>,
): URLSearchParams => {
  const entries = Object.entries(query || {});
  const pairs = entries.flatMap(([key, value]) =>
    value !== undefined && value !== null ? [[key, `${value}`]] : [],
  );
  return new URLSearchParams(pairs);
};
Enter fullscreen mode Exit fullscreen mode

As mentioned earlier, our users will be able to search for Marvel characters. So let's create on top of the existing fetchMarvelAPI() a new method getCharacters() to fetch the Marvel API resource “/characters".

// src/services/marvel.ts

import type { DataWrapper, Character } from "~/types";
import { buildSearchParams, getOffset } from "~/utils";
//...
type GetCharactersArgs = {
  context: MarvelContext;
  page: number;
  limit: number;
  startsWith?: string;
};

export const getCharacters = async ({
  context,
  page,
  limit,
  startsWith,
}: GetCharactersArgs) =>
  await fetchMarvelAPI<DataWrapper<Character>>({
    context,
    path: "/characters",
    query: {
      offset: getOffset(page, limit),
      limit: String(limit),
      nameStartsWith: startsWith,
    },
  });
Enter fullscreen mode Exit fullscreen mode

Again, we are referring to a new getOffset() method from the utils folder in the query object. It's perfect for skipping a specified number of results when fetching the API, which is useful when creating a pagination system (something we will implement later in this tutorial).

So in src/utils/index.ts, add the following method:

export const getOffset = (page: number, limit: number): string =>
  String(limit * (page - 1));
Enter fullscreen mode Exit fullscreen mode

Don't forget to add all the required types in a new src/types/index.ts:

export type Nullable<T> = T | null;

export type DataWrapper<T> = {
  code?: number;
  status?: Nullable<string>;
  copyright?: Nullable<string>;
  attributionTextisplay?: Nullable<string>;
  attributionHTML?: Nullable<string>;
  data?: DataContainer<T>;
  etag?: Nullable<string>;
};

export type DataContainer<T> = {
  offset?: number;
  limit?: number;
  total?: number;
  count?: number;
  results?: T[];
};

export type Url = {
  type?: Nullable<string>;
  url?: Nullable<string>;
};

export type Image = {
  path?: Nullable<string>;
  extension?: Nullable<string>;
};

export type ResourceList = {
  available?: number;
  returned?: number;
  collectionURI?: Nullable<string>;
  items?: ResourceSummary[];
};

export type ResourceSummary = {
  resourceURI?: Nullable<string>;
  name?: Nullable<string>;
  type?: Nullable<string>;
  role?: Nullable<string>;
};

export type Character = {
  id: number;
  name?: Nullable<string>;
  description?: Nullable<string>;
  mediaType?: Nullable<string>;
  modified?: Nullable<string>;
  resourceURI?: Nullable<string>;
  urls?: Url[];
  thumbnail?: Image;
  comics?: ResourceList;
  stories?: ResourceList;
  events?: ResourceList;
  series?: ResourceList;
};
Enter fullscreen mode Exit fullscreen mode

Great, we are now done with this step! We can now focus on building the UI of our application.

Step 4: Build the layout

Even if our application is just one page, we can use the power of layouts to add shared UI using the daisyUI header/footer components. This is helpful if you want to add other pages later and avoid copying/pasting code in each page component.

So let’s customise the existing base layout in src/routes/layout.tsx:

export default component$(() => (
  <div class="flex flex-col min-h-screen bg-base-300">
    <div class="navbar sticky top-0 justify-center bg-primary text-primary-content">
      <p class="text-xl">Marvel Search App</p>
    </div>
    <main class="grow max-w-[81rem] w-full self-center px-8">
      <Slot />
    </main>
    <footer class="footer footer-center sticky bottom-0 p-4">
      <aside>
        <p>Data provided by Marvel. © {new Date().getFullYear()} Marvel</p>
      </aside>
    </footer>
  </div>
));
Enter fullscreen mode Exit fullscreen mode

Because we must attribute Marvel as the source of data whenever we display any results from the Marvel API, the footer is the perfect place to add the required text.

Now if you refresh the application, you should see this:
App running with new layout

Step 5: Build the search component

Let's proceed and create our first component: the search form. It will include a simple text input and a submit button.

Create a new file in src/components/search-form/SearchForm.tsx with the following:

import { component$ } from "@builder.io/qwik";
import type { Nullable } from "~/types";

type Props = {
  searchTerm: Nullable<string>;
};

export const SearchForm = component$((props: Props) => (
  <section class="bg-base-200 mt-8 rounded">
    <div class="flex flex-col items-center gap-4 p-8">
      <h1 class="text-3xl uppercase">Explore</h1>
      <p>Search your favorite Marvel characters!</p>
      <form class="flex flex-col md:flex-row justify-center items-center max-w-lg w-full gap-4">
        <input
          type="text"
          placeholder="Spider-Man, Thor, Avengers..."
          name="search"
          class="input w-full md:w-3/6"
          value={props.searchTerm}
        />
        <button type="submit" class="btn btn-primary w-full md:w-1/6">
          Search
        </button>
      </form>
    </div>
  </section>
));
Enter fullscreen mode Exit fullscreen mode

Next, import your component in src/routes/index.tsx:

//...
import { SearchForm } from "~/components/search-form/SearchForm";

export default component$(() => {
  return (
    <>
      <SearchForm />
    </>
  );
});
Enter fullscreen mode Exit fullscreen mode

Perfect, your application should look like this:
App running with new search form component

We can now move on and start fetching actual data from the Marvel API.

Step 6: Fetch data

To fetch data on the server so it becomes available to use in our page component when the page loads, we are going to use the routeLoader$() capacity from Qwik.

Let’s add a useSearchLoader() in src/routes/index.tsx, which will invoke getCharacters() that we previously defined based on a search term extracted from the URL parameters.

import { routeLoader$, type DocumentHead } from "@builder.io/qwik-city";
import { getMarvelContext, getCharacters } from "~/services/marvel";

export const useSearchLoader = routeLoader$(async (requestEvent) => {
  const searchTerm = requestEvent.url.searchParams.get('search');

  if (!searchTerm) {
    return null;
  }

  const params = {
    context: getMarvelContext(requestEvent),
    limit: 12,
    page: 1,
    startsWith: searchTerm,
  };

  return await getCharacters(params);
});

//...
Enter fullscreen mode Exit fullscreen mode

But why use routeLoader$() over routeAction$() in our case? Because routeLoader$() allows us to rely exclusively on URL search parameters to retrieve our inputs before loading data.

Explanation: when the user clicks the form submit button, the URL is automatically appended with the form data (e.g. ?search=spiderman). The page then reloads, triggers again our loader, extracts the search input from the URL, loads the data on the server, and gives back the control to the component. One major advantage of this approach is that it makes sharing the URL easy.

We can now access our useSearchLoader() data in our component and see if the call was a success by showing the number of results for example:

// src/routes/index.tsx

import { routeLoader$, useLocation, type DocumentHead } from "@builder.io/qwik-city";
//...
export default component$(() => {
  const location = useLocation();
  const searchTerm = location.url.searchParams.get('search');

  const resource = useSearchLoader();

  return (
    <>
      <SearchForm
        searchTerm={searchTerm}
      />
      {resource.value?.data?.results && (
        <p>Total results: {resource.value.data.total}</p>
      )}
    </>
  );
});
Enter fullscreen mode Exit fullscreen mode

We also used useLocation() here to retrieve the input from the RouteLocation object, making sure that the user's search is preserved when the page reloads.

So now if you search for the term "Thor", you should see this:
App running with new Thor search term and results

Great, we now successfully receive data from the Marvel API!

Step 7: Build the results component

Let's build the UI to display our characters. We will first create a grid component that automatically adjusts its columns based on screen size with Tailwind CSS.

Create a new file in src/components/character-grid/CharacterGrid.tsx:

import { component$ } from "@builder.io/qwik";
import type { Character } from "~/types";
import { CharacterCard } from "../character-card/CharacterCard";

type Props = {
  collection: Character[];
  total?: number;
};

export const CharacterGrid = component$((props: Props) => (
  <section class="my-8">
    <div class="flex flex-col md:flex-row items-center gap-2 mb-4">
      <p class="text-xl">
        Total results
      </p>
      <div class="badge badge-lg">{props.total}</div>
    </div>
    <div class="grid grid-cols-[repeat(auto-fill,minmax(12rem,1fr))] justify-items-center gap-4">
      {props.collection.map((character) => (
        <CharacterCard key={character.id} character={character} />
      ))}
    </div>
  </section>
));
Enter fullscreen mode Exit fullscreen mode

Next, add the UI for a single character by creating a new file in src/components/character-card/CharacterCard.tsx with the following content:

import { component$ } from "@builder.io/qwik";
import type { Character } from "~/types";
import { getThumbnail } from "~/utils";

type Props = {
  character: Character;
};

export const CharacterCard = component$((props: Props) => (
  <div class="bg-base-200 w-48 overflow-hidden rounded">
    <div class="hover:scale-105 transition duration-300">
      <img
        alt={props.character.name || ""}
        width={192}
        height={288}
        class="object-cover object-top w-48 h-72"
        src={getThumbnail(props.character.thumbnail)}
      />
    </div>
    <p class="py-4 px-2 font-bold">{props.character.name}</p>
  </div>
));
Enter fullscreen mode Exit fullscreen mode

This component is simple, it displays only a few information like the name of the character or the image. Speaking of the image, let’s add a new getThumbnail() in src/utils/index.ts to build the full path from the Image object:

import type { Image } from "~/types";
//...
export const getThumbnail = (thumbnail: Image | undefined): string => {
  if (!thumbnail) {
    return "";
  }

  return `${thumbnail.path}.${thumbnail.extension}`;
};
Enter fullscreen mode Exit fullscreen mode

Finally, import the CharacterGrid component in src/routes/index.tsx to see the result:

import { CharacterGrid } from "~/components/character-grid/CharacterGrid";
//...

export default component$(() => {
  //...

  return (
    <>
      <SearchForm
        searchTerm={searchTerm}
      />
      {resource.value?.data?.results && (
        <CharacterGrid
          collection={resource.value.data.results}
          total={resource.value.data.total}
        />
      )}
    </>
  );
});
Enter fullscreen mode Exit fullscreen mode

Now if you refresh your browser, you should see the following:
App running with new search results UI

It looks great! We are almost done but we still have one more thing to do before we wrap up this tutorial.

Step 8: Add a load more button

As you may have noticed earlier, we have limited our useSearchLoader() to 12 items. So if you search for “s”, you will have a lot more results but only 12 characters displayed on your screen.

So how to display all of them? With a load more button! It is very useful for breaking up large datasets into smaller, more manageable chunks.

First, let’s bring some changes to our CharacterGrid component to display a “Show more” button at the bottom:

import { component$, type QRL } from "@builder.io/qwik";
//...
type Props = {
  //...
  currentPage: number;
  onMore$?: QRL<() => void>;
  totalPages: number;
};
export const CharacterGrid = component$((props: Props) => (
  <section class="my-8">
    //...
    {props.currentPage < props.totalPages && (
      <div class="flex justify-center mt-8">
        <button
          type="button"
          class="btn btn-primary text-base-content"
          onClick$={props.onMore$}
        >
          Show more
        </button>
      </div>
    )}
  </section>
));
Enter fullscreen mode Exit fullscreen mode

This button will be displayed only if the total number of pages is superior to the current page.

Next, prepare a new loader to fetch our data. Since we cannot reuse useSearchLoader(), which only triggers when the page loads, we will implement a new function using Qwik's server$() capability. This function allows us to define a server-exclusive function, making it ideal for loading our data when the user clicks on the button.

In src/routes/index.tsx:

import { routeLoader$, server$, useLocation, type DocumentHead } from "@builder.io/qwik-city";
//...
export const getMore = server$(async function (page, searchTerm) {
  const params = {
    context: getMarvelContext(this),
    limit: 12,
    page,
    startsWith: searchTerm,
  };

  return await getCharacters(params);
});
Enter fullscreen mode Exit fullscreen mode

Now, to call our newly created getMore() method, we need to modify our component to implement the callback along with two state objects. The first one, currentPage, will hold the current page number which will increment every time the user clicks on the button, and the second one, collection, will store all the results from our requests made to the API.

// src/routes/index.tsx

import { $, component$, useSignal } from "@builder.io/qwik";
import type { Character } from "~/types";
import { getTotalPages } from "~/utils";

//...
export default component$(() => {
  //...

  const currentPage = useSignal<number>(1);
  const collection = useSignal<Character[]>(
    resource.value?.data?.results || []
  );

  const handleMore = $(async () => {
    const newData = await getMore(currentPage.value + 1, searchTerm);
    const newResults = newData.data?.results || [];
    collection.value = [...collection.value, ...newResults];
    currentPage.value += 1;
  });

  return (
    <>
      //...
      {resource.value?.data?.results && (
        <CharacterGrid
          collection={collection.value}
          currentPage={currentPage.value}
          onMore$={handleMore}
          total={resource.value.data.total}
          totalPages={getTotalPages(resource.value.data.total, 12)}
        />
      )}
    </>
  );
});
Enter fullscreen mode Exit fullscreen mode

Finally, to determine the total number of pages, add the following method in src/utils/index.ts:

export const getTotalPages = (
  total: number | undefined,
  limit: number,
): number => (total ? Math.ceil(total / limit) : 1);
Enter fullscreen mode Exit fullscreen mode

Awesome, we are now done with our load more button. You can click the "Show more" button and see the additional search results:
App displaying results

Conclusion

If you are reading this, it means you have reached the end of this tutorial. Thank you for following along, I hope you enjoyed!
Your search page is now complete, feel free to modify anything you want; the goal was to create a playground for you to customise in the future.

For more details, check out my demo repository here.

And if you want to go further, we can also explore a more complete version of the application I created here.

Resources:

Top comments (0)