Stack and scope

A tennis trivia game doesn’t sound like something that needs a React framework. But Next.js supplies a lot of the setup that would otherwise slow you down, and Netlify’s automatic serverless function generation and deployment pipeline let you ship without gluing together a separate build and host workflow. The toolchain here is Next.js, TypeScript, Tailwind CSS, and Netlify.

The game shows a tennis player’s name and asks you to pick their country from an autocomplete input. Five rounds, one running score. The player dataset ships as a local JSON file in the starter repo, so there’s no external API dependency.

Starting point

The starter repo comes with a Next.js app scaffolded via npx create-next-app tennis-trivia, with a few files manually changed to .ts and .tsx. Next.js detects the TypeScript usage on its own. Tailwind CSS is configured using the official Next.js guide. Clone the repo and check out the start branch:

git clone [email protected]:brenelz/tennis-trivia.git
cd tennis-trivia
git checkout start

For local development, copy .env.sample to .env.local. It contains one value, the API path. Because this variable is referenced on the front end, it’s prefixed with NEXT_PUBLIC_:

cp .env.sample .env.local

Install dependencies and start the dev server:

npm install
npm run dev

You’ll get a mostly blank page with just a headline at http://localhost:3000.

UI scaffold

In pages/index.tsx, the Home() function gets markup built from Tailwind utility classes. It includes an autocomplete input, a submit button, and a score counter at the bottom:

