Artykuł opublikowany po angielsku.
Przenoszenie API Express do obsługi tras w Next.js App Router
Dowiedz się, jak przekształcić ścieżki Express, middleware oraz wzorce danych na Next.js App Router z użyciem komponentów serwerowych oraz jakie są kwestie związane z wdrażaniem.
Kiedy migracja na Next.js faktycznie ma sens (a kiedy nie)
App Router w Next.js staje się rozsądnym wyborem, gdy twój projekt spełnia co najmniej dwa z następujących warunków:
- Szybkość początkowego ładowania lub widoczność w wyszukiwarkach jest kluczowa, a obecnie używasz aplikacji jednostronicowej, która pobiera dane z backendu Express po załadowaniu strony.
- Twój proces wdrażania jest już skierowany na Vercel lub podobne platformy albo jesteś gotów przyjąć taką infrastrukturę — funkcje App Routera oparte na chmurze i bezserwerowym modelu zakładają takie środowisko hostowania.
- Twoja aplikacja Express służy głównie do wyświetlania stron i obsługi standardowych operacji CRUD, zamiast zarządzać trwałymi połączeniami, zadaniami w tle lub procesami wymagającymi dużych obliczeń.
Migracja nie jest zalecana w następujących sytuacjach:
- Zależysz w dużej mierze od ugruntowanego ekosystemu middleware Express — specjalistycznych dostawców autoryzacji, bibliotek do ograniczania liczby żądań czy integracji z narzędziami do monitorowania — które wymagałyby całkowitej ponownej implementacji przy znikomych korzyściach.
Zalecane podejście: zachowaj swoją API Express jako autorytatywne źródło danych i reguł biznesowych, a następnie wykorzystaj Next.js jako warstwę renderowania oraz backendu dla frontendu. Najpierw przenieś interfejs użytkownika oraz trasy służące do odczytu danych, pozostawiając bez zmian usługi wewnętrzne wymagające intensywnego zapisu. Ten przewodnik opiera się na tej strategii migracji.
Mapowanie tras Express do obsługujących ich funkcji w Next.js
Funkcje obsługujące trasy znajdujące się w pliku app/api/**/route.ts stanowią najbardziej bezpośredni zamiennik definicji tras w Express. Koncepcyjna różnica polega na tym, że zamiast obiektów req i res otrzymujesz obiekt Request i zwracasz obiekt Response (lub używasz NextResponse dla większej wygody), a każda metoda HTTP staje się oddzielną funkcją eksportowaną, zamiast wywołaniem metody router.get().
Rozważmy standardową trasę Express, która pobiera i tworzy zamówienia:
// express: routes/orders.ts
import ⟨0⟩ from "express";
import ⟨1⟩ from "../db";
import ⟨2⟩ from "../middleware/auth";
const router = Router(); router.get("/api/orders", requireAuth, async (req, res) => {
const userId = req.user.id;
const orders = await db.order.findMany({ where: ⟨3⟩ });
res.json(⟪4⟩);
}); router.post("/api/orders", requireAuth, async (req, res) => {
const ⟨5⟩ = req.body;
if (!items?.length) {
return res.status(400).json(⟪6⟩);
}
const order = await db.order.create({
data: ⟨7⟩,
});
res.status(201).json(⟪8⟩);
}); export default router;
Odpowiednia implementacja obsługi trasy:
// app/api/orders/route.ts
import ⟨0⟩ from "next/server";
import ⟨1⟩ from "@/lib/db";
import ⟨2⟩ from "@/lib/auth";
export async function GET(req: NextRequest) {
const user = await getSessionUser(req);
if (!user) {
return NextResponse.json(⟪3⟩, ⟨4⟩);
} const orders = await db.order.findMany({ where: ⟨5⟩ });
return NextResponse.json(⟪6⟩);
}export async function POST(req: NextRequest) {
const user = await getSessionUser(req);
if (!user) {
return NextResponse.json(⟪7⟩, ⟨8⟩);
} const body = await req.json();
if (!body.items?.length) {
return NextResponse.json(⟪9⟩, { status: 400 }); } const order = await db.order.create({
data: { userId: user.id, items: body.items },
});
return NextResponse.json({ order }, { status: 201 });
}
Przy tłumaczeniu wzorców Express na obsługiwacze tras należy zwrócić uwagę na kilka szczegółów:
Segментy dynamiczne wykorzystują składnię opartą na folderach zamiast przedrostków w postaci kropek. Trasa Express o adreście /api/orders/:id staje się app/api/orders/[id]/route.ts. Parametr jest przekazywany jako drugi argument funkcji obsługiwacza: GET(req, { params }: { params: Promise<{ id: string }> }). W aktualnych wersjach Next.js parametry są dostarczane jako obietnica, więc należy na nie poczekać przed odczytaniem ich wartości.
Brak łańcucha middleware dla danej trasy. Middleware requireAuth z Express przekształca się albo w wspólną funkcję pomocniczą wywoływaną na początku każdego obsługiwacza (jak pokazano w powyższym przykładzie), albo – co jest lepsze – w logikę umieszczoną w pliku middleware.ts (omówione to w późniejszej sekcji), dzięki czemu poszczególne obsługiwacze tras nie muszą zajmować się kwestiami autoryzacji.
Aparatowanie ciała żądania wymaga wyraźnych wywołań – należy napisać await req.json(), zamiast polegać na express.json(). Nie ma żadnego automatycznego aparatowania, co w rzeczywistości poprawia przejrzystość: unika się nieoczekiwanych ograniczeń wielkości ciała żądania narzucanych przez globalny middleware, który skonfigurowano miesiące temu i o którym zapomniano.
Obsługi tras pozostają zwykłymi funkcjami Node lub Edge. Jeśli walidowałeś dane wejściowe za pomocą zod w swoich trasach Express, ten kod walidacyjny zostaje przeniesiony bez żadnych zmian.
Komponenty serwera a istniejące wzorce renderowania klienta w React
Aspekt ten najczęściej zaskakuje zespoły bardziej niż jakikolwiek inny. W architekturze Express plus React każdy komponent domyślnie jest komponentem klienta: renderuje się w przeglądarce, a gdy potrzebuje danych, wywołuje twoją API z pomocą useEffect lub za pośrednictwem biblioteki do pobierania danych, takiej jak React Query.
// stary wzorzec: React renderowany po stronie klienta, który komunikuje się z Express
function OrderList() {
const [orders, setOrders] = useState<Order[] | null>(null);
useEffect(() => {
fetch("/api/orders", { credentials: "include" })
.then((r) => r.json())
.then((data) => setOrders(data.orders));
}, []); if (!orders) return <Spinner />;
return (
<ul>
{orders.map((o) => (
<li key={o.id}>{o.id} — ${o.total}</li>
))}
</ul>
);
}
App Router odwraca tę domyślną zasadę. Każdy komponent jest komponentem serwerowym, chyba że wyraźnie zaznaczysz inaczej, co oznacza, że jest wykonywany na serwerze, bezpośrednio uzyskuje dostęp do bazy danych lub usług i nigdy nie wysyła swojego JavaScripta na stronę klienta. Dla danych należących do danej strony można całkowicie pominąć trasę API.
i zapytaj o to bezpośrednio:// app/orders/page.tsx — Komponent serwerowy, bez „use client”
import { db } from "@/lib/db";
import { getSessionUser } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function OrdersPage() {
const user = await getSessionUser();
if (!user) redirect("/login"); // bezpośredni dostęp do bazy danych, bez fetch, bez stanu ładowania, bez kosztów związanych z paczką kliencką
const orders = await db.order.findMany({
where: { userId: user.id }rver dostarcza HTML, który już zawiera dane. Dodaj "use client" tylko wtedy, gdy komponent wymaga interaktywności: stanu, efektów, obsługi zdarzeń lub API dostępnych wyłącznie w przeglądarce:// app/orders/OrderFilters.tsx
"use client";
import > from "react";
import > from "next/navigation"; export function OrderFilters() {
const router = useRouter();
const params = useSearchParams();
const [status, setStatus] = useState(params.get("status") ?? "all"); function apply(next: string) {
setStatus(next);
const url = new URLSearchParams(params);
url.set("status", next);
router.push(`/orders?$>`);
} return (
<select value=> onChange=>>
<option value="all">Wszystko</option>
<option value="pending">W oczekiwaniu</option>
<option value="shipped">Wysłane</option>
</select>
);
}
/code> Praktyczna zasada dla zespołów: włączaj "use client" tak nisko w drzewie komponentów, jak to możliwe. Zachowaj swoje strony i układy jako komponenty serwerowe; stosuj tę instrukcję tylko w przypadku komponentów liściowych, które rzeczywiście wymagają interaktywności. Jeśli z przyzwyczajenia dodasz "use client" do każdego komponentu (co zdarza się podczas przenoszenia aplikacji jednostronicowej bez ponownego przemyślenia architektury), nie uzyskasz żadnych korzyści z App Routera i ostatecznie będziesz mieć gorszy model mentalny niż przy oryginalnym ustawieniu.
Zastępowanie środowiska autoryzacji Express środowiskiem Next.js
W Express środowisko autoryzacji jest uruchamiane dla każdej trasy w procesie Node.js. Next.js dostarcza plik middleware.ts, który przechwytuje żądania na warstwie brzegowej, zanim dotrą do jakiejkolwiek strony lub obsługiwanego elementu, pełniąc rolę odpowiednika architektonicznego:
// stare: middleware/auth.ts (Express)
import jwt from „jsonwebtoken“;
export function requireAuth(req, res, next) {
const token = req.cookies.session;
if (!token) return res.status(401).json([„0“]); try {
req.user = jwt.verify(token, process.env.JWT_SECRET!);
next();
} catch {
res.status(401).json([„1“]);
}
}
// middleware.ts — znajduje się w korzeniu projektu
import „2“ from „next/server“;
import „3“ from „jose“; // kompatybilne z Edge, w odróżnieniu od jsonwebtoken
const PROTECTED_PREFIXES = [„/dashboard“, „/orders“, „/api/orders“]; export async funkcion middleware(req: NextRequest) {
const isProtected = PROTECTED_PREFIXES.some((p) =>
req.nextUrl.pathname.startsWith(p)
);
if (!isProtected) return NextResponse.next(); const token = req.cookies.get("session")?.value;
if (!token) {
return NextResponse.redirect(new URL("/login", req.url));
} try {
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
const { payload } = await jwtVerify(token, secret); // przekazanie zweryfikowanego ID użytkownika dalej poprzez nagłówek żądania
const headers = new Headers(req.headers);
headers.set("x-user-id", String(payload.sub));
return NextResponse.next({ request: { headers } });
} catch {
return NextResponse.redirect(new URL("/login", req.url));
}
}export const config = {
matcher: ["/dashboard/:path*", "/orders/:path*", "/api/orders/:path*"],
};
Twoje
Problemy, które regularnie utrudniają pracę zespołom migrującym:- Domyślnie middleware działa w środowisku Edge runtime, a nie w Node.js. Każda biblioteka polegająca na modułach podstawowych Node.js — takie jak
jsonwebtokenczy typowe klienty bazy danych — będzie działać niewłaściwie lub w ogóle przestać funkcjonować. Należy zastosować alternatywy przygotowane do pracy w środowisku Edge:josejest powszechnie stosowanym wyborem do operacji z JWT. Wszystkie zapytania do bazy danych należy przenieść do Server Components lub Route Handlers, gdzie dostępne jest pełne środowisko Node.js; nigdy nie należy ich wykonywać w middleware.
SELECT * FROM sessions WHERE id = ?. Ogranicz logikę middleware do szybkich, bezstanowych operacji, takich jak weryfikacja podpisu tokena. Pytania dotyczące uprawnień — „czy ten użytkownik może przeglądać tę zamówienie?” — należy przenieść do komponentu strony lub samego obsługiwanego elementu, gdzie dostęp do bazy danych i API Node.js są w pełni dostępne.Wzorce pobierania danych: Komponenty serwerowe kontra obecne podejście oparte na wywołaniach API
Większość architektur Express w połączeniu z Reactem follows przewidywalną sekwencję: komponent jest montowany, pobiera dane z API, API wysyła zapytanie do bazy danych, JSON wraca do przeglądarki, a komponent się aktualizuje. Dwa ruchy w sieci — od klienta do serwera, od serwera do bazy danych — dostarczają informacje, które serwer już posiadał.
Z App Routerem operacje odczytu w komponentach serwerowych sprowadzają je do jednego kroku: serwer zapytuje bazę danych i przesyła bezpośrednio do klienta strumień HTML zawierający wynik, jak pokazano we wcześniejszym kodzie OrdersPage. W przypadku operacji zapisu istnieją dwa typowe podejścia: Route Handlers, gdy potrzebujesz konwencjonalnej API (na przykład publicznej interfejsu REST), lub Server Actions do mutacji wywoływanych przez własne formularze i interfejs użytkownika.
Server Actions najbardziej odbiegają od konwencji Express. Definiujesz funkcję, która jest wykonywana na serwerze, i uruchamiasz ją bezpośrednio z formularza, nie tworząc żadnej wyraźnej trasy API:
// app/orders/actions.ts
"use server";
import { db } from "@/lib/db";
import { getSessionUser } from "@/lib/auth";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";export async function createOrder(formData: FormData) {
const user = await getSessionUser();
if (!user) redirect("/login"); const itemId = formData.get("itemId");
if (typeof itemId !== "string" || !itemId) {
throw new Error("Niezbędny jest parametr itemId");
} await db.order.create({
data: { userId: user.id, items: [{ itemId, qty: 1 }] },
}); // ponowne wyświetlenie strony z zamówieniami ze świeżymi danymi serwerowymi — nie ma potrzeby ponownego pobierania na stronie klienta
revalidatePath("/orders");
}
// app/orders/NewOrderForm.tsx
import ⟨0⟩ from „./actions“;
export function NewOrderForm() {
return (
<form action=⟪1⟩>
<input type="text" name="itemId" placeholder="ID elementu" required />
<button type="submit">Stworzyć zamówienie</button>
</form>
);
}
Wybór między działaniami serwera a obsługą tras:
Działanie serwera to wewnętrzne wywołanie funkcji z interfejsu użytkownika do serwera; obsługa trasy to prawdziwy punkt końcowy HTTP. Różnica ta ma znaczenie przy decydowaniu, który wzorzec pasuje do danej mutyacji.
Użyj Route Handlera, gdy endpoint musi być stabilny i dostępny z zewnątrz aplikacji Next.js – np. przez klienta mobilnego, integrację z podmiotem trzecim lub publiczną API. Route Handlery zapewniają wyraźne ścieżki URL, czasowniki HTTP oraz kontrakt, który sam kontrolujesz i wersjonujesz.
Użyj Server Actiona, gdy zmiana pochodzi z twoich własnych form i komponentów interaktywnych. Server Actions wymagają mniej kodu szablonowego i automatycznie odnowiają dane z pamięci podręcznej za pomocą revalidatePath lub revalidateTag, eliminując konieczność ręcznego aktualizowania pamięci podręcznej po wywołaniu fetch. Nie są przeznaczone dla użytkowników zewnętrznych ani do zapewnienia zgodności wstecznej; traktuj je jako wewnętrzne zdalne wywołania procedur.
Test praktyczny: jeśli chcesz udokumentować punkt końcowy dla kogoś spoza twojego zespołu, utwórz z niego Route Handler. Jeśli istnieje on wyłącznie w celu obsługi przycisku lub formularza w interfejsie użytkownika, prostsze będzie użycie Server Action.
Różnice w implementacji (Vercel vs ECS/EB)
Zespoły przyzwyczajone do wdrażania Express na ECS lub Elastic Beanstalk napotykają fundamentalną zmianę architektoniczną przy pracy z Next.js na Vercel. Aplikacja Express działa jako proces Node o długim czasie trwania: jeden proces obsługuje wiele żądań, utrzymuje aktywne połączenia z bazą danych i charakteryzuje się przewidywalnym zużyciem pamięci oraz czasem uruchamiania.
Next.js na Vercel rozpakowuje strony oraz obsługę tras jako oddzielne funkcje serverless lub edge functions. Każda z tych funkcji uruchamia się niezależnie, działa w ramach własnych ograniczeń wykonywania i nie dzieli się trwałym pulą połączeń do bazy danych w taki sposób, jak to ma miejsce przy pojedynczym procesie Express. Jeśli utworzysz klienta Prisma w taki sam sposób, jak to robiono w Express, przy dużej obciążeniu wyczerpiesz limit połączeń do bazy danych, ponieważ każda wywołanie funkcji może utworzyć własne połączenie.
// lib/db.ts — wymagany wzorzec dla serverless Prisma
import { PrismaClient } from "@prisma/client";
const globalForPrisma = global as unknown as { prisma: PrismaClient };// ponowne użycie klienta przy kolejnych wywołaniach zamiast tworzenia nowego za każdym razem
export const db =
globalForPrisma.prisma ??
new PrismaClient({
// użyj łącza typu pooled (np. PgBouncer / Prisma Accelerate / RDS Proxy)
datasources: { db: { url: process.env.DATABASE_URL_POOLED } },
});if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;
Wzorzec ten umożliwia ponowne użycie klienta przy kolejnych wywołaniach zamiast tworzenia nowej instancji za każdym razem.
Jeśli zainstalujesz Next.js w ECS lub Elastic Beanstalk zamiast na Vercel, framework będzie działał jako tradycyjny serwer Node. Uruchom next build, a następnie next start, albo skonfiguruj tryb wyjściowy typu standalone, aby uzyskać lżejsze artefakty Dockera. Zachowujesz zalety długotrwałych procesów, ale tracisz automatyczną sieć edge Vercel, incrementalną regenerację treści statycznych w CDN oraz możliwość szybkich testów bez konfiguracji. Musisz sam skonfigurować te funkcje albo pogodzić się z ich brakiem. To jest uzasadniona decyzja inżynieryjna, a nie kompromis; wiele zespołów używa Next.js w ECS właśnie dlatego, że już posiada taką infrastrukturę i woli nie dzielić hostingu między dwoma dostawcami.
Przydziel czas na dwa dodatkowe zadania migracyjne: zmiennice środowiskowe (Next.js wymaga przedrostka NEXT_PUBLIC_ dla każdej zmiennej dostępnej w przeglądarce — sprawdź każdą zmienną, którą przekazałeś do budowania aplikacji React) oraz konfigurację w czasie budowy versus w czasie wykonywania (wartości wbudowane w statyczny eksport zachowują się inaczej niż te odczytywane na żądanie, w przeciwieństwie do pojedynczego procesu Express, gdzie wszystko działa w czasie wykonywania).
Lista kontrolna migracji i częste pułapki
Wykonuj te kroki kolejno:
- Rozwijaj aplikację Next.js równolegle z serwerem Express — nie dotykaj istniejącej aplikacji podczas początkowej konfiguracji.
- Najpierw przekształć strony wymagające intensywnego odczytu i zoptymalizowane pod wyszukiwarki — te strony najbardziej skorzystają na użyciu Server Components i niosą minimalne ryzyko migracji.
middleware.ts za pomocą biblioteki do weryfikacji tokenów kompatybilnej z środowiskiem Edge Runtime.next build && next start) — next dev maskuje pewne błędy, które pojawiają się tylko w trybie produkcyjnym, takie jak naruszenia zasad działania między serwerem a klientem.Częste błędy występujące podczas migracji:
- Importowanie kodu dostępnego tylko na serwerze do komponentu klienta. Gdy komponent z instrukcją
"use client"importuje cokolwiek, co ma dostęp dofs, klienta bazy danych lub tajemnic, proces budowania albo się nie powiedzie, albo — co jest bardziej niebezpieczne — tajemnice trafią do pliku JavaScript klienta. Należy zainstalować pakietserver-only, aby wywołać błąd podczas budowania, zamiast w tajemnicy ujawniać dane dostępowe.
revalidatePath lub revalidateTag po akcji serwera. Bez wyraźnej ponownej walidacji interfejs pokazuje przestarzałe dane po zmianie, ponieważ komponenty serwerowe mogą być w pamięci podręcznej."use client". Nawyki z aplikacji jednostronicowych są główną przyczyną, dla której aplikacja Next.js nie osiąga lepszych wyników niż oryginał.Podsumowanie
Jeśli nadal jesteś niepewny, zacznij od prostego eksperymentu: wybierz jedną stronę wymagającą dużo odczytów i istotną pod kątem SEO z obecnej aplikacji, przerób ją na komponent serwerowy, który będzie wysyłał zapytania do bazy danych lub bezpośrednio wywoływał istniejące API Express, a następnie umieść go na trasie, której nie obsługuje serwer Express. Porównaj czas dostarczenia pierwszego bajtu oraz rozmiar pliku przed migrowaniem kolejnych stron. Po zweryfikowaniu tej metody przenieś logikę autoryzacji do pliku middleware.ts, a następnie stopniowo przekształcaj najbardziej obciążone procesy w Server Actions – takie stopniowe migrowanie jest bezpieczniejsze i bardziej praktyczne niż całkowite przepisanie kodu. Zespoły, które napotykają problemy, to te, które migrują całą bazę kodu, zanim określą, gdzie komponenty serwerowe faktycznie zmniejszają pracę, a gdzie jedynie wprowadzają nową warstwę koncepcyjną na istniejącym systemie.
Literatura pokrewna
- Stwórz interfejs wyszukiwania w React z wyraźną odpowiedzialnością za stan — Trzymaj wydarzenia, wartości pochodne, cykle żądań oraz wyświetlanie informacji oddzielnie, aby prawidłowa odpowiedź trafiła do właściwego zapytania.
- Debuguj RAG, znajdując pierwszy etap, który zawiodł — Oddziel awarie źródła danych, procesu pobierania informacji, generowania treści oraz operacyjne problemy, aby każde badanie rozwiązywało jasno zidentyfikowany problem.
- Rola mostu TypeScript 6 na drodze do natywnego kompilatora TS 7 — Dowiedz się, w jaki sposób TypeScript 6 aktualizuje domyślne konfiguracje, rozwiązywanie problemów z modułami oraz składnię importów, aby przygotować bazy kodu pod szybszy, oparty na Go kompilator TypeScript 7.