Techniki pobierania danych w Next.js

Next.js to potężny framework oparty na React, który upraszcza tworzenie aplikacji webowych, oferując od razu renderowanie po stronie serwera (SSR) i generowanie statycznych stron (SSG). Gdy strony rozrastają się do pełnoprawnych aplikacji, sprawne zarządzanie danymi staje się kluczowe. App Router w Next.js daje większą elastyczność i kontrolę nad pobieraniem danych, dzięki czemu można lepiej zadbać o wydajność i wrażenia użytkowników.

Pobieranie danych w App Routerze

App Router w Next.js opiera się na architekturze React Server Components (RSC), dzięki której dane można pobierać zarówno w komponentach serwerowych, jak i klienckich.

Pobieranie danych w komponentach serwerowych daje sporo korzyści, bo mają one bezpośredni dostęp do zasobów po stronie serwera, takich jak bazy danych czy system plików.

Takie podejście nie tylko wykorzystuje moc obliczeniową serwera i jego bliskość źródeł danych, co przyspiesza pobieranie danych i renderowanie, ale też odciąża stronę kliencką.

Załóżmy, że tak wygląda podstawowa architektura naszej aplikacji.

bash
my-next-app/
├── app/
│   ├── layout.js
│   ├── page.js
│   ├── products/
│   │   ├── page.js
│   │   └── [id]/
│   │       └── page.js
│   ├── chat/
│   │   └── [chatId]/
│   │       └── page.js
├── components/
│   ├── ChatComponent.js
├── pages/
│   └── api/
│       ├── revalidate.js
│       └── send-message.js
├── package.json
└── next.config.js

Komponenty serwerowe

Architektura RSC w App Routerze pozwala używać słów kluczowych async i await w komponentach serwerowych. Wystarczy zdefiniować komponent jako funkcję asynchroniczną, żeby móc korzystać ze znanej z JavaScriptu składni await. To podstawa pobierania danych w komponentach serwerowych.

tsx
// app/product/page.tsx

// Define a TypeScript interface for the product data
interface Product {
  id: number;
  title: string;
  price: number;
  description: string;
  category: string;
  image: string;
}

// Next.js server component for the product page
export default async function ProductPage() {
  // Fetch product details from the API
  const res = await fetch('https://fakestoreapi.com/products/3', {
  });

  // Handle any fetch errors
  if (!res.ok) {
    throw new Error('Failed to fetch product details');
  }

  // Use the Product type to type the JSON response
  const product: Product = await res.json();

  return (
    <div>
      <h1>{product.title}</h1>
      <img src={product.image} alt={product.title} width={200} />
      <p>{product.description}</p>
      <p>Price: ${product.price}</p>
      <p>Category: {product.category}</p>
    </div>
  );
}

Komponent jest zdefiniowany jako funkcja async, więc dane można pobrać za pomocą await bezpośrednio w nim. Ta nowoczesna składnia upraszcza operacje asynchroniczne, a kod jest czystszy i czytelniejszy.

tsx
export default async function ProductPage() {
  const res = await fetch('https://fakestoreapi.com/products/3');
  const product: Product = await res.json();
  // Render product details
}

Komponent pobiera dane produktu z API za pomocą fetch. Dzięki SSR treść renderuje się na serwerze, co daje szybsze pierwsze ładowanie i lepsze SEO, bo do klienta trafia gotowy HTML.

🚩 Trzeba tylko pamiętać, że dane mogą zostać wygenerowane statycznie w czasie builda, a wtedy nie zaktualizują się same, dopóki nie przebudujesz aplikacji. Można temu zaradzić strategiami takimi jak Incremental Static Regeneration (ISR), które dbają o świeżość danych.

Cache'owanie

Domyślnie Next.js automatycznie zapisuje wartości zwracane przez fetch w Data Cache na serwerze. Oznacza to, że dane mogą zostać pobrane w czasie builda lub w czasie obsługi żądania, trafić do cache'a i być używane ponownie przy każdym kolejnym żądaniu danych.

tsx
// 'force-cache' is the default, and can be omitted
fetch('https://...', { cache: 'force-cache' })

Odpowiedzi mogą dzięki temu przychodzić szybciej, bo dane są tymczasowo przechowywane i nie trzeba ich ponownie pobierać z serwera źródłowego. Źle skonfigurowane cache'owanie może jednak serwować nieaktualne dane.