export default function Home() {
  return (
    <div className="bg-blue-500">
    <div className="max-w-2xl mx-auto text-center py-16 px-4 sm:py-20 sm:px-6 lg:px-8">
      <h2 className="text-3xl font-extrabold text-white sm:text-4xl">
        <span className="block">Tennis Trivia - Next.js Netlify</span>
      </h2>
      <div>
        <p className="mt-4 text-lg leading-6 text-blue-200">
          What country is the following tennis player from?
        </p>
        <h2 className="text-lg font-extrabold text-white my-5">
          Roger Federer
        </h2>

        <form>
          <input
            list="countries"
            type="text"
            className="p-2 outline-none"
            placeholder="Choose Country"
          />
          <datalist id="countries">
            <option>Switzerland</option>
           </datalist>
           <p>
             <button
               className="mt-8 w-full inline-flex items-center justify-center px-5 py-3 border border-transparent text-base font-medium rounded-md text-blue-600 bg-white hover:bg-blue-50 sm:w-auto"
               type="submit"
             >
               Guess
            </button>
          </p>
        </form>

        <p className="mt-4 text-lg leading-6 text-white">
          <strong>Current score:</strong> 0
        </p>
      </div>
    </div>
    </div>
  );

Typing the player data

Create a lib folder at the project root with a players.ts file. Define a Player type matching the structure of the JSON data in data/tennisPlayers.json:

export type Player = {
  id: number,
  first_name: string,
  last_name: string,
  full_name: string,
  country: string,
  ranking: number,
  movement: string,
  ranking_points: number,
};

Then declare variables for the actual data:

export const playerData: Player[] = require("../data/tennisPlayers.json");
export const top100Players = playerData.slice(0, 100);

const allCountries = playerData.map((player) => player.country).sort();
export const uniqueCountries = [...Array.from(new Set(allCountries))];

playerData is an array of Player objects. The final line builds a unique country list by passing the country values into a JavaScript Set, then spreading the set into a new array. That extra conversion keeps TypeScript’s type inference happy:

Wiring up the dynamic pieces

The page currently holds hardcoded values. The parts that need to be dynamic are the player’s name, the country list, and the score. Modify getServerSideProps to select five random players and pull in uniqueCountries:

import { Player, uniqueCountries, top100Players } from "../lib/players";
...
export async function getServerSideProps() {
  const randomizedPlayers = top100Players.sort((a, b) => 0.5 - Math.random());
  const players = randomizedPlayers.slice(0, 5);

  return {
    props: {
      players,
      countries: uniqueCountries,
    },
  };
}

The returned props object flows into the React component. Define a HomeProps type for the page component, with players as a Player[]:

type HomeProps = {
  players: Player[];
  countries: string[];
};

export default function Home({ players, countries }: HomeProps) {
  const player = players[0];
  ...
} 

Replace the hardcoded “Roger Federer” with {player.full_name}:

The country list in the autocomplete becomes:

<datalist id="countries">
  {countries.map((country, i) => (
    <option key={i}>{country}</option>
  ))}
</datalist>

The score needs a piece of state:

export default function Home({ players, countries }: HomeProps) {
  const [score, setScore] = useState(0);
  ...
}

Instead of guessing all five rounds at once, hold the game state together. When you refresh the page you get a new player, and typing shows all unique countries. Next, add state for the guessed country and a guessCountry handler tied to the form’s submit event. The comparison is simple: match the current player’s country against the submitted guess and bump the score if they’re equal.

Guess feedback

A bare score change is fine, but visible feedback is better. Add a status piece of state and update the guess method:

const [status, setStatus] = useState(null);
...
const guessCountry = () => {
  if (player.country.toLowerCase() === pickedCountry.toLowerCase()) {
    setStatus({ status: "correct", country: player.country });
    setScore(score + 1);
  } else {
    setStatus({ status: "incorrect", country: player.country });
  }
};

Render a message below the player name based on that status:

{status && (
  <div className="mt-4 text-lg leading-6 text-white">
    <p>      
      You are {status.status}. It is {status.country}
    </p>
    <p>
      <button
        autoFocus
        className="outline-none mt-8 w-full inline-flex items-center justify-center px-5 py-3 border border-transparent text-base font-medium rounded-md text-blue-600 bg-white hover:bg-blue-50 sm:w-auto"
      >
        Next Player
      </button>
    </p>
  </div>
)}

The form should only display when there’s no result yet:

{!status && (
  <form>
  ...
  </form>
)}

Moving through rounds

To advance from player to player, store the current step in state. Values run from 0 to 4; when the step reaches 5, the game is over. Add the state variables, then derive the current player from the step index:

const [currentStep, setCurrentStep] = useState(0);
const [playersData, setPlayersData] = useState(players);
const player = playersData[currentStep];

A nextStep function advances the index and is attached to a button in the UI:

const nextStep = () => {
  setPickedCountry("");
  setCurrentStep(currentStep + 1);
  setStatus(null);
};
...
<button
  autoFocus
  onClick={nextStep}
  className="outline-none mt-8 w-full inline-flex items-center justify-center px-5 py-3 border border-transparent text-base font-medium rounded-md text-blue-600 bg-white hover:bg-blue-50 sm:w-auto"
 > 
   Next Player
</button>

On the final round, stepping forward leaves player undefined. Guard against that with a conditional render for a completed state:

{player ? (
  <div>
    <p className="mt-4 text-lg leading-6 text-blue-200">
      What country is the following tennis player from?
    </p>
    ...
    <p className="mt-4 text-lg leading-6 text-white">
      <strong>Current score:</strong> {score}
    </p>
  </div>
) : (
  <div>
    <button
      autoFocus
      className="outline-none mt-8 w-full inline-flex items-center justify-center px-5 py-3 border border-transparent text-base font-medium rounded-md text-indigo-600 bg-white hover:bg-indigo-50 sm:w-auto"
      >
      Play Again
    </button>
  </div>
)}

Playing again

“Play Again” needs to reset all game state and fetch a fresh set of five players from the server. It works by calling the /api/newGame endpoint defined in pages/api/newGame.ts, which reuses the same helper variables as getServerSideProps:

import { NextApiRequest, NextApiResponse } from "next"
import { top100Players } from "../../lib/players";

export default (req: NextApiRequest, res: NextApiResponse) => {
  const randomizedPlayers = top100Players.sort((a, b) => 0.5 - Math.random());
  const top5Players = randomizedPlayers.slice(0, 5);
  res.status(200).json({players: top5Players});
}

The client handler overrides the server-provided playersData with the fresh data from the API call, using the environment variable for the API URL:

const playAgain = async () => {
  setPickedCountry("");
  setPlayersData([]);
  const response = await fetch(
    process.env.NEXT_PUBLIC_API_URL + "/api/newGame"
  );
  const data = await response.json();
  setPlayersData(data.players);
  setCurrentStep(0);
  setScore(0);
};

<button
  autoFocus
  onClick={playAgain}
  className="outline-none mt-8 w-full inline-flex items-center justify-center px-5 py-3 border border-transparent text-base font-medium rounded-md text-indigo-600 bg-white hover:bg-indigo-50 sm:w-auto"
>
  Play Again
</button>

Small UX touches

Set focus on the country input after each step change via a ref and useEffect:

const inputRef = useRef(null);
...
useEffect(() => {
  inputRef?.current?.focus();
}, [currentStep]);

<input
  list="countries"
  type="text"
  value={pickedCountry}
  onChange={(e) => setPickedCountry(e.target.value)}
  ref={inputRef}
  className="p-2 outline-none"
  placeholder="Choose Country"
/>

That makes keyboard-only play workable: just press Enter to submit a guess and move on to the next round.

Deployment

Netlify detects a Next.js app and configures the build automatically. The flow is standard: connect the GitHub repo to Netlify, pick the repo, deploy with defaults.

One manual step: add the NEXT_PUBLIC_API_URL environment variable in Netlify’s dashboard and redeploy for it to take effect.

The final app is deployed at tennis-trivia.netlify.app, and the repo also offers a “Deploy to Netlify” button for a one-click setup.