Johdanto
React Server Components (RSC) ja Server Actions ovat rehellisesti sanottuna muuttaneet koko tapaa, jolla rakennamme full-stack-sovelluksia Next.js:llä. Vuonna 2026 nämä teknologiat ovat vihdoin kypsyneet tuotantovalmiiksi, ja niiden ympärille on alkanut muodostua vakiintuneita suunnittelumalleja — sellaisia, jotka jokaisen Next.js-kehittäjän kannattaa tuntea.
Tässä artikkelissa sukelletaan syvemmälle RSC:n ja Server Actionien käytännön suunnittelumalleihin. Käydään läpi komponenttiarkkitehtuuri, datan hakeminen, lomakkeiden käsittely, virheenhallinta ja suorituskyvyn optimointi. Kaikki konkreettisilla koodiesimerkeillä, tietenkin.
Jos olet jo tutustunut Next.js:n uusimpiin ominaisuuksiin, tämä artikkeli vie sinut seuraavalle tasolle.
Server Components: Oletusarvoinen renderöintimalli
Next.js App Routerissa jokainen komponentti on oletuksena Server Component. Käytännössä tämä tarkoittaa, että komponentti suoritetaan palvelimella, sen tuottama HTML lähetetään selaimelle, eikä komponenttiin liittyvää JavaScriptiä lähetetä asiakaspuolelle lainkaan. Jos olet tottunut perinteiseen React-kehitykseen, tämä on iso ajattelutavan muutos.
Suurin etu? Server Componentit eivät kasvata asiakaspuolen JavaScript-pakettia yhtään. Voit käyttää niissä suoraan tietokantakyselyitä, tiedostojärjestelmää ja muita palvelinpuolen resursseja — ilman ylimääräisiä API-kerroksia.
// app/products/page.tsx
// Tämä on automaattisesti Server Component
import { db } from "@/lib/database";
export default async function ProductsPage() {
// Suora tietokantakysely — ei tarvita API-reittiä
const products = await db.query("SELECT * FROM products WHERE active = true");
return (
<main>
<h1>Tuotteet</h1>
<ul>
{products.map((product) => (
<li key={product.id}>
<h2>{product.name}</h2>
<p>{product.price} €</p>
</li>
))}
</ul>
</main>
);
}
Milloin Server Component, milloin Client Component?
Nyrkkisääntö on itse asiassa aika yksinkertainen: käytä Server Componenteja oletuksena ja lisää "use client" vasta silloin, kun tarvitset selaimen API:ja tai Reactin hookeja kuten useState, useEffect tai useRef.
Käytännössä jako menee suunnilleen näin:
- Server Components: Datan hakeminen, tietokantakyselyt, raskaat laskutoimitukset, arkaluonteisten tietojen käsittely (API-avaimet, tokenit), staattinen sisältö
- Client Components: Lomakkeiden interaktiivisuus, animaatiot, selaimen API:t (localStorage, geolocation), reaaliaikaiset päivitykset, tapahtumapohjaiset käyttöliittymät
Komponenttiarkkitehtuurin suunnittelumallit
1. Palvelin ensin -malli (Server-First Pattern)
Tehokkain arkkitehtuurimalli on pitää Server Componentit komponenttipuun yläosassa ja työntää Client Componentit mahdollisimman alas "lehtisolmuiksi". Tämä minimoi asiakaspuolelle lähetettävän JavaScriptin määrän, mikä näkyy suoraan sivun latausnopeudessa.
// app/dashboard/page.tsx — Server Component (ylin taso)
import { db } from "@/lib/database";
import { DashboardStats } from "./dashboard-stats";
import { ActivityChart } from "./activity-chart"; // Client Component
import { RecentOrders } from "./recent-orders";
export default async function DashboardPage() {
const stats = await db.getDashboardStats();
const orders = await db.getRecentOrders(10);
return (
<div className="dashboard">
{/* Server Component — ei JS:ää asiakkaalle */}
<DashboardStats stats={stats} />
{/* Client Component — vain tämä lähettää JS:ää */}
<ActivityChart data={stats.activityData} />
{/* Server Component — ei JS:ää asiakkaalle */}
<RecentOrders orders={orders} />
</div>
);
}
// app/dashboard/activity-chart.tsx
"use client";
import { useEffect, useRef } from "react";
export function ActivityChart({ data }: { data: number[] }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
// Piirretään kaavio canvas-elementtiin
const ctx = canvasRef.current?.getContext("2d");
if (!ctx) return;
// ... kaavion piirtologiikka
}, [data]);
return <canvas ref={canvasRef} width={800} height={400} />;
}
2. Server Component lapsena Client Componentissa (Children Pattern)
Tämä on yksi niistä malleista, joka tuntui aluksi omituiselta mutta osoittautui todella tehokkaaksi. Ideana on välittää Server Component Client Componentin lapsena. Näin saat palvelimella renderöidyn sisällön upotettua interaktiiviseen kuoreen ilman, että sisällöstä tulee asiakaspuolen koodia.
// app/components/modal.tsx
"use client";
import { useState } from "react";
import { ReactNode } from "react";
export function Modal({ children, trigger }: {
children: ReactNode;
trigger: string;
}) {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>{trigger}</button>
{isOpen && (
<div className="modal-overlay">
<div className="modal-content">
{/* Server-renderöity sisältö — ei lisää JS-pakettia */}
{children}
<button onClick={() => setIsOpen(false)}>Sulje</button>
</div>
</div>
)}
</>
);
}
// app/products/[id]/page.tsx — Server Component
import { Modal } from "@/components/modal";
import { ProductDetails } from "./product-details"; // Server Component
export default async function ProductPage({ params }: {
params: Promise<{ id: string }>
}) {
const { id } = await params;
return (
<div>
<h1>Tuotesivu</h1>
<Modal trigger="Näytä tuotetiedot">
{/* Tämä renderöidään palvelimella */}
<ProductDetails productId={id} />
</Modal>
</div>
);
}
3. Rinnakkainen datan hakeminen (Parallel Data Fetching)
Yksi yleisimmistä (ja helpoimmin vältettävissä olevista) suorituskykyongelmista on datan hakeminen peräkkäin, kun se voitaisiin tehdä rinnakkain. Tämä luo niin kutsutun "vesiputouksen", joka hidastaa sivun lataamista merkittävästi. Olen nähnyt tämän virheen yllättävän monessa projektissa.
// ❌ Huono: peräkkäinen hakeminen (waterfall)
export default async function ProfilePage({ params }: {
params: Promise<{ userId: string }>
}) {
const { userId } = await params;
const user = await getUser(userId); // 200ms
const posts = await getUserPosts(userId); // 300ms — odottaa edellisen
const followers = await getFollowers(userId); // 150ms — odottaa edellisen
// Yhteensä: ~650ms
return <Profile user={user} posts={posts} followers={followers} />;
}
// ✅ Hyvä: rinnakkainen hakeminen
export default async function ProfilePage({ params }: {
params: Promise<{ userId: string }>
}) {
const { userId } = await params;
const [user, posts, followers] = await Promise.all([
getUser(userId), // 200ms
getUserPosts(userId), // 300ms — samanaikainen
getFollowers(userId), // 150ms — samanaikainen
]);
// Yhteensä: ~300ms (hitaimman kesto)
return <Profile user={user} posts={posts} followers={followers} />;
}
Ero on huomattava — yli puolet nopeampi, yhdellä pienellä muutoksella.
Suspense ja suoratoisto: Progressiivinen renderöinti
React Suspense ja Next.js:n sisäänrakennettu suoratoisto (streaming) mahdollistavat sivun progressiivisen renderöinnin. Käytännössä tämä tarkoittaa, ettei käyttäjän tarvitse odottaa kaiken datan lataamista ennen kuin mitään näkyy. Voit näyttää staattisen rungon heti ja täydentää dynaamiset osat sitä mukaa kuin data valmistuu.
Suspense-rajat komponenttitasolla
// app/dashboard/page.tsx
import { Suspense } from "react";
import { DashboardHeader } from "./dashboard-header";
import { SalesChart } from "./sales-chart";
import { RecentActivity } from "./recent-activity";
import { UserStats } from "./user-stats";
export default function DashboardPage() {
return (
<div className="dashboard">
{/* Tämä näkyy heti */}
<DashboardHeader />
<div className="dashboard-grid">
{/* Jokainen osio latautuu itsenäisesti */}
<Suspense fallback={<ChartSkeleton />}>
<SalesChart />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
<Suspense fallback={<StatsSkeleton />}>
<UserStats />
</Suspense>
</div>
</div>
);
}
// Luurankokomponentit (skeleton) antavat visuaalisen vihjeen latauksesta
function ChartSkeleton() {
return (
<div className="skeleton chart-skeleton">
<div className="skeleton-bar" style={{ width: "60%" }} />
<div className="skeleton-bar" style={{ width: "80%" }} />
<div className="skeleton-bar" style={{ width: "40%" }} />
</div>
);
}
loading.tsx ja error.tsx -tiedostot
Next.js App Router tarjoaa tiedostopohjaisen tavan hallita lataus- ja virhetiloja. loading.tsx luo automaattisesti Suspense-rajan koko reitille, ja error.tsx käsittelee virheet kyseisen reittiosan sisällä. Tämä on yksi niistä asioista, jotka tekevät App Routerista niin käyttökelpoisen.
// app/products/loading.tsx
export default function ProductsLoading() {
return (
<div className="products-loading">
<h1>Ladataan tuotteita...</h1>
<div className="grid">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="product-card-skeleton">
<div className="skeleton-image" />
<div className="skeleton-text" />
<div className="skeleton-price" />
</div>
))}
</div>
</div>
);
}
// app/products/error.tsx
"use client";
export default function ProductsError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="error-container">
<h2>Tuotteiden lataaminen epäonnistui</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>Yritä uudelleen</button>
</div>
);
}
Partial Prerendering: Staattisen ja dynaamisen parhaat puolet
Partial Prerendering (PPR) on Next.js:n uusi renderöintimalli, joka yhdistää staattisen ja dynaamisen renderöinnin samalla sivulla. Idea on yksinkertainen mutta nerokas: PPR esirenderöi sivun staattisen rungon rakennusvaiheessa ja jättää dynaamisille osille "aukot", jotka täytetään suoratoistolla käyttäjän pyytäessä sivua.
Käytännössä staattinen sisältö näkyy käyttäjälle välittömästi CDN:ltä, ja dynaamiset osat (kuten käyttäjäkohtaiset tiedot tai reaaliaikaiset hinnat) suoratoistetaan rinnakkain. Aika siisti ratkaisu.
PPR:n käyttöönotto
// next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = {
experimental: {
ppr: "incremental", // Ota PPR käyttöön reittikohtaisesti
},
};
export default config;
// app/store/page.tsx
import { Suspense } from "react";
import { ProductGrid } from "./product-grid";
import { UserGreeting } from "./user-greeting";
import { CartSummary } from "./cart-summary";
// Aktivoi PPR tälle reitille
export const experimental_ppr = true;
export default function StorePage() {
return (
<main>
{/* Staattinen runko — esirenderöidään rakennusvaiheessa */}
<header>
<h1>Verkkokauppa</h1>
<nav>{/* Navigaatio — staattinen */}</nav>
{/* Dynaaminen osa — suoratoistetaan */}
<Suspense fallback={<span>Ladataan...</span>}>
<UserGreeting />
</Suspense>
<Suspense fallback={<span>Ostoskori (...)</span>}>
<CartSummary />
</Suspense>
</header>
{/* Tuotelista — staattinen */}
<ProductGrid />
</main>
);
}
// app/store/user-greeting.tsx — Server Component
import { cookies } from "next/headers";
// Tämä komponentti käyttää cookies()-API:a, joka tekee siitä dynaamisen
export async function UserGreeting() {
const sessionCookie = (await cookies()).get("session");
if (!sessionCookie) {
return <span>Tervetuloa, vieras!</span>;
}
const user = await getUserFromSession(sessionCookie.value);
return <span>Tervetuloa, {user.name}!</span>;
}
PPR:n hyöty on konkreettinen: staattinen runko palvellaan heti reunapalvelimilta, ja dynaamiset osat suoratoistetaan samassa HTTP-pyynnössä — ilman ylimääräisiä kierroksia palvelimelle.
Server Actions: Lomakkeet ja mutaatiot
Server Actions ovat asynkronisia funktioita, jotka suoritetaan palvelimella mutta joita voidaan kutsua suoraan Client Componenteista. Next.js luo automaattisesti POST-päätepisteen taustalle ja hoitaa verkkopyynnön puolestasi. Tämä poistaa valtavan määrän boilerplate-koodia.
Peruskäyttö: Lomakkeen käsittely
// app/actions/contact.ts
"use server";
import { z } from "zod";
import { db } from "@/lib/database";
// Validointiskeema
const contactSchema = z.object({
name: z.string().min(2, "Nimi on liian lyhyt"),
email: z.string().email("Virheellinen sähköpostiosoite"),
message: z.string().min(10, "Viesti on liian lyhyt"),
});
export async function submitContactForm(formData: FormData) {
// Validoi syöte palvelimella — tämä on turvallisuuden kannalta kriittistä
const result = contactSchema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
message: formData.get("message"),
});
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
};
}
// Tallenna tietokantaan
await db.insert("contact_messages", result.data);
return { success: true, errors: null };
}
// app/contact/page.tsx — Server Component
import { ContactForm } from "./contact-form";
export default function ContactPage() {
return (
<main>
<h1>Ota yhteyttä</h1>
<ContactForm />
</main>
);
}
// app/contact/contact-form.tsx
"use client";
import { useActionState } from "react";
import { submitContactForm } from "@/app/actions/contact";
export function ContactForm() {
const [state, formAction, isPending] = useActionState(
async (_prevState: any, formData: FormData) => {
return await submitContactForm(formData);
},
{ success: false, errors: null }
);
return (
<form action={formAction}>
<div>
<label htmlFor="name">Nimi</label>
<input id="name" name="name" type="text" required />
{state.errors?.name && (
<span className="error">{state.errors.name[0]}</span>
)}
</div>
<div>
<label htmlFor="email">Sähköposti</label>
<input id="email" name="email" type="email" required />
{state.errors?.email && (
<span className="error">{state.errors.email[0]}</span>
)}
</div>
<div>
<label htmlFor="message">Viesti</label>
<textarea id="message" name="message" rows={5} required />
{state.errors?.message && (
<span className="error">{state.errors.message[0]}</span>
)}
</div>
<button type="submit" disabled={isPending}>
{isPending ? "Lähetetään..." : "Lähetä viesti"}
</button>
{state.success && (
<p className="success">Viesti lähetetty onnistuneesti!</p>
)}
</form>
);
}
Progressiivinen parannus
Tässä on yksi Server Actionien parhaista puolista: progressiivinen parannus. Lomake toimii myös ilman JavaScriptiä — jos selain ei tue tai lataa JavaScriptiä, lomake lähetetään silti perinteisenä HTML-lomakkeena palvelimelle. Tämä on harvinaisen elegantti ratkaisu.
// app/newsletter/page.tsx — Toimii ilman JavaScriptiä
import { subscribeToNewsletter } from "@/app/actions/newsletter";
export default function NewsletterPage() {
return (
<form action={subscribeToNewsletter}>
<label htmlFor="email">Tilaa uutiskirje</label>
<input id="email" name="email" type="email" required />
<button type="submit">Tilaa</button>
</form>
);
}
Mutaatiot ja välimuistin päivitys
Kun Server Action muuttaa dataa, sinun täytyy kertoa Next.js:lle, että välimuistissa oleva data on vanhentunut. Tähän käytetään revalidatePath- ja revalidateTag-funktioita. Tämä unohtuu helposti, joten pidä se mielessä.
// app/actions/products.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { db } from "@/lib/database";
export async function createProduct(formData: FormData) {
const name = formData.get("name") as string;
const price = parseFloat(formData.get("price") as string);
const description = formData.get("description") as string;
// Validoi ja tallenna
const product = await db.insert("products", {
name,
price,
description,
createdAt: new Date().toISOString(),
});
// Päivitä välimuisti
revalidatePath("/products");
revalidatePath(`/products/${product.id}`);
// Ohjaa uudelle sivulle
redirect(`/products/${product.id}`);
}
export async function deleteProduct(productId: string) {
await db.delete("products", productId);
// Päivitä tuotelista
revalidatePath("/products");
redirect("/products");
}
Optimistinen päivitys (Optimistic Updates)
Optimistinen päivitys on yksi niistä asioista, jotka tekevät sovelluksesta oikeasti miellyttävän käyttää. Ideana on näyttää muutos heti käyttöliittymässä ennen kuin palvelin on ehtinyt vahvistaa operaation. React 19:n useOptimistic-hook tekee tästä yllättävän suoraviivaista.
// app/todos/todo-list.tsx
"use client";
import { useOptimistic } from "react";
import { addTodo, toggleTodo, deleteTodo } from "@/app/actions/todos";
type Todo = {
id: string;
text: string;
completed: boolean;
};
export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [optimisticTodos, setOptimisticTodos] = useOptimistic(
initialTodos,
(state: Todo[], action: { type: string; payload: any }) => {
switch (action.type) {
case "add":
return [...state, {
id: crypto.randomUUID(),
text: action.payload,
completed: false,
}];
case "toggle":
return state.map((todo) =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
);
case "delete":
return state.filter((todo) => todo.id !== action.payload);
default:
return state;
}
}
);
async function handleAdd(formData: FormData) {
const text = formData.get("text") as string;
// Näytä muutos heti
setOptimisticTodos({ type: "add", payload: text });
// Lähetä palvelimelle taustalla
await addTodo(text);
}
async function handleToggle(id: string) {
setOptimisticTodos({ type: "toggle", payload: id });
await toggleTodo(id);
}
async function handleDelete(id: string) {
setOptimisticTodos({ type: "delete", payload: id });
await deleteTodo(id);
}
return (
<div>
<form action={handleAdd}>
<input name="text" placeholder="Uusi tehtävä" required />
<button type="submit">Lisää</button>
</form>
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id}>
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo.id)}
/>
<span className={todo.completed ? "completed" : ""}>
{todo.text}
</span>
</label>
<button onClick={() => handleDelete(todo.id)}>Poista</button>
</li>
))}
</ul>
</div>
);
}
Tyyppiturvallinen Server Actions: next-safe-action
Tuotantoympäristössä Server Actionien tulisi olla tyyppiturvallisia ja sisältää yhdenmukainen virheenkäsittely. Olen itse alkanut käyttää next-safe-action -kirjastoa lähes kaikissa projekteissani, ja se tarjoaa tähän todella elegantin ratkaisun middleware-putkiston avulla.
// lib/safe-action.ts
import { createSafeActionClient } from "next-safe-action";
import { getSession } from "@/lib/auth";
// Luo perusasiakas
export const actionClient = createSafeActionClient({
handleServerError(e) {
console.error("Server Action -virhe:", e.message);
return "Odottamaton virhe tapahtui";
},
});
// Luo autentikoitu asiakas
export const authActionClient = actionClient.use(async ({ next }) => {
const session = await getSession();
if (!session) {
throw new Error("Kirjautuminen vaaditaan");
}
return next({
ctx: { userId: session.userId, role: session.role },
});
});
// app/actions/update-profile.ts
"use server";
import { z } from "zod";
import { authActionClient } from "@/lib/safe-action";
import { db } from "@/lib/database";
import { revalidatePath } from "next/cache";
const updateProfileSchema = z.object({
displayName: z.string().min(2).max(50),
bio: z.string().max(500).optional(),
});
export const updateProfile = authActionClient
.schema(updateProfileSchema)
.action(async ({ parsedInput, ctx }) => {
const { displayName, bio } = parsedInput;
const { userId } = ctx;
await db.update("users", userId, { displayName, bio });
revalidatePath("/profile");
return { message: "Profiili päivitetty onnistuneesti" };
});
// app/profile/edit/profile-form.tsx
"use client";
import { useAction } from "next-safe-action/hooks";
import { updateProfile } from "@/app/actions/update-profile";
export function ProfileForm({ currentProfile }: {
currentProfile: { displayName: string; bio: string };
}) {
const { execute, result, isPending } = useAction(updateProfile);
return (
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
execute({
displayName: formData.get("displayName") as string,
bio: formData.get("bio") as string,
});
}}
>
<input
name="displayName"
defaultValue={currentProfile.displayName}
/>
<textarea name="bio" defaultValue={currentProfile.bio} />
<button disabled={isPending}>
{isPending ? "Tallennetaan..." : "Tallenna"}
</button>
{result.data?.message && (
<p className="success">{result.data.message}</p>
)}
{result.serverError && (
<p className="error">{result.serverError}</p>
)}
</form>
);
}
Datan hakemisen parhaat käytännöt
react.cache() deduplikaatioon
Kun sama data tarvitaan useassa komponentissa saman renderöinnin aikana, react.cache() varmistaa, että kysely suoritetaan vain kerran. Tämä on erityisen hyödyllistä silloin, kun esimerkiksi käyttäjätiedot tarvitaan sekä navigaatiossa että sivun sisällössä — mikä on todella yleinen tilanne.
// lib/data/user.ts
import { cache } from "react";
import { db } from "@/lib/database";
// Tämä funktio suoritetaan vain kerran per renderöintipyyntö,
// vaikka sitä kutsuttaisiin useasta komponentista
export const getCurrentUser = cache(async () => {
const session = await getSession();
if (!session) return null;
return db.query("SELECT * FROM users WHERE id = ?", [session.userId]);
});
// app/components/navbar.tsx — Server Component
import { getCurrentUser } from "@/lib/data/user";
export async function Navbar() {
const user = await getCurrentUser(); // Ensimmäinen kutsu — suorittaa kyselyn
return (
<nav>
{user ? (
<span>Hei, {user.name}!</span>
) : (
<a href="/login">Kirjaudu</a>
)}
</nav>
);
}
// app/dashboard/page.tsx — Server Component
import { getCurrentUser } from "@/lib/data/user";
export default async function DashboardPage() {
const user = await getCurrentUser(); // Toinen kutsu — käyttää välimuistia
if (!user) redirect("/login");
return <h1>Tervetuloa, {user.name}</h1>;
}
Älä kutsu Route Handlereita Server Componenteista
Tämä on yllättävän yleinen virhe: omien API-reittien kutsuminen Server Componentista. Se luo ylimääräisen HTTP-pyynnön palvelimelta palvelimelle, mikä on täysin turhaa. Server Componentissa voit suoraan käyttää samaa logiikkaa ilman kierrosta verkon kautta.
// ❌ Huono: turha HTTP-pyyntö
export default async function ProductsPage() {
// Tämä lähettää HTTP-pyynnön samalle palvelimelle
const res = await fetch("http://localhost:3000/api/products");
const products = await res.json();
return <ProductList products={products} />;
}
// ✅ Hyvä: suora tietokantakysely
import { getProducts } from "@/lib/data/products";
export default async function ProductsPage() {
const products = await getProducts();
return <ProductList products={products} />;
}
Virheenkäsittely ja rajatapaukset
Error Boundary -hierarkia
Next.js App Routerin error.tsx-tiedostot luovat automaattisen Error Boundary -hierarkian. Jokainen reittiosan virhe käsitellään lähimmässä error.tsx-tiedostossa, mikä estää koko sovelluksen kaatumisen yksittäisen osan virheen takia.
// app/error.tsx — Ylimmän tason virheenkäsittely
"use client";
import { useEffect } from "react";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Lähetä virhe seurantapalveluun
console.error("Globaali virhe:", error);
}, [error]);
return (
<div className="error-page">
<h1>Jotain meni pieleen</h1>
<p>Pahoittelemme häiriötä. Yritä uudelleen.</p>
<button onClick={reset}>Yritä uudelleen</button>
</div>
);
}
not-found.tsx mukautetuille 404-sivuille
// app/products/[id]/page.tsx
import { notFound } from "next/navigation";
import { db } from "@/lib/database";
export default async function ProductPage({ params }: {
params: Promise<{ id: string }>
}) {
const { id } = await params;
const product = await db.getProduct(id);
if (!product) {
notFound(); // Näyttää lähimmän not-found.tsx:n
}
return <ProductDetails product={product} />;
}
// app/products/[id]/not-found.tsx
export default function ProductNotFound() {
return (
<div className="not-found">
<h2>Tuotetta ei löytynyt</h2>
<p>Etsimääsi tuotetta ei ole olemassa tai se on poistettu.</p>
<a href="/products">Takaisin tuotelistaan</a>
</div>
);
}
Turvallisuus: Server Actionien suojaaminen
Tämä on tärkeä pointti, joka joskus unohtuu: Server Actionit ovat käytännössä julkisia HTTP-päätepisteitä. Vaikka Next.js suojaa niitä automaattisesti CSRF-hyökkäyksiltä, sinun vastuullasi on varmistaa autentikaatio ja auktorisointi jokaisessa actionissa.
// app/actions/admin.ts
"use server";
import { getSession } from "@/lib/auth";
import { db } from "@/lib/database";
export async function deleteUser(userId: string) {
// 1. Autentikaatio: onko käyttäjä kirjautunut?
const session = await getSession();
if (!session) {
throw new Error("Kirjautuminen vaaditaan");
}
// 2. Auktorisointi: onko käyttäjällä oikeus tähän toimintoon?
if (session.role !== "admin") {
throw new Error("Vain ylläpitäjät voivat poistaa käyttäjiä");
}
// 3. Validointi: onko syöte kelvollinen?
if (typeof userId !== "string" || userId.length === 0) {
throw new Error("Virheellinen käyttäjätunnus");
}
// 4. Liiketoimintalogiikan tarkistus
if (userId === session.userId) {
throw new Error("Et voi poistaa omaa tiliäsi");
}
await db.delete("users", userId);
}
Muista aina: asiakaspuolen validointi parantaa käyttökokemusta, mutta palvelinpuolen validointi on se, joka oikeasti suojaa sovellustasi. Älä koskaan luota pelkkään asiakaspuolen tarkistukseen.
Suorituskyvyn optimointi: Yhteenveto parhaista käytännöistä
Kootaan yhteen tärkeimmät suorituskykyyn vaikuttavat suunnittelumallit:
- Hae data siellä missä sitä käytetään: Käytä Server Componenteja datan hakemiseen suoraan — älä luo tarpeettomia API-kerroksia.
- Käytä rinnakkaista hakemista:
Promise.all()estää vesiputoukset ja nopeuttaa sivun lataamista merkittävästi. - Hyödynnä Suspense-rajoja: Jaa sivu itsenäisiin latausalueisiin, jotta käyttäjä näkee sisältöä progressiivisesti.
- Minimoi Client Componentit: Pidä interaktiiviset komponentit pieninä ja komponenttipuun alaosassa.
- Käytä react.cache() deduplikaatioon: Vältä turhia tietokantakyselyitä, kun sama data tarvitaan useassa paikassa.
- Harkitse PPR:ää: Yhdistä staattisen renderöinnin nopeus dynaamisen sisällön joustavuuteen.
- Vältä Server Actionien käyttöä datan hakemiseen: Server Actionit käyttävät POST-pyyntöjä, joita ei voi välimuistia. Käytä niitä vain mutaatioihin.
- Hyödynnä optimistisia päivityksiä:
useOptimistictekee käyttöliittymästä responsiivisen ilman palvelimen vasteajan odottelua.
Käytännön esimerkki: Täydellinen CRUD-sovellus
Kootaan vielä lopuksi kaikki opit yhteen yksinkertaisella mutta täydellisellä esimerkillä. Tämä yhdistää Server Components -datan hakemisen, Server Actions -mutaatiot, Suspense-suoratoiston ja virheenkäsittelyn — kaikki samassa sovelluksessa.
// app/notes/page.tsx — Server Component
import { Suspense } from "react";
import { db } from "@/lib/database";
import { NoteList } from "./note-list";
import { CreateNoteForm } from "./create-note-form";
export default function NotesPage() {
return (
<main>
<h1>Muistiinpanot</h1>
<CreateNoteForm />
<Suspense fallback={<p>Ladataan muistiinpanoja...</p>}>
<NotesLoader />
</Suspense>
</main>
);
}
async function NotesLoader() {
const notes = await db.query(
"SELECT * FROM notes ORDER BY created_at DESC"
);
return <NoteList initialNotes={notes} />;
}
// app/actions/notes.ts
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/database";
const noteSchema = z.object({
title: z.string().min(1, "Otsikko vaaditaan").max(200),
content: z.string().min(1, "Sisältö vaaditaan"),
});
export async function createNote(formData: FormData) {
const result = noteSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
});
if (!result.success) {
return { error: result.error.flatten().fieldErrors };
}
await db.insert("notes", {
...result.data,
createdAt: new Date().toISOString(),
});
revalidatePath("/notes");
return { error: null };
}
export async function deleteNote(noteId: string) {
await db.delete("notes", noteId);
revalidatePath("/notes");
}
Tämä esimerkki yhdistää kaikki keskeiset suunnittelumallit käytännössä. Server Components hakee dataa suoraan tietokannasta, Suspense mahdollistaa progressiivisen lataamisen, Server Actions hoitaa mutaatiot tyyppiturvallisesti, ja revalidatePath pitää käyttöliittymän ajan tasalla.
Yhteenveto
React Server Components ja Server Actions ovat muuttaneet Next.js-kehityksen perustavanlaatuisesti. Ne eivät ole pelkästään uusia API:ja — ne edustavat kokonaan uutta tapaa ajatella full-stack-sovellusten rakentamista. Ja rehellisesti sanottuna, kun nämä mallit naksahtavat kohdalleen, kehittäjäkokemus on todella hyvä.
Tärkeimmät opit tästä artikkelista:
- Server Components oletuksena — lisää
"use client"vain kun tarvitset interaktiivisuutta - Children Pattern — välitä Server Componenteja Client Componentien lapsina
- Rinnakkainen datan hakeminen — käytä
Promise.all()vesiputouksien välttämiseen - Suspense-rajat — jaa sivu itsenäisiin latausalueisiin
- PPR — yhdistä staattinen ja dynaaminen renderöinti samalla sivulla
- Server Actions mutaatioihin — ei datan hakemiseen
- Tyyppiturvallinen validointi — Zod + next-safe-action tuotantoympäristöissä
- Turvallisuus ensin — autentikaatio ja auktorisointi jokaisessa Server Actionissa
Näiden suunnittelumallien hallitseminen tekee sinusta tehokkaamman Next.js-kehittäjän ja auttaa rakentamaan sovelluksia, jotka ovat sekä nopeita käyttäjille että ylläpidettäviä kehittäjille. Toivottavasti tämä artikkeli antoi sinulle konkreettisia työkaluja omiin projekteihin.