Upravljanje API rutama
Uvod
Route Handler je funkcija koju Next.js izvršava kada stigne HTTP zahtjev na istoimenu putanju. Pomoću ovih funkcija možemo u našu Next.js aplikaciju dodati backend funkcionalnosti.
Započeti ćemo sa jednostavnim scenarijem - što ako želimo našoj aplikaciji dodati mogućnost da korisnik može označiti koji Pokemoni su mu omiljeni (favoriti). S obzirom da API kojeg koristimo ne podržava tu opciju, taj dio moramo odvojeno implementirati. U produkcijskoj aplikaciji bi morali implementirati i neku vrstu trajne memorije (bazu podataka) ali za sada ćemo jednostavno spremati podatke u memoriju procesa (aplikacije) - što znači da će nakon nekog vremena podaci biti izbrisani.
Ruta /api/favorites
Cilj nam je napraviti dvije API rute - jednu za dohvat (GET) Pokemona koji su nam favoriti, a drugu za slanje (POST) imena Pokemona kojeg želimo dodati u favorite. Logika stvaranja APi ruta je slična kao i definiranje navigacijskih ruta - moramo paziti na putanju unutar app direktorija jer to određuje adresu same rute (na koji URL ćemo slati zahtjeve), te unutar odabrane putanje moramo stvoriti datoteku naziva route.js.
Za početak ćemo napraviti datoteku app/api/favorites/route.js (što znači da možemo slati zahtjeve na /api/favorites) i unutar nje ćemo napisati dvije funkcije - jednu kada aplikacije dobije GET zahtjev na tu adresu, a drugu za obradu POST zahtjeva. Logika funkcija je jednostavna:
- GET → vraća listu omiljenih Pokémona
- POST → prima
{ name }i dodaje ga u popis
let favorites = ["Pikachu"]; // demo “baza” u RAM-u
export async function GET() {
return Response.json({ favorites });
}
export async function POST(request) {
const body = await request.json();
if (!body?.name)
return Response.json({ error: "name missing" }, { status: 400 });
if (!favorites.includes(body.name)) favorites.push(body.name);
return Response.json({ ok: true, favorites });
}Osvrnimo se na par detalja u prethodnom primjeru:
- kao što je već spomenuto, podaci se spremaju u varijablu "favorites" što znači da će biti dostupni u radnoj memoriji za vrijeme trajanja procesa. Zapravo bi ovdje trebalo implementirati neki trajni spremnik kako bi podaci ostali sačuvani.
- Rute pišemo kao eksport funkcije koje se zovu kao i vrste HTTP zahtjeva. U ovom primjeru smo napravili eksport GET i POST funkcija. Lokacija cijele datoteke određuje putanju.
- GET funkcija jednostavno vraća podatak "favorites", uz pomoć ugrađene metode
Response.json(). - Obrada POST zahtjeva je malo složenija, sami zahtjev primamo kao ulazni parametar, te iz njega dohvaćamo podatak
namekojeg onda dodajemo u niz favorita
Klijentska komponenta
Idući korak je implementirati mogućnost dodavanja favorita na korisničkom sučelju. Kako bi to mogli postići, prvo ćemo napraviti novu komponentu koja će imati ugrađenu logiku slanja POST zahtjeva na našu API rutu. Slično kao i "back" tipka koju smo implementirali u prethodnoj lekciji, ovo će biti klijentska komponenta. Pogledajmo primjer implementacije:
"use client";
import { useState, useTransition } from "react";
export default function FavoriteButton({ name }) {
const [saved, setSaved] = useState(false);
const [isPending, startTransition] = useTransition();
async function dodajFavorita() {
startTransition(async () => {
const res = await fetch("/api/favorites", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (res.ok) setSaved(true);
});
}
return (
<button
disabled={saved || isPending}
onClick={dodajFavorita}
className={`px-3 py-1 rounded text-white ${
saved ? "bg-green-600" : "bg-amber-500 hover:bg-amber-600"
}`}
>
{saved ? "Spremljen!" : isPending ? "Spremam..." : "Dodaj u favorite"}
</button>
);
}Ponovno koristimo "useClient" kako bi eksplicitno naveli da se radi o klijentskoj komponenti. U ovom primjeru se koristi i useTransition hook koji nam služi kako bi izbjegli blokiranje sučelja dok čekamo odgovor na POST zahtjev. Komponenta može raditi i bez toga ali na ovaj način postižemo bolje korisničko iskustvo. Korištenje hook-a se zapravo svodi ne to da dio komponente koji je asinkron ili gdje imamo neko učitavanje označimo kao tranziciju. Sami hook nam vraća dva elementa - stanje tranzicije (isPending) i funkciju pomoću koje definiramo samu tranziciju (startTransition). Konkretno, u našem primjeru smo logiku slanja POST zahtjeva označili kao tranziciju.
Dodatno, koristimo interno stanje saved kako bi provjerili jesmo li uspješno spremili podatak u favorite ili ne. Izgled elementa se dinamički mijenja ovisno o stanju spremanja i tranzicije.
Dodavanje u sučelje
Prethodno napravljena klijentska komponenta predstavlja samo jedan mali dio sučelja tj. samo button element. Preostalo nam je još taj element dodati u postojeće stranice na kojima prikazujemo popis Pokemona. Imamo već implementiranu stranicu za prikaz detalja, sve što nam preostaje je učitati komponentu i dodati je na stranicu:
import FavoriteButton from "@/components/FavoriteButton";
export default async function PokemonDetalji({ params }) {
const { name } = await params;
const res = await fetch(`https://pokeapi.co/api/v2/pokemon/${name}`);
// ostatak logike
return (
<main className="flex flex-col items-center p-4">
<!-- postojeci izgled --!>
<FavoriteButton name={data.name} />
</main>
);
}Naglašeni dio su jedine dvije promjene u postojećoj stranici, dijelovi kôda su ponovljeni samo radi lakše orijentacije (stvarni izgled se može malo razlikovati). Sada nam dodavanje Pokemona u favorite radi ispravno - GET rutu možemo testirati i direktno iz preglednika i provjeriti ispravnost dodavanja.
Provjera favorita
Opcija "Dodaj u favorite" ispravno radi ali korisničko iskustvo nije najbolje - svaki put kada otvorimo stranicu za prikaz detalja Pokemona, imati ćemo opciju dodavanja u favorite bez obzira nalazi li se taj Pokemon već u favoritima. U samoj API ruti smo dodali provjeru kako bi izbjegli duple unose, ali svejedno bi bilo korisniku prikazati pravo stanje.
Postoje dva načina na koji možemo riješiti ovaj problem - logiku provjere možemo implementirati na strani klijenta ili na strani poslužitelja. Krenimo od malo jednostavnijeg (jer sve promjene radimo u komponenti) načina - implementacije na strani klijenta.
Provjera na strani klijenta
Sve promjene ćemo implementirati unutar FavoriteButton komponente. Zapravo sve što trebamo napraviti je dodati jedan useEffect hook sa kojim ćemo prilikom prvog iscrtavanja komponente provjeriti je li pripadajući Pokemon već u listi favorita ili nije.
"use client";
import { useState, useEffect useTransition } from "react";
export default function FavoriteButton({ name }) {
const [saved, setSaved] = useState(false);
const [isPending, startTransition] = useTransition();
useEffect(() => {
fetch("/api/favorites")
.then((res) => res.json())
.then((data) => {
if (data.favorites?.includes(name))
setSaved(true);
})
}, [name]);
/// Ostatak komponenteSada prilikom prikaza komponente provjeravamo je li Pokemon već u favoritima. Ipak, korisničko iskustvo i dalje nije potpuno optimizirano - prilikom učitavanja statusa, komponenta će kratko prikazivati "Dodaj u favorite" dok se ne završi GET operacija tj. dok se useEffect logika ne izvrši do kraja i promjeni stanje saved na true.
Ovo možemo izbjeći sa uvođenjem novog stanja koje ćemo koristiti za praćenje procesa provjere. To stanje ćemo iskoristiti za uvjetno renderiranje te ćemo na samom početku prikazati poruku "Provjera" kako bi vizualno prikazali da provjeravamo je li Pokemon već u favoritima ili nije. To možemo napraviti ovako:
"use client";
import { useState, useEffect, useTransition } from "react";
export default function FavoriteButton({ name }) {
const [saved, setSaved] = useState(false);
const [isPending, startTransition] = useTransition();
const [provjera, setProvjera] = useState(true);
useEffect(() => {
fetch("/api/favorites")
.then((res) => res.json())
.then((data) => {
if (data.favorites?.includes(name))
setSaved(true);
})
.finally(() => setProvjera(false)); // provjera gotova
}, [name]);
async function dodajFavorita() {
// bez promjena
}
if (provjera)
return (
<button className="px-3 py-1 rounded bg-gray-300 text-gray-600" disabled>
Provjera...
</button>
);
// prethodni standardni izgled
}Nemojte zaboraviti dodati finally metodu u useEffect kako bi na kraju učitavanja regulirali stanje provjere.
Na ovaj način imamo implementiranu provjeru favorita u potpunosti na klijentskoj strani. Odabir ovog pristupa donosi nekoliko prednosti: implementacija je jednostavnija i imamo manje poslužiteljskog kôda, nije potrebno nikakvo prosljeđivanje propsa, sve nam je u komponenti te imamo razdvojene odgovornosti. Naravno, ovaj pristup ima i neke nedostatke: imamo kratku fazu "provjera..." što utječe na layout shift (minimalno, ali ipak utječe), lista favorita može biti velika pa će GET dugo trajati i usporiti rad aplikacije, a ne možemo znati stvarno stanje dok se GET ne završi.
Kod manjih listi favorita, ovo bi mogao biti i jednostavniji pristup ali u većini slučajeva ćemo listu favorita ipak negdje spremati pa je možda bolje koristiti poslužiteljski pristup za provjeru favorita.
Provjera na poslužitelju
Pogledajmo sada kako napraviti provjeru favorita na poslužiteljskoj strani. Ponovno krećemo od komponente FavoriteButton koju moramo malo prilagoditi.
Komponenta će sada primati dodatno svojstvo (prop) koje će označavati je li Pokemon već spremljen u favoritima ili nije. To svojstvo ćemo i povezati sa postojećim stanjem saved. Više nam nije potrebna logika učitavanja, a možemo izbaciti i useEffect ili ga ostaviti kao failsafe provjeru u slučaju da ne dobijemo svojstvo od poslužitelja. Primjer implementacije izgleda ovako:
"use client";
import { useState, useEffect, useTransition } from "react";
export default function FavoriteButton({ name, initialSaved = false }) {
const [saved, setSaved] = useState(initialSaved);
const [isPending, startTransition] = useTransition();
/* samo ako nismo dobili initialSaved */
useEffect(() => {
if (initialSaved) return;
fetch("/api/favorites")
.then((res) => res.json())
.then((data) => {
if (data.favorites?.includes(name))
setSaved(true);
});
}, [initialSaved, name]);
function dodajFavorita() {
startTransition(async () => {
const res = await fetch("/api/favorites", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name })
});
if (res.ok) setSaved(true);
});
}
return (
<button
disabled={saved || isPending}
onClick={dodajFavorita}
className={`px-3 py-1 rounded text-white transition-colors ${
saved ? "bg-green-600" : "bg-amber-500 hover:bg-amber-600"
}`}
>
{saved ? "Spremljen!" : isPending ? "Spremam..." : "Dodaj u favorite"}
</button>
);
}Dodavanje u favorite i dalje radi ispravno, radi nam i provjera ali se i dalje radi o klijentskoj provjeri (možemo izbrisati useEffect ako ne želimo tu opciju). U gornjem primjeru je ključno uočiti kako smo dodali prop initialSaved kojeg komponenta prima i to iskoristili kao početnu vrijednost stanja saved. Dakle, ova komponenta će od svog roditeljskom elementa dobiti svojstvo koje će označavati je li Pokemon već u favoritima ili nije.
U ovom slučaju, roditeljska komponenta je stranica u kojoj imamo implementiran prikaz detalja pojedinačnog Pokemona. Samim time, idući korak nam je modificirati tu stranicu kako bi dodali logiku provjere stanja favorita i slanja te informacije button komponenti.
Na stranici već imamo jedno učitavanje - slanje GET zahtjeva za dohvat detalja Pokemona. Sada ćemo dodati još jedan GET - na našu API rutu da provjerimo je li taj Pokemon već u favoritima. U situaciji kada imamo višestruke zahtjeve, možemo ih izvršavati uzastopno (pričekati da jedan završi pa onda poslati drugi) ili paralelno (u isto vrijeme poslati oba zahtjeva). Ova druga opcija je poželjnija, pogotovo u ovom slučaju kada šaljemo zahtjeve na različite poslužitelje. Zbog toga ćemo na stranici napraviti malo više promjena, osim dodavanja novog GET zahtjeva, implementirati ćemo i paralelni dohvat podataka:
import Image from "next/image";
import styles from "./page.module.css";
import { notFound } from "next/navigation";
import FavoriteButton from "@/components/FavoriteButton";
export default async function PokemonDetalji({ params }) {
const { name } = await params;
/* paralelni fetch */
const pokFetch = fetch(`https://pokeapi.co/api/v2/pokemon/${name}`);
const favFetch = fetch(`${process.env.NEXT_PUBLIC_SITE_URL}/api/favorites`, {
cache: "no-store"
});
// Cekamo kraj oba zahtjeva
const [pokeRes, favRes] = await Promise.all([pokFetch, favFetch]);
if (!pokeRes.ok) return notFound();
// Parsiranje rezultata
const data = await pokeRes.json();
const { favorites } = await favRes.json();
// Provjera favorita
const spremljen = favorites.includes(data.name);
const spriteUrl = data.sprites.front_default;
if (!spriteUrl) {
return <p>Pokémon slika nije dostupna.</p>;
}
return (
<main className="flex flex-col items-center p-4">
<!-- Ostatak stranice--!>
<FavoriteButton name={data.name} initialSaved={spremljen} />
</main>
);
}Osvrnimo se ponovno na ključne detalje:
- Koristimo
Promise.allmetodu kako bi paralelno poslali dva zahtjeva i pričekali njihovo izvršavanje. Ukoliko niste upoznati sa ovom metodom, pogledajte detaljniji opis u dokumentaciji (opens in a new tab) - Nakon što su oba zahtjeva dovršena, parsiramo rezultate i provjeravamo nalazi li se ime dohvaćenog Pokemona u listi favorita
- Rezultat provjere moramo poslati
FavoriteButtonkomponenti kao svojstvoinitialSavedkako bi se mogla prikazati ispravna opcija
Potrebno je naglasiti još jedan bitni detalj. Naredba za dohvat statusa favorita izgleda ovako:
const favFetch = fetch(`${process.env.NEXT_PUBLIC_SITE_URL}/api/favorites`, {
cache: "no-store"
});Svojstvo cache nam je poznata ih prethodnih lekcija. S obzirom da se status favorita može često mijenjati, nema smisla spremati (cache-irati) rezultat upita pa to eksplicitno navodimo sa "no-store" opcijom. Druga važna stvar je adresa na koju šaljemo zahtjev - bez obzira što se radi o lokalnoj putanji (zahtjev šaljemo na API rutu), poslužiteljske komponente moraju uvijek sadržavati apsolutne putanje. U produkcijskoj aplikaciji tu bi naveli URL našeg poslužitelja, a sada ćemo iskoristiti tzv. environment varijablu koju možemo prilagoditi i mijenjati ovisno na kojem portu nam je pokrenut lokalni poslužitelj.
Na root razini cijele aplikacije napravite novu datoteku imena .env i dodajte URL lokalnog poslužitelja:
NEXT_PUBLIC_SITE_URL='http://localhost:3000'Na kraju imamo ispravno implementiranu provjeru status favorita na poslužiteljskoj strani. Sada bi mogli dodati još nekoliko funkcionlanosti - stranicu sa popisom svih favorita, opciju brisanja iz favorita, sortiranje ili pretragu favorita... ali to možete pokušati i samostalno implementirati za vježbu.
