Building Co-stars: A Movie Data Detective App
Many years ago, I was watching the film Imagine Me and You, and there’s a scene were Celia Imrie’s character looks at a cardboard cut out of Ewan McGregor, and says something along the lines of “I wouldn’t kick him out of bed!”. And I remember wondering whether Celia Imrie and Ewan McGregor actually know each other, or have worked together before?
That gave me the idea for an app where you could put in the names of 2 actors, and it would return all the movies and TV shows that they had starred in together.
And now it is a reality! I built it, and you can go and have a play around at costars.app (And I bet you can’t guess which movie Celia Imrie and Ewan McGregor both starred in!)
If you’re interested in the technical details of how I created it, read on!
What do I want to learn / improve?
I had a really clear idea of what the finished product would be, and so I just needed to decide how I wanted to create it.
Things that I wanted more practice in:
- Node.js
- Vanilla JS
- Vanilla CSS
- Docker
These things seemed to lend themselves nicely to this project anyway, so it felt like a good place to start.
The other key thing was to find an API that would be able to provide the data I needed. I decided to go with TMDB, as when I had a look, the API seemed well documented, and I really prefer their website. It feels a lot cleaner than IMDB which seems to have been overrun with ads and videos. IMDB has always been my go to for movie info in the past, and TMDB does not have quite as much detail as IMDB. But for these purposes, I think it should suffice, and be simpler to use. Despite IMDB clearly having better taste.
Getting started
I found this helpful tutorial on getting started with node and express to get the basics set up, and start to get my head round what server.js
does.
I was using Claude.AI throughout as well, but wanted to make sure I didn’t rely too heavily on it for writing the code, and tried to find online tutorials as my main source of info.
I envisioned the app to be super simple - just a form with 2 input areas, 1 for each actor, and then the results would render below them. I felt it made sense to create this as a Single Page Application (SPA) as that would minimise the need for people to have to navigate around the app. Everything would be right there.
So I set up a basic index.ejs
page, and rendered the form.ejs
partial from there.
views/index.ejs
1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html>
<head>
<title>Co-stars App</title>
</head>
<body>
<h1>Welcome to Co-stars!</h1>
<p>The app where you can discover which TV shows and movies 2 actors have in common.</p>
<%- include('partials/form') %>
</body>
</html>
views/partials/form.ejs
1
2
3
4
5
6
7
8
9
10
<form action="/search-actors" method="GET">
<h2>Find projects in common</h2>
<label for="actor1">Actor 1</label>
<input type="text" id="actor1" name="actor1" required>
<label for="actor1">Actor 2</label>
<input type="text" id="actor2" name="actor2" required>
<button type="submit">Search projects</button>
</form>
I updated my server.js
to console.log the values in the input boxes, to check that was coming through ok.
TMDB API
Now it was time to start testing with real data - very exciting! I applied for TMDB access, and had my bearer token ready to go.
TMDB has really clear interactive documentation to check things are working, and what data you can expect back.
I started off using v4 of the TMDB API, because surely the latest version must be the best?! However, when I tried to use the basic /search/person
endpoint, it didn’t work. It turns out that v3 is the “default” and the better option to use, with much more comprehensive docs, so that was a good lesson to learn. Later isn’t always better when it comes to APIs!
I used curl with my bearer token to check everything was working, and then started writing it into the code.
I created a separate apiClient.js
file that could be called from server.js
for better separation of concerns and easier testing.
I would need to use /search/person
to request data back from the string that the user input, to return the ID
of each actor, and then query again on the /person/${actor_id}/combined_credits
endpoint to get their Movie and TV credits. I’d initially thought I would need to get the move and tv credits separately, but actually TMDB provides a very useful combined_credits
endpoint, that was perfect!
Node fetch options
Looking through some of the tutorials online, I was a bit confused about what I needed to use in order to fetch the data. I could find very little just using plain old fetch()
and kept seeing Undici and Axios being used. I was very keen to use the basics, and not need to import other libraries if it wasn’t necessary.
Claude.AI summarised them like this:
fetch
(built-in): Perfect for learning, handles TMDB API easilyundici
: More features, better performance, but overkill for this projectaxios
: Popular but adds dependencies and abstractions that might hide important conceptsSince you’re focusing on fundamentals, I’d stick with
fetch
. You’ll learn HTTP concepts more directly, and if you need undici’s features later, the transition is straightforward.
I was happy to go with Claude.AI’s recommendation, and this saved me a lot of googling around trying to understand the differences between them.
fetch
takes 2 arguments: url, options.
This means you can pass in the method and headers as options.
1
2
3
4
5
6
7
const options = {
method: 'GET',
headers: {
'accept': 'application/json',
'Authorization': 'Bearer ' + process.env.TMDB_BEARER_TOKEN
},
}
Testing
Once I had things up and running, I decided it was probably time to add some tests and went with Node’s built-in test runner, to once again, keep things simple.
I got a bit confused between how to export
and import
functions, mixing up the older style syntax of require
without named functions, and the import
named functions of ES modules. Once again, Claude.AI was on hand to explain where I was going run, and I got it sorted with minimal frustration!
When it came to testing, it seemed like using undici
was the way forward to make use of its MockAgent
pattern, as explained by Claude.AI.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
describe('getActorData', () => {
let mockAgent;
beforeEach(() => {
// Create and configure mock
});
afterEach(() => {
// Clean up mock
});
it('should call fetch', async () => {
// Set up expected response
// Call your function
// Assert something about the result
});
});
Once this was all set up, I could write the tests to check that the API client was working as expected.
Project matching
Now that I was retrieving data for the actors, it was time to compare the projects coming back, and find ones in common.
I created a separate movieMatcher.js
file, and wrote the tests first to ensure that I was building the right thing. I initially wanted to pass in the actor ids and then make the call to grab their credits. Trying to test this was quite painful, and in the end, I think that showed that it was probably a better idea to just pass in the credits themselves, and handle the API calls elsewhere.
We would now have:
In APIClient.js
getActorData(actor_name)
- find the ID from TMDBgetActorCredits(actor_id)
- pass in the ID and get the credits from TMDB
We could then pass the results of getActorCredits
to findCommonCredits
in movieMatcher.js
.
This felt a lot cleaner.
I was then able to continue to write the code. I wanted to grab all the credits that they had in common, and return the title, the year, and whether it was a movie or TV show.
This was the format:
1
2
3
4
[
{ id: 123, title: "Shared Movie", year: "2023", media_type: "movie" },
{ id: 789, title: "Shared TV Show", year: "2022", media_type: "tv" }
]
Here’s the code:
1
2
3
4
5
6
7
8
9
10
11
12
13
export function findCommonCredits(actor1Credits, actor2Credits) {
const actor2ProjectIds = new Set(actor2Credits.map(credit => credit.id));
return actor1Credits
.filter(credit => actor2ProjectIds.has(credit.id))
.map(credit => ({
id: credit.id,
title: credit.title || credit.name,
year: (credit.release_date || credit.first_air_date)?.split('-')[0],
media_type: credit.media_type
}));
}
Rendering results
Now that we were getting the data we needed, we could look at rendering it on the page.
I wanted to do Client Side rendering, for a smooth user experience, and to learn how things work away from my natural habitat of Rails.
This means: Browser → JavaScript intercepts → JavaScript calls Express server → Express calls external TMDB API → JavaScript handles the JSON response → Browser
Also, because this is all happening in situ on a single page, once we get the results, we need to append them to the HTML on the page.
When googling how to do this, I kept coming across the same pattern:
1
2
3
4
fetch(url)
.then(response => response.json())
.then(json => { console.log(json);
});
Claude.AI was once again able to clear things up for me when I got confused by it. Once again, the .then()
pattern is the older style, and async/await
is the newer way to do things that is easier to read and debug.
1
2
3
const response = await fetch(url);
const json = await response.json();
console.log(json);
We would catch the submit using event.preventDefault();
and then build up our own URL for the API request, that would get passed to apiClient.js
via server.js
.
1
2
3
const url = `/search-actors?actor1=${actor1Name}&actor2=${actor2Name}`;
const response = await fetch(url);
const json = await response.json();
Once we had the results back from TMDB, we needed to append them to the HTML. I wanted to use plain JS for this, rather than any of the available frameworks. So it’s literally a case of finding the relevant HTML tags in your existing document, and then building up a list that you attach it to.
I started with creating a <ul>
: const projectList = document.createElement('ul')
Looping through each project, creating a <li>
, setting the innerHTML to the project info, and then appending each <li>
to the <ul>
.
I made a few tweaks, like clearing previous results, for the next time, and implementing a few safety features through TDD:
- Handling movies with missing data (e.g. year or title)
- Safely display searches with HTML within them (e.g.
<script>
tags) to prevent malicious XSS (Cross Site Scripting) by usingescapeHTML
- Handling very long inputs and clearing unnecessary white space
1
2
3
4
5
6
7
8
9
10
11
12
validProjects.forEach(project => {
let li = document.createElement('li')
const year = project.year ? `, ${project.year}` : "";
const escapeHtml = (text) => {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
li.innerHTML = `<a href="...">${escapeHtml(project.title)}</a> (${project.media_type})${year}`;
projectList.appendChild(li)
})
And there it was! A list of collaborations! Not a very pretty list, but it was a list.
Docker
I wanted to use Docker for this project to learn more about it. I don’t think it was necessary for a project this size, but I think it was helpful to practise using it on a small project, and get an understanding of it.
To get going with Docker I read a few different articles, and then got going. I felt a bit overwhelmed with choosing the right Node package, but Claude.AI was really helpful in guiding me through it and reassured me that node:22
was all that was needed.
I started off with a really basic file:
1
2
3
4
FROM node:22
COPY server.js .
ENTRYPOINT ["node"]
CMD ["server.js"]
I then tried to build and run it, to see what would happen.
1
2
docker build -t costars .
docker run costars
I hit a series of errors, which were very helpful in explaining what was missing, and what was needed.
I ended up with this:
1
2
3
4
5
6
7
8
9
10
FROM node:22
COPY package*.json ./
RUN npm install
COPY .env .
COPY *.js .
COPY public/ ./public/
COPY test/ ./test/
COPY views/ ./views/
ENTRYPOINT ["node"]
CMD ["server.js"]
I then tried to set up volume mounting so that when I worked on styling, it would automatically update.
It didn’t work properly, and I went through a lot of debugging and adding and removing variously named CSS files, until finally, Claude.AI helped me realise that I was still missing a key line in my Dockerfile: WORKDIR /app
1
2
3
4
5
6
7
8
9
10
11
FROM node:22
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY .env .
COPY *.js .
COPY public/ ./public/
COPY test/ ./test/
COPY views/ ./views/
ENTRYPOINT ["node"]
CMD ["server.js"]
This meant that everything was being copied into /
instead of /app
, and therefore it was missing any changes that happened in /app/public/
(which is where my CSS file was).
Adding styles
I asked Claude.AI for a few different themed designs, and liked the retro 80s version the best. It created an HMTL preview doc for me, with CSS as well. I ignored that until the end, and focused on trying to get the basics done by myself.
Once the barebones were there, I got more assistance to add in snazzy things like an animated grid background, and neon style lettering.
Animated grid
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
body::before {
content: '';
position: fixed;
width: 150%;
height: 150%;
left: -25%;
top: -25%;
background-image:
linear-gradient(rgba(0, 255, 255, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 255, 255, 0.1) 1px, transparent 1px);
background-size: 50px 50px;
animation: gridMove 20s linear infinite;
z-index: -2;
}
@keyframes gridMove {
0% { transform: translate(0, 0); }
100% { transform: translate(50px, 50px); }
}
Two perpendicular gradients create the grid lines, and the animation seamlessly scrolls by translating exactly one grid unit.
Subtle horizontal lines mimic old monitor displays:
1
2
3
4
5
6
7
8
9
10
body::after {
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 255, 255, 0.03) 2px,
rgba(0, 255, 255, 0.03) 4px
);
pointer-events: none;
}
Neon Text Glow
Multiple text-shadow layers create the neon lighting effect:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
h1 {
color: var(--neon-cyan);
text-shadow:
0 0 5px var(--neon-cyan),
0 0 10px var(--neon-cyan),
0 0 20px var(--neon-cyan),
0 0 40px var(--neon-cyan);
animation: titlePulse 3s ease-in-out infinite alternate;
}
@keyframes titlePulse {
0% { filter: brightness(1) saturate(1); }
100% { filter: brightness(1.2) saturate(1.3); }
}
Accessibility-Friendly Motion
Respects user preferences for reduced motion:
1
2
3
4
5
@media (prefers-reduced-motion: reduce) {
body::before {
animation: none;
}
}
Mobile friendly design
Having got the main styles working nicely, I turned my attention to making it look better on mobile. I read this interesting article about doing away with breakpoints, and with Claude.AI’s help, used the tool that they recommended called Utopia.
This allows you to use the clamp()
function in CSS to dynamically change the font or padding sizes so that it will resize itself sensibly (based on min and max sizes that you set) no matter what the size of viewport.
It also has a helpful warning if you stray outside of the recommended WCAG guidelines (as I did at one point).
The Utopia settings that I used:
- Min/Max viewport:
320px
to1440px
- Type scales:
1.2
to1.25
- Base font:
14px
to18px
I could then grab the properties created:
1
2
3
4
5
:root {
--step-5: clamp(1.5768rem, 1.0464rem + 2.6521vw, 3.4332rem);
--step-2: clamp(1.1074rem, 0.9216rem + 0.9291vw, 1.7578rem);
--step-0: clamp(0.875rem, 0.8036rem + 0.3571vw, 1.125rem);
}
1
2
3
4
5
6
7
8
9
10
11
12
h1 {
font-size: var(--step-5); /* Instead of fixed 3.5rem */
}
h2 {
font-size: var(--step-2); /* Instead of fixed 1.5rem */
}
p {
font-size: var(--step-0); /* Scales with base size */
}
This works really nicely, and I was able to apply similar principals to the spacing as well.
Once I was happy with how things looked, there was just one thing left to do: ship it!
Railway deployment
I decided to use Railway, as a change from Heroku which I’m very familiar with.
It’s all very straightforward, although there are a few features that I just keep losing in the menus!
A few things I learned:
- Needed to remove
.env
from Docker image to avoid it being a security risk - can add them to Railway’s env variables - The port needed updating in Railway to 8080 rather than 3000.
- I needed to add
EXPOSE 3000
to the Dockerfile - I needed to “Generate Domain” from the “Networking” menu in order to see it live
- Once it was live, I could then add my “Custom Domain”
Conclusion
And that’s that! I did a bit of tweaking on sorting the data by movie, TV show, and Appearances as self. I realised that TV shows are an odd one, as they can go on a really long time, and the 2 actors may never have met. So I added a disclaimer about that. Appearances as self are even stranger - there are a LOT of actors who have gone to the Oscars or appeared on Graham Norton’s couch who will have never met. But I still thought it interesting to include them.
It’s been an interesting project to work on. I think Node.js and Express lend themselves really nicely to this kind of app. Single page, and fast. There were a few things that it took me a while to get my head round, and understand the order of things, but it wasn’t too bad. I also really enjoyed doing the raw JS method of appending the HTML - it’s a bit fiddly but it works! I think I’m going to appreciate the work of things like React in future a bit more. Despite it being quite small in scope, it still took a while to work through some of the edge cases and wanting to make the format as clear as possible (e.g. ensuring it still looked good on a mobile). I think this is a good reminder that things always take longer than you think, and it’s no wonder I’m stalling a bit on my Simplifood project!
There are a few things that I would like to do next:
- Rate limiting to prevent automated abuse
- Solve the “Greg Wise” bug. If you add Greg Wise and another actor he’s definitely worked with (Emma Thompson, Rowan Atkinson) it will come up as “No collaborations found”. I think this might have something to do with him being listed as a “Writer” rather than “Actor” as the first credit on TMDB, but it’s something I need to explore further.
- Add a simpler, grayscale or black and white look - this would be interesting to create a completely different look, but also may be of use to people who find the current design a bit busy/flashy
You can find the code here, and if you want to ask or tell me something, you can find me on Bluesky.
Otherwise, happy duo hunting! Costars