Tutorials

The Production-Ready TCG Search: Securing API Keys with Next.js Server Components

In our last tutorial, we built a fast, functional TCG search bar with vanilla JavaScript. It was a great start, but we intentionally left a critical loose end: the exposed API key. It’s time to level up.

JustTCG Editor
August 14, 2025
6 minute read
933 views

Share this article

The Production-Ready TCG Search: Securing API Keys with Next.js Server Components

Welcome to the production-ready version. This guide will teach you how to build a secure, performant, and modern search application using Next.js 15. We’ll leverage the power of the App Router and Server Components to ensure your API keys never leave the server, all while creating a snappy user experience.

The Secure Architecture Explained

Instead of calling our API directly from the browser, we’ll use a more robust pattern that is central to the Next.js philosophy. The state of our search will be managed by the URL itself, and our Server Components will react to it.

Here’s the data flow:

  1. A Client Component (<SearchBar />) will handle user input.
  2. When the user searches, the Client Component won’t fetch data. Instead, it will update the page’s URL with search parameters (e.g., ?q=charizard).
  3. This URL change will trigger Next.js to re-render the parent Server Component (Page) on the server.
  4. The Page component will read the new search parameters, securely fetch data from the JustTCG API using our secret API key, and stream the results back to the browser.

This pattern is incredibly powerful because the API key is never exposed, and the data fetching logic is completely hidden from the user.

Prerequisites

  • Node.js v20+ installed.
  • A free JustTCG API Key from your dashboard.
  • Basic familiarity with React.

Project Setup & Environment Variables

First, let’s create our Next.js project.

npx create-next-app@latest nextjs-tcg-search

Follow the prompts, selecting TypeScript and Tailwind CSS if you like.

Next, the most important step for security: create a file named .env.local in the root of your new project. This file is for your secret environment variables and should never be committed to Git.

.env.local

JUSTTCG_API_KEY="your_api_key_here"

By default, Next.js only makes variables prefixed with NEXT_PUBLIC_ available in the browser. Since our key doesn't have that prefix, it's a server-only secret.

Part 1: Managing State with the URL (The Client Component)

Our search bar needs to be interactive, so it must be a Client Component. It will use two key hooks: useSearchParams to read the current URL's query and useRouter to update it.

Create a new file at app/components/SearchBar.tsx:

'use client';  
  
import { useRouter, useSearchParams } from 'next/navigation';  
import { FormEvent, useRef } from 'react';  
  
type Game = {  
  id: number;  
  name: string;  
  slug: string;  
};  
  
export default function SearchBar({ games }: { games: Game[] }) {  
  const router = useRouter();  
  const searchParams = useSearchParams();  
  const inputRef = useRef<HTMLInputElement | null>(null);  
  
  function pushWithParams(params: URLSearchParams) {  
    const query = params.toString();  
    router.push(query ? `/?${query}` : "/");  
  }  
  
  function onSubmit(event: FormEvent<HTMLFormElement>) {  
    event.preventDefault();  
    const formData = new FormData(event.currentTarget);  
  
    const params = new URLSearchParams(searchParams);  
    params.set("q", formData.get("q") as string);  
    const game = formData.get("game") as string;  
    if (game) {  
      params.set("game", game);  
    } else {  
      params.delete("game");  
    }  
  
    pushWithParams(params);  
  }  
  
  function clearSearch() {  
    const params = new URLSearchParams(searchParams);  
    params.delete("q");  
    pushWithParams(params);  
    if (inputRef.current) {  
      inputRef.current.value = "";  
      inputRef.current.focus();  
    }  
  }  
  
  const initialQuery = searchParams.get("q") || "";  
  
  return (  
    <form onSubmit={onSubmit}>  
      <input  
        ref={inputRef}  
        type="text"  
        name="q"  
        defaultValue={initialQuery}  
        placeholder="Enter card name..."  
      />  
      <select name="game" defaultValue={searchParams.get('game') || ''}>  
        <option value="">All Games</option>  
        {games.map((game) => (  
          <option key={game.id} value={game.slug}>  
            {game.name}  
          </option>  
        ))}  
      </select>  
      <button type="submit">Search</button>  
      {initialQuery && <button type="button" onClick={clearSearch}>Clear</button>}  
    </form>  
  );  
}

We start the file with 'use client' because we're using hooks for interactivity. This component is now responsible only for managing the URL, not for fetching data.

Part 2: Fetching Data Securely (The Server Component)

