Next.js data fetching & Server Actions
Fetch data directly in an async Server Component
In the App Router you fetch where you render. Mark the component async and await a fetch call or a database query in its body. No useEffect, no useState, no separate API route.
// app/posts/page.tsx - a Server Component
export default async function Page() {
const posts = await db.post.findMany() // runs on the server only
return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
}
The query runs on the server, so database credentials and query logic stay out of the browser bundle, and the HTML arrives with the data in it. Reach for a client useEffect fetch only when the data is user-driven after load (polling, interaction) and an await on the server would not cover it.
Server Actions run the mutation
A Server Action is an async function tagged with the 'use server' directive. Wire it to a form and it receives the submitted FormData.
// actions.ts
'use server'
export async function createPost(formData: FormData) {
const title = formData.get('title')
await db.post.create({ data: { title } })
}
<form action={createPost}>
<input name="title" />
<button>Save</button>
</form>
The form posts to the action, so it submits before JavaScript hydrates (progressive enhancement). Under the hood the action is a POST request; the function body stays on the server and the client holds only a reference to it.
Track pending and errors with useActionState
useActionState (from react) returns the result state, a form action, and a pending flag. It also shifts the action signature: the previous state becomes the first argument and FormData moves to second.
'use client'
import { useActionState } from 'react'
// action: async (prevState, formData) => ({ message: '...' })
const [state, formAction, pending] = useActionState(createUser, { message: '' })
Whatever the action returns becomes the next state, which is how you send server-side validation errors back to the form. A sibling hook, useFormStatus (from react-dom), also exposes pending, with one placement rule.
Wrong call useFormStatus in the same component that renders the <form>. It reports the nearest parent form, so pending stays false.
Right put it in a child rendered inside the form (a <SubmitButton>), which reads that form's status.
Refresh cached data after a mutation
A mutation changes the database, not the cache. Call revalidatePath or revalidateTag (from next/cache) inside the action so the affected route re-renders; skip it and the screen keeps the stale value until the next full reload.
Parallel requests, or a hidden waterfall
Two awaits in a row run in sequence even when the requests are independent:
const user = await getUser(id) // finishes first
const posts = await getPosts(id) // only starts once user resolves
Start both before awaiting, then join them:
const userP = getUser(id)
const postsP = getPosts(id)
const [user, posts] = await Promise.all([userP, postsP])
Promise.all rejects as a whole if any request fails; use Promise.allSettled when a partial result is acceptable. Separate route segments already render in parallel, so the waterfall worth hunting is the one inside a single component.
fetch is not cached by default in Next 15
This is the version trap. In Next 14 and earlier, an App Router fetch defaulted to force-cache; in Next 15 a bare fetch is uncached. Opt in per request:
fetch(url, { cache: 'force-cache' }) // persist across requests
fetch(url, { next: { revalidate: 3600 } }) // refresh at most hourly
fetch(url, { next: { tags: ['posts'] } }) // tag for on-demand invalidation
A candidate carrying the Next 14 mental model will expect fetched data to persist across requests when it no longer does. Within one render pass, identical fetch calls are still deduped; wrap a non-fetch db call in React.cache for the same per-request memoization.
Schema validation is not authorization
A zod schema proves the request is well-formed, not that the caller may touch the row. Take only an id from the client, derive identity from the session, and look the row up by ownership from a trusted source:
'use server'
export async function completePost(postId: string) {
const session = await auth()
if (!session?.user) return
const post = await db.post.findFirst({
where: { id: postId, ownerId: session.user.id }, // ownership check
})
if (!post) return
await db.post.update({ where: { id: post.id }, data: { done: true } })
}
Arguments bound with action.bind(null, x) are encrypted, but a hidden <input> sits in the HTML and is tamperable, so re-validate everything from the client server-side regardless.
revalidate scope, redirect, and one action at a time
revalidatePath(path) invalidates one route by its file path; revalidateTag(tag) invalidates every cached entry carrying that tag, across routes. Called inside an action, both re-render the current route in the same response; for other paths the refresh lands on the next visit.
redirect() (from next/navigation) throws a control-flow exception, so anything after it is skipped: put revalidatePath before redirect. The client also dispatches Server Actions one at a time per client, so a Promise.all over several action calls still runs them one after another; do the parallel work inside a single action.
Next 16 splits revalidateTag into a two-argument form and adds updateTag for read-your-own-writes; on the Next 15 baseline the tag helper takes a single argument.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.