Building a Marvel Search Application with Qwik
Joan Roucoux
Posted on July 22, 2024
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:
- Go to https://developer.marvel.com/ and click on the “Get Started” button to create your account.
- Next, go to “/account” to retrieve your API public and private keys.
- Don't forget to add
*.*
for localhost support to your authorised domains like below.
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 😆)
Next, move to the new app folder and run the application to make sure everything is okay:
- cd ./qwik-marvel-demo-app
- npm start
You should see the default app running in your browser:
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
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",
},
},
],
},
};
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();
};
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
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);
};
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,
},
});
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));
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;
};
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>
));
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:
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>
));
Next, import your component in src/routes/index.tsx
:
//...
import { SearchForm } from "~/components/search-form/SearchForm";
export default component$(() => {
return (
<>
<SearchForm />
</>
);
});
Perfect, your application should look like this:
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);
});
//...
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>
)}
</>
);
});
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:
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>
));
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>
));
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}`;
};
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}
/>
)}
</>
);
});
Now if you refresh your browser, you should see the following:
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>
));
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);
});
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)}
/>
)}
</>
);
});
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);
Awesome, we are now done with our load more button. You can click the "Show more" button and see the additional search 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:
Posted on July 22, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.