Now for the magic. Our main page will be an async Server Component that does the heavy lifting. Next.js automatically passes a searchParams prop to page components, which contains the current URL's query parameters.

Modify your app/page.tsx file:

import SearchBar from './components/SearchBar';  
import SearchResults from './components/SearchResults';  
  
async function fetchData(url: string, options: RequestInit) {  
  try {  
    const response = await fetch(url, options);  
    if (!response.ok) {  
      console.error(`API call failed with status: ${response.status}`);  
      return [];  
    }  
    const data = await response.json();  
    return data.data;  
  } catch (error) {  
    console.error("Failed to fetch data:", error);  
    return [];  
  }  
}  
  
export default async function Page({  
  searchParams,  
}: {  
  searchParams?: Promise<{ q?: string; game?: string }>;  
}) {  
  const searchQuery = (await searchParams)?.q || "";  
  const gameQuery = (await searchParams)?.game || "";  
  
  const fetchOptions = {  
    headers: { 'x-api-key': process.env.JUSTTCG_API_KEY! },  
  };  
  
  // Set up parallel data fetching  
  const gamesPromise = fetchData(  
    'https://api.justtcg.com/v1/games',  
    { ...fetchOptions, next: { revalidate: 86400 } }  
  );  
  
  let searchResultsPromise = Promise.resolve([]);  
  if (searchQuery) {  
    const params = new URLSearchParams({ q: searchQuery });  
    if (gameQuery) params.append('game', gameQuery);  
      
    searchResultsPromise = fetchData(  
      `https://api.justtcg.com/v1/cards?${params.toString()}`,  
      { ...fetchOptions, cache: 'no-store' }  
    );  
  }  
  
  const [games, searchResults] = await Promise.all([gamesPromise, searchResultsPromise]);  
  
  return (  
    <main>  
      <h1>Secure TCG Search</h1>  
      <SearchBar games={games} />  
      <SearchResults results={searchResults} />  
    </main>  
  );  
}

Notice how clean this is. The page reads the search query, makes a secure fetch call if a query exists, and then passes the results down. The process.env.JUSTTCG_API_KEY is only ever accessed here, on the server. We also use { cache: 'no-store' } to opt out of data caching, which is ideal for a real-time pricing API.

Part 3: Displaying the Results

Finally, create a component to render the results. For better type safety and code clarity, we’ll define the shape of our data with TypeScript interfaces. This is a best practice that makes your components more robust and easier to maintain.

Create app/components/SearchResults.tsx:

interface PriceHistory {  
  p: number;  
  t: number;  
}  
  
interface Variant {  
  id: string;  
  condition: string;  
  printing: string;  
  language: string;  
  price: number;  
  lastUpdated: number;  
  priceChange24hr: number;  
  priceChange7d: number;  
  priceChange30d: number;  
  priceHistory: PriceHistory[];  
}  
  
interface Card {  
  id: string;  
  name: string;  
  game: string;  
  set: string;  
  number: string;  
  rarity: string;  
  tcgplayerId: string;  
  variants: Variant[];  
}  
  
export default function SearchResults({ results }: { results: Card[] }) {  
  if (!results || results.length === 0) {  
    return <p>Enter a search to see results.</p>;  
  }  
  
  // Find the cheapest variant for display  
  const getCheapestPrice = (variants: Variant[]) => {  
    if (!variants || variants.length === 0) return 'N/A';  
    const cheapest = variants.reduce((min, v) => (v.price < min ? v.price : min), variants[0].price);  
    return `$${cheapest.toFixed(2)}`;  
  };  
  
  return (  
    <div className="results-grid">  
      {results.map((card) => (  
        <div key={card.id} className="card">  
          <h4>{card.name}</h4>  
          <p>{card.set}</p>  
          <p>From: {getCheapestPrice(card.variants)}</p>  
        </div>  
      ))}  
    </div>  
  );  
}

Conclusion: A Modern, Secure Pattern

That’s it! You now have a working Next.js application that follows modern security and architecture best practices. Your API key is safe, your data fetching is efficient, and your UI is snappy thanks to Next.js’s smart routing.

This server-first pattern is the foundation for building complex, secure, and performant applications with the App Router. Now you can deploy this with confidence to a platform like Vercel, which makes managing your environment variables a breeze.


Github: https://github.com/JustTCG/nextjs-tcg-search-tutorial

J
Published by

JustTCG Editor

August 14, 2025

Share this article