Żeby jawnie sterować cache'owaniem, możesz użyć opcji takich jak 'no-store', która je wyłącza, albo 'force-cache', która wymusza zapisanie danych w cache'u:

tsx
/**
* By setting the cache option to 'no-store',
* we ensure that fresh data is fetched
* every time the page is requested.
*/
fetch('https://...', { cache: 'no-store' });

Ustawienie cache na 'no-store' gwarantuje pobranie świeżych danych przy każdym żądaniu, co jest kluczowe w aplikacjach wymagających dokładnych danych w czasie rzeczywistym.

Rewalidacja (ISR)

Na produkcji dane pobrane podczas SSR mogą być statyczne i nie aktualizować się same. Rozwiązaniem jest Incremental Static Regeneration (ISR) w Next.js, który pozwala ponownie generować strony w tle, w miarę jak pojawia się ruch.

Ustawiając właściwość revalidate w obiekcie przekazywanym do funkcji pobierającej dane, określasz, jak często strona ma się odświeżać:

tsx
export default async function ProductPage() {
  const res = await fetch('https://fakestoreapi.com/products/3',
  { next: 
    { 
      revalidate: 60
      //re-fetching the latest data in 60 sec
    } 
  });
  const product: Product = await res.json();
  // Render product details
}

ISR łączy generowanie statyczne z dynamicznymi aktualizacjami, dzięki czemu użytkownicy dostają najnowszą treść bez przebudowywania całej strony.

Kiedy używać ISR, a kiedy no-store

  • ISR sprawdza się przy treściach, które nie zmieniają się co sekundę, ale trzeba je co jakiś czas aktualizować: na blogach, stronach produktów czy w serwisach informacyjnych, gdzie dane są względnie stabilne.
  • no-store lepiej pasuje do treści, które muszą być aktualne w czasie rzeczywistym i nie mogą być nawet trochę nieświeże, takich jak wyniki meczów na żywo, notowania giełdowe czy dashboardy czasu rzeczywistego.

Webhooki i aktualizacja treści na żądanie

Oprócz Incremental Static Regeneration (ISR) i strategii cache'owania no-store mocną alternatywą przy zarządzaniu aktualizacjami treści w aplikacji Next.js są webhooki. Dzięki nim treść jest generowana statycznie i aktualizowana tylko na żądanie, co ogranicza zbędne obciążenie serwera i daje precyzyjną kontrolę nad tym, kiedy treść się odświeża.

Załóżmy, że korzystamy z headless CMS-a, takiego jak Contentful, Sanity czy WordPress, który po opublikowaniu nowej treści wysyła webhook do naszej aplikacji Next.js.

Webhook uruchomi rewalidację konkretnej strony, żeby wyświetlała najnowszą treść.

Utwórz w aplikacji Next.js route API, który obsłuży przychodzące żądanie webhooka. Zwykle znajdzie się on w katalogu pages/api projektu.

typescript
// pages/api/revalidate.ts

import { revalidatePath } from 'next/cache';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';

export async function POST(request: NextRequest): Promise<NextResponse> {
  try {
    const { item } = await request.json();

    const path = item?.get?.tree?.path;

    if (path) {
      /**
       * revalidatePath is used to manually refresh the data and re-render 
       * the route segments under a specific path. This function updates
       * the Data Cache and also invalidates the Full Route Cache.
       */
      revalidatePath(path);

      return NextResponse.json({ revalidated: true, now: Date.now() });
    }

    return NextResponse.json({
      revalidated: false,
      now: Date.now(),
      message: 'Missing path to revalidate',
    });
  } catch (error) {
    return NextResponse.json({ error: 'Error revalidating', details: error }, { status: 500 });
  }
}

revalidatePath daje precyzyjną kontrolę nad aktualizacjami treści w aplikacji Next.js. Rewalidacja wybranych ścieżek na żądanie zwiększa elastyczność statycznych stron i sprawia, że użytkownicy mają dostęp do najnowszej treści z minimalnym opóźnieniem. To kluczowa funkcja w aplikacjach, w których świeżość treści ma duże znaczenie, a przy tym nie trzeba poświęcać wydajności.

Możemy też rewalidować wiele zasobów naraz za pomocą revalidateTag, przekazując do funkcji fetch tablicę tagów.

typescript
//revalidateTag refreshes all pages associated with the given tags.
revalidateTag('product-list');
tsx
// app/products/page.ts

export default async function ProductListPage() {
  const products = await fetch('https://.../products', {
    next: {
      revalidate: {
        tags: ['product-list'], // Tag for the product list
      },
    },
  })
  return (
    <div>
      {/* product list */}
    </div>
  );
}
tsx
// app/categories/[categoryId]/page.ts

export default async function CategoryPage({ params }) {
  const { categoryId } = params;

  const products = await fetch(`https://.../categories/${categoryId}/products`, {
    next: {
      revalidate: {
        tags: ['product-list'],
      },
    },
  })

  return (
    <div>
      {/* render product list based on category */} 
    </div>
  );
}

Dzięki revalidateTag możesz sprawnie aktualizować zarówno listę produktów, jak i strony kategorii w odpowiedzi na zmiany w CMS-ie.

Webhooki dają precyzyjną kontrolę nad aktualizacjami treści: aplikacja dostarcza najświeższe informacje bez narzutu ciągłej rewalidacji po stronie serwera. To podejście godzi wydajność z elastycznością i idealnie pasuje do aplikacji, w których zmiany treści wynikają ze zdarzeń, a nie z upływu czasu. Wybierz webhooki, gdy chcesz ograniczyć obciążenie serwera i aktualizować treść tylko wtedy, gdy naprawdę ma to sens.

Komponenty klienckie

Renderowanie po stronie klienta (CSR) szczególnie dobrze sprawdza się w funkcjach wymagających częstych aktualizacji, takich jak czaty. Pobierając wiadomości po stronie klienta, możemy zbudować responsywny i interaktywny interfejs, w którym użytkownicy widzą wiadomości na bieżąco, w miarę ich wysyłania i odbierania.

tsx
'use client'; // Ensures this component runs on the client side

import React, { useEffect, useState } from 'react';

function ChatComponent({ chatId }) {
  // State to hold the list of chat messages
  const [messages, setMessages] = useState([]);
  // State to manage the current input message
  const [inputMessage, setInputMessage] = useState('');

  // Fetch messages when the component mounts or when chatId changes
  useEffect(() => {
    const fetchMessages = async () => {
      try {
        const response = await fetch(`https://.../${chatId}/messages`);
        if (!response.ok) {
          throw new Error('Failed to fetch chat messages');
        }
        const data = await response.json();
        setMessages(data);
      } catch (err) {
        // setting error
      } finally {
        //setting loading false
      }
    };

    fetchMessages();

    // Set up polling to refresh messages every 5 seconds
    const interval = setInterval(fetchMessages, 5000);
    return () => clearInterval(interval); // Clean up the interval on component unmount
  }, [chatId]);


  /** rest of code with 
   * handling new message input change func,
   * message list and chat template
   */
}

export default ChatComponent;

Zalety pobierania danych po stronie klienta w czacie

  • Interakcja w czasie rzeczywistym: użytkownicy widzą nowe wiadomości na żywo, dzięki czemu czat jest responsywny i angażujący.
  • Natychmiastowa informacja zwrotna: optymistyczny UI zapewnia płynne działanie, pokazując wiadomości od razu.
  • Oszczędne wykorzystanie zasobów: obsługa aktualizacji po stronie klienta odciąża serwer, który może zająć się innymi operacjami, co poprawia ogólną wydajność.

Takie podejście wykorzystuje możliwości klienta, by zapewnić dynamiczny i interaktywny czat, i dobrze pokazuje siłę CSR we współczesnych aplikacjach webowych.

Podsumowanie

W tym wpisie pokrótce omówiłem pobieranie danych w Next.js, zarówno po stronie serwera, jak i klienta. Pokazałem, jak React Server Components (RSC) i Incremental Static Regeneration (ISR) optymalizują pobieranie danych po stronie serwera oraz jak rewalidacja na żądanie i renderowanie po stronie klienta poprawiają interaktywność i responsywność aplikacji webowych w czasie rzeczywistym.

Dziękuję za lekturę i mam nadzieję, że te wskazówki przydadzą Ci się przy budowaniu dynamicznych i wydajnych aplikacji w Next.js!