Authorization and the data access layer
Check access next to the data in Next.js: a data access layer with server-only and React cache, DTOs instead of rows, ownership checks, and proxy.ts as a hint.
- 01Check at the dataExplain why a check in a layout or in proxy.ts can be skipped, and put the real check where every page, Route Handler and Server Action reads the data.
- 02Build a data access layerWrite a server-only module that finds the signed-in user once per render, checks ownership and roles, and returns only the fields pages show.
- 03Stop one user reading another's dataSpot an order id someone changed, answer 401, 403 or 404 on purpose, and keep per-user data out of shared caches.
A staff page anyone could read
The tea shop from Authentication knows who is signed in. Now it has staff. Ravi works in the shop, and /staff/orders lists every order with the customer's address, for packing. Asha and Ben are customers: each may see their own orders, and nobody else's.
Deciding what a signed-in person may read or change is authorization. Authentication answers "who is this?"; authorization answers "may this person do this?". Here is the shop's first try, a real Next.js 16.4.3 project: one check, in the layout of the app/staff folder. The recorder built it, ran it, sent it three requests, then signed in as Ravi in real Chrome:
app/staff/layout.tsx1import { redirect } from "next/navigation";2import { readSession } from "@/lib/session";3import { findUserById } from "@/lib/users";4 5export const instant = false;6 7export default async function StaffLayout({ children }: LayoutProps<"/staff">) {8 const session = await readSession();9 const user = session ? await findUserById(session.userId) : undefined;10 console.log(`staff layout: checked ${user?.name ?? "nobody"}`);11 if (user?.role !== "staff") redirect("/");12 return (13 <main>14 <h1>Staff area</h1>15 {children}16 </main>17 );18}GET /staff/orders/export200
- Response headers
varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetchcontent-typetext/csv
The body that arrived, as it arrived
id,tea,boxes,address
1,Mint tea,2,12 Lake Road
2,Green tea,3,12 Lake Road
3,Smoky lapsang,2,4 Hill Streetapp/staff/layout.tsx, the file shown first, looks safe. Line 8 reads the session, line 9 finds the user, and line 11 sends anyone who isn't staff to the home page. Line 10 prints a line on the server each time the layout runs, so the recording shows when it did.
Now look at the Network tab. It opens on GET /staff/orders/export, sent with no cookie. The answer is 200, and its body is a spreadsheet of every order: 12 Lake Road, 4 Hill Street. The request below it, with Asha's cookie, got the same. The layout's check never ran for either, because a layout isn't in front of everything in its folder.
/staff, a Route Handler, and a Server Action. A layout's check stands in front of one of them. A check in lib/dal.ts stands in front of the data, so every way in passes it.A hotel's front desk greets guests in the lobby. But guests also come in through the car park, the side doors and the lift. So the hotel doesn't rely on the desk: every room door reads the guest's key card, and each card opens one room.
- In real life: The front desk checks guests as they come in through the lobby
- In Next.js: A check in a layout, for pages under it
- In real life: Side doors, the lift from the car park, room service
- In Next.js: Route Handlers, the router's requests, Server Actions
- In real life: Every room door reads the key card itself
- In Next.js: The data access layer checks before it returns any data
- In real life: A card opens only that guest's room
- In Next.js: getOrder returns an order only to its owner or to staff
Check access where the data is read, not only where a page starts. Put every read and change of private data in a server-only data access layer that finds the signed-in user, checks what they may do, and returns only the fields the page needs. Then every page, Route Handler and Server Action gets the same check, however the request arrives.
Why a check in a layout isn't enough
A layout wraps the pages under it (Layouts and pages). That's about what they look like. It doesn't make the layout a gate that every request passes. Each row is one way into the staff orders, and whether the staff layout ran for it in the recording:
| Way in | Does the staff layout run? | What the recording shows |
|---|---|---|
Opening /staff from outside it | Yes. The staff layout isn't on the screen yet, so it renders with the page. | Ravi's click on Staff in the Steps tab printed staff layout: checked Ravi. |
A click on a link inside /staff | No. The router asks only for what changed below the layout it already shows. | The request with no cookie got every order and address, and the layout printed nothing. |
GET /staff/orders/export, a Route Handler | No. Layouts wrap pages, not Route Handlers. | 200 and the whole spreadsheet, with no cookie and with Asha's. |
| A Server Action | No. It's a POST that runs the action's own code. | The cancel action below changed Ben's order from Asha's page. |
Route Handlers aren't under any layout
1import { allOrders } from "@/lib/db";2 3// Staff download every order as a spreadsheet.4export async function GET() {5 const orders = await allOrders();6 const rows = orders.map((order) => `${order.id},${order.tea},${order.boxes},${order.address}`);7 return new Response(["id,tea,boxes,address", ...rows].join("\n"), {8 headers: { "content-type": "text/csv" },9 });10}11 A Route Handler answers a request on its own. Line 5 reads every order, and lines 7 to 9 send them. Nothing here, and nothing around it, asks who is asking. Its folder is under app/staff only on disk.
A click inside /staff doesn't run the layout again
Open the example's Steps tab. The recorder signed in as Ravi through the form, clicked Staff, then All orders. Pick step 6. Its request is GET /staff/orders?_rsc=… with three headers from the router (Linking and navigating): rsc: 1 asks for an RSC payload, the data React uses to draw the Server Components' result, instead of HTML; next-router-state-tree lists what is already on the screen, the root layout and the staff layout; and next-url names the page the click came from. The server renders only what changed below those layouts: the orders page.
Next.js's docs say it plainly: layouts don't render again on a client-side navigation, so a check there isn't run on every route change. The Terminal tab shows it. Under $ next start, Chrome's visit printed staff layout: checked Ravi while it loaded /staff. The click on All orders printed only orders page: listed 3 orders, the last line, with no layout line after Ravi's.
Those headers are only text, and anyone can send them. The first request in the Network tab copied the ones Chrome sent in step 6, without a cookie. The answer was 200, an RSC payload holding every order and address. The first line the server printed was orders page: listed 3 orders, with no staff layout line before it: the layout never ran.
Server Actions are POSTs to any page
A Server Action runs when a POST names it, whatever page it's sent to. The layout can't stand in front of it either. Securing Server Actions calls one from outside the shop's pages.
A layout that returns null or a different part for some visitors changes what shows on the screen. It doesn't stop the pages and slots under it from running, or their results from being sent: Parallel routes found the staff dashboard's sales in the payload of a member of staff who couldn't see them.
A data access layer
The fix is to check where the data is read. Next.js's docs recommend a data access layer (DAL) for new projects: one module that every read and change of private data goes through. It has three jobs:
- It only runs on the server.
- It checks who is asking and what they may do.
- It returns only the fields the page needs.
Here is the tea shop's, in lib/dal.ts:
1import "server-only";2import { cache } from "react";3import { redirect } from "next/navigation";4import { allOrders, findOrder, setStatus } from "./db";5import { readSession } from "./session";6import { findUserById } from "./users";7 8// The signed-in user, as in the Authentication lesson, now with a role. React's cache()9// runs it once per render, however many components and functions ask.10export const getCurrentUser = cache(async () => {11 const session = await readSession();12 if (!session) return null;13 const user = await findUserById(session.userId);14 return user ? { id: user.id, name: user.name, role: user.role } : null;15});16 17// For pages: anyone without a valid session goes to the sign-in page.18export async function verifySession() {19 const user = await getCurrentUser();20 if (!user) redirect("/sign-in");21 return user;22}23 24// An order, for its owner or for staff, with only the fields pages show.25export async function getOrder(id: number) {26 const user = await getCurrentUser();27 const order = await findOrder(id);28 if (!user || !order) return null;29 if (order.userId !== user.id && user.role !== "staff") return null;30 return { id: order.id, tea: order.tea, boxes: order.boxes, status: order.status };31}32 33// Every order with its address: staff only.34export async function getAllOrders() {35 const user = await getCurrentUser();36 if (user?.role !== "staff") return null;37 const orders = await allOrders();38 return orders.map(({ id, tea, boxes, address }) => ({ id, tea, boxes, address }));39}40 41export async function cancelOrder(id: number) {42 const order = await getOrder(id);43 if (!order) return "There's no such order.";44 if (order.status !== "paid") return "Only a paid order can be cancelled.";45 await setStatus(id, "cancelled");46 return `Order ${id} is cancelled.`;47}48 - Line 1,
import "server-only", makes the build fail if a Client Component imports this file (Server-only code). The checks and the database code never reach the browser. - Lines 10 to 15 are
getCurrentUser, from Authentication, now with the user'srole. Line 11 reads and checks the signed session cookie. Line 14 returns three fields, never the email or the password hash. - Line 10 wraps it in React's
cache(). While Next.js renders a page, the first call runs the function and later calls in the same render get the same answer, so the page, the layout andgetOrdercan each ask without reading the cookie again. - Lines 18 to 22 are
verifySession, the name the docs use: for pages, anyone without a valid session goes to/sign-in(line 20). - Lines 25 to 31 are
getOrder. Line 28 refuses when nobody is signed in or there's no such order. Line 29 refuses when the order isn't the user's and they aren't staff. - Lines 34 to 39 are
getAllOrders: line 36 lets only staff through. Lines 41 to 47,cancelOrder, change an order only aftergetOrdersaid yes.
cache() shares results only within one React render pass. Server Actions and Route Handlers aren't render passes, the docs say, so there every call runs the function again. That costs a little time; it never skips a check.
The data it reads comes from lib/db.ts, a stand-in for the orders table, read for each request:
1import "server-only";2import { connection } from "next/server";3import { findUserById } from "./users";4 5// A stand-in for the shop's orders table, read for each request.6// The address and the note are for staff only.7const orders = [8 { id: 1, userId: "u1", tea: "Mint tea", boxes: 2, status: "paid", address: "12 Lake Road", note: "Leave at the door" },9 { id: 2, userId: "u1", tea: "Green tea", boxes: 3, status: "shipped", address: "12 Lake Road", note: "" },10 { id: 3, userId: "u2", tea: "Smoky lapsang", boxes: 2, status: "paid", address: "4 Hill Street", note: "Refunded once before" },11];12 13// One order with its customer's whole row, the way an ORM's "include" returns it.14export async function findOrder(id: number) {15 await connection();16 const order = orders.find((row) => row.id === id);17 return order ? { ...order, customer: await findUserById(order.userId) } : undefined;18}19 20export async function allOrders() {21 await connection();22 return orders;23}24 25export async function setStatus(id: number, status: string) {26 const order = orders.find((row) => row.id === id);27 if (order) order.status = status;28}29 Each row on lines 8 to 10 has an address and a note for staff. Line 17 returns the order with its customer's whole row inside, the way many database libraries do when you ask them to include a related row. Line 15, connection(), makes each read wait for a request, like a real query (Request-time APIs). Pages don't import this file any more: only lib/dal.ts does.
Now the staff page and the spreadsheet read through the data access layer. Compare both versions:
app/staff/orders/page.tsx1import { allOrders } from "@/lib/db";2 3export default async function AllOrders() {4 const orders = await allOrders();5 console.log(`orders page: listed ${orders.length} orders`);6 return (7 <ul>8 {orders.map((order) => (9 <li key={order.id}>10 Order {order.id}: {order.boxes} boxes of {order.tea} to {order.address}11 </li>12 ))}13 </ul>14 );15}GET /staff/orders?_rsc=BXoFdUEmaxIUn4vq200
- Sent with the request
rsc1next-router-state-tree%5B%22%22%2C%7B%22children%22%3A%5B%22staff%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C162%5D%7D%2Cnull%2Cnull%2C8290%5D%7D%2Cnull%2Cnull%2C8274%5Dnext-url/staff
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-EncodingCache-Controlprivate, no-cache, no-store, max-age=0, must-revalidateContent-Typetext/x-component
The body that arrived, as it arrived: the RSC payload, one row per line
4:"$Sreact.fragment"
5:"/_next/static/chunks/3oh00mkbkq8u6.js"
6:I[39756,["$5"],"default"]
7:I[37457,["$5"],"default"]
b:I[97367,["$5"],"OutletBoundary"]
c:"$Sreact.suspense"
f:"ViewportBoundary"
10:I[97367,["$5"],"$f"]
12:"MetadataBoundary"
13:I[97367,["$5"],"$12"]
1:X
8:X
e:X
9:[["children",{"s":"__PAGE__","h":162,"d":{"r":["$","$4","c",{"children":["$La",null,["$","$Lb",null,{"children":["$","$c",null,{"name":"Next.MetadataOutlet","children":"$@d"}]}]]}],"p":false,"v":"$e"}}]]
3:[["children",{"s":"orders","h":98,"d":{"r":["$","$4","c",{"children":[null,["$","$L6",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L7",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],"p":false,"v":"$8"},"c":"$Q9"}]]
2:[["children",{"s":"staff","h":8290,"d":{"r":null,"p":true,"v":null},"c":"$Q3"}]]
15:X
16:X
19:R
0:{"q":"","i":false,"S":true,"r":"$1","b":"i9wf2_k5bWD6kDMKtyelX","t":{"t":{"s":"","h":82,"d":{"r":null,"p":true,"v":null},"c":"$Q2"},"h":{"r":["$","$4","h",{"children":[null,["$","$L10","gssu-HCo8ezewPNj9xX9Rv",{"children":"$L11"}],["$","$L13","gssu-HCo8ezewPNj9xX9Rm",{"children":[["$","div",null,{"hidden":true,"children":["$","$c",null,{"name":"Next.Metadata","children":"$L14"}]}],null]}]]}],"p":false,"v":"$15"}},"s":"$16","l":"$@17","a":"$@18","p":"$19"}
8:C
16:300
11:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
d:null
14:[["$","title","0",{"children":"Tea shop"}]]
16:C
15:C
1:C
e:C
a:["$","ul",null,{"children":[["$","li","1",{"children":["Order ",1,": ",2," boxes of ","Mint tea"," to ","12 Lake Road"]}],["$","li","2",{"children":["Order ",2,": ",3," boxes of ","Green tea"," to ","12 Lake Road"]}],["$","li","3",{"children":["Order ",3,": ",2," boxes of ","Smoky lapsang"," to ","4 Hill Street"]}]]}]
17:1663
18:1645
19:o1,~19:o59e,4:"$Sreact.fragment"
5:"/_next/static/chunks/3oh00mkbkq8u6.js"
6:I[39756,["$5"],"default"]
7:I[37457,["$5"],"default"]
b:I[97367,["$5"],"OutletBoundary"]
c:"$Sreact.suspense"
f:"ViewportBoundary"
10:I[97367,["$5"],"$f"]
12:"MetadataBoundary"
13:I[97367,["$5"],"$12"]
1:X
8:X
e:X
9:[["children",{"s":"__PAGE__","h":162,"d":{"r":["$","$4","c",{"children":["$La",null,["$","$Lb",null,{"children":["$","$c",null,{"name":"Next.MetadataOutlet","children":"$@d"}]}]]}],"p":false,"v":"$e"}}]]
3:[["children",{"s":"orders","h":98,"d":{"r":["$","$4","c",{"children":[null,["$","$L6",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L7",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],"p":false,"v":"$8"},"c":"$Q9"}]]
2:[["children",{"s":"staff","h":8290,"d":{"r":null,"p":true,"v":null},"c":"$Q3"}]]
15:X
16:X
0:{"q":"","i":false,"S":true,"r":"$1","b":"i9wf2_k5bWD6kDMKtyelX","t":{"t":{"s":"","h":82,"d":{"r":null,"p":true,"v":null},"c":"$Q2"},"h":{"r":["$","$4","h",{"children":[null,["$","$L10","gssu-HCo8ezewPNj9xX9Rv",{"children":"$L11"}],["$","$L13","gssu-HCo8ezewPNj9xX9Rm",{"children":[["$","div",null,{"hidden":true,"children":["$","$c",null,{"name":"Next.Metadata","children":"$L14"}]}],null]}]]}],"p":false,"v":"$15"}},"s":"$16","a":"$@17","w":"$@18"}
19:o8a,8:C
16:300
11:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
19:o36,d:null
14:[["$","title","0",{"children":"Tea shop"}]]
19:o9,15:0
e:0
19:o11,18:false
17:1630
19:o12,16:C
15:C
1:C
e:C
19:C
Line 4 reads every order straight from lib/db.ts and trusts the layout above it. The request with no cookie skipped the layout: its payload holds 12 Lake Road and 4 Hill Street. The spreadsheet answered 200 with no cookie and with Asha's.
1import { getAllOrders, getCurrentUser } from "@/lib/dal";2 3// Staff download every order as a spreadsheet.4export async function GET() {5 if (!(await getCurrentUser())) return Response.json({ error: "Sign in first." }, { status: 401 });6 const orders = await getAllOrders();7 if (!orders) return Response.json({ error: "Staff only." }, { status: 403 });8 const rows = orders.map((order) => `${order.id},${order.tea},${order.boxes},${order.address}`);9 return new Response(["id,tea,boxes,address", ...rows].join("\n"), {10 headers: { "content-type": "text/csv" },11 });12}13 Line 5 answers 401 when nobody is signed in, and line 7 answers 403 when the user isn't staff. The layout still redirects customers who open /staff, which is a kindness: they don't see a page they can't use. It just isn't what keeps the addresses private any more.
Returning DTOs, not rows
Line 30 of lib/dal.ts builds a new object with four fields, instead of returning the row. An object made only to carry data out of a layer like this is called a DTO, a data transfer object. It matters because whatever a Server Component passes to a Client Component is sent to the browser, whole (Composing Server and Client Components).
The order page passes the order to CancelButton, a Client Component that needs only id and status. Here are two versions of the page. The recorder asked for each one's RSC payload with Asha's cookie:
app/orders/[id]/page.tsx1import { notFound, redirect } from "next/navigation";2import { findOrder } from "@/lib/db";3import { readSession } from "@/lib/session";4import CancelButton from "./cancel-button";5 6export const instant = false;7 8export default async function OrderPage({ params }: PageProps<"/orders/[id]">) {9 if (!(await readSession())) redirect("/sign-in");10 const { id } = await params;11 const order = await findOrder(Number(id));12 if (!order) notFound();13 return (14 <main>15 <h1>Order {order.id}</h1>16 <p>17 {order.boxes} boxes of {order.tea}: {order.status}18 </p>19 <CancelButton order={order} />20 </main>21 );22}GET /orders/1?_rsc=200
- Sent with the request
rsc1cookiesession=eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJ1MSIsImlhdCI6MTc5MzMxODQwMCwiZXhwIjo0MTAyNDQ0ODAwfQ.sY9GVxj1-v3mOJvS4ev9s8MSmkxEorTL7e01C0TNkVQ
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-EncodingCache-Controlprivate, no-cache, no-store, max-age=0, must-revalidateContent-Typetext/x-component
The body that arrived, as it arrived: the RSC payload, one row per line
2:"$Sreact.fragment"
3:"/_next/static/chunks/3oh00mkbkq8u6.js"
4:I[22016,["$3"],""]
5:I[39756,["$3"],"default"]
6:I[37457,["$3"],"default"]
d:I[97367,["$3"],"OutletBoundary"]
e:"$Sreact.suspense"
11:"ViewportBoundary"
12:I[97367,["$3"],"$11"]
14:"MetadataBoundary"
15:I[97367,["$3"],"$14"]
1:X
7:X
9:X
10:X
b:[["children",{"s":"__PAGE__","h":8354,"d":{"r":["$","$2","c",{"children":["$Lc",[["$","script","script-0",{"src":"/_next/static/chunks/14srrq34o-p77.js","async":true,"nonce":"$undefined"}]],["$","$Ld",null,{"children":["$","$e",null,{"name":"Next.MetadataOutlet","children":"$@f"}]}]]}],"p":false,"v":"$10"}}]]
a:[["children",{"s":{"n":"id","t":"d","k":"1","s":[]},"h":8290,"d":{"r":["$","$2","c",{"children":[null,["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],"p":false,"v":"$9"},"c":"$Qb"}]]
8:[["children",{"s":"orders","h":8290,"d":{"r":["$","$2","c",{"children":[null,["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],"p":false,"v":"$9"},"c":"$Qa"}]]
17:X
18:X
1b:R
0:{"q":"","i":false,"S":true,"r":"$1","b":"B3VDgkZk__p9Lx5Qx9Sii","t":{"t":{"s":"","h":8274,"d":{"r":["$","$2","c",{"children":[[["$","script","script-0",{"src":"/_next/static/chunks/3oh00mkbkq8u6.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":[["$","nav",null,{"children":["$","$L4",null,{"href":"/","children":"Teas"}]}],["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]]}]}]]}],"p":false,"v":"$7"},"c":"$Q8"},"h":{"r":["$","$2","h",{"children":[null,["$","$L12","B9OweowaTt7JOU9qkwS93v",{"children":"$L13"}],["$","$L15","B9OweowaTt7JOU9qkwS93m",{"children":[["$","div",null,{"hidden":true,"children":["$","$e",null,{"name":"Next.Metadata","children":"$L16"}]}],null]}]]}],"p":false,"v":"$17"}},"s":"$18","l":"$@19","a":"$@1a","p":"$1b"}
9:C
18:300
13:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
f:null
16:[["$","title","0",{"children":"Tea shop"}]]
18:C
17:C
1:C
10:C
7:C
19:3814
1a:3791
1c:"/_next/static/chunks/14srrq34o-p77.js"
1d:I[47060,["$3","$1c"],"default"]
c:["$","main",null,{"children":[["$","h1",null,{"children":["Order ",1]}],["$","p",null,{"children":[2," boxes of ","Mint tea",": ","paid"]}],["$","$L1d",null,{"order":{"id":1,"userId":"u1","tea":"Mint tea","boxes":2,"status":"paid","address":"12 Lake Road","note":"Leave at the door","customer":{"id":"u1","name":"Asha","email":"asha@example.com","role":"customer","passwordHash":"scrypt$131072$8$1$5f1c2a9e7b3d4c6a8e0f1b2c3d4e5f60$19c2186aa17ba6b6430ef7f372557e29732a232d183f6281589e638c830fa2202aad452eb0f9e265fcfd9b4dfba4c4a0b0a53a7b26e1c3112b61f11ceede0da5"}}}]]}]
1b:o1,~1b:o5c3,2:"$Sreact.fragment"
3:"/_next/static/chunks/3oh00mkbkq8u6.js"
4:I[22016,["$3"],""]
5:I[39756,["$3"],"default"]
6:I[37457,["$3"],"default"]
d:I[97367,["$3"],"OutletBoundary"]
e:"$Sreact.suspense"
11:"ViewportBoundary"
12:I[97367,["$3"],"$11"]
14:"MetadataBoundary"
15:I[97367,["$3"],"$14"]
1:X
7:X
9:X
10:X
b:[["children",{"s":"__PAGE__","h":8354,"d":{"r":["$","$2","c",{"children":["$Lc",[["$","script","script-0",{"src":"/_next/static/chunks/14srrq34o-p77.js","async":true,"nonce":"$undefined"}]],["$","$Ld",null,{"children":["$","$e",null,{"name":"Next.MetadataOutlet","children":"$@f"}]}]]}],"p":false,"v":"$10"}}]]
a:[["children",{"s":{"n":"id","t":"d","k":"1","s":[]},"h":8290,"d":{"r":["$","$2","c",{"children":[null,["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],"p":false,"v":"$9"},"c":"$Qb"}]]
8:[["children",{"s":"orders","h":8290,"d":{"r":["$","$2","c",{"children":[null,["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":"$undefined","forbidden":"$undefined","unauthorized":"$undefined"}]]}],"p":false,"v":"$9"},"c":"$Qa"}]]
17:X
18:X
1b:o83d,0:{"q":"","i":false,"S":true,"r":"$1","b":"B3VDgkZk__p9Lx5Qx9Sii","t":{"t":{"s":"","h":8274,"d":{"r":["$","$2","c",{"children":[[["$","script","script-0",{"src":"/_next/static/chunks/3oh00mkbkq8u6.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":[["$","nav",null,{"children":["$","$L4",null,{"href":"/","children":"Teas"}]}],["$","$L5",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L6",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]]}]}]]}],"p":false,"v":"$7"},"c":"$Q8"},"h":{"r":["$","$2","h",{"children":[null,["$","$L12","B9OweowaTt7JOU9qkwS93v",{"children":"$L13"}],["$","$L15","B9OweowaTt7JOU9qkwS93m",{"children":[["$","div",null,{"hidden":true,"children":["$","$e",null,{"name":"Next.Metadata","children":"$L16"}]}],null]}]]}],"p":false,"v":"$17"}},"s":"$18","a":"$@19","w":"$@1a"}
1b:o8a,9:C
18:300
13:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
1b:o36,f:null
16:[["$","title","0",{"children":"Tea shop"}]]
1b:oa,17:0
10:0
1b:o11,1a:false
19:3776
1b:o17,18:C
17:C
1:C
10:C
7:C
1b:C
Line 11 gets the whole row from findOrder, the customer's row included, and line 19 passes it as order. The button uses only id and status, but the payload carries 12 Lake Road, the note, Asha's email and her passwordHash.
Both pages look the same on the screen. The first page's payload also carries 12 Lake Road, Leave at the door, asha@example.com and "passwordHash":"scrypt$131072…", because findOrder included the customer's row. Here it's Asha's own data, but a password hash should never leave the server, and the same page sends the row of any order it shows. The second page's payload holds the DTO's four fields and nothing else.
findOrder returns the whole row, with the customer's row inside. getOrder copies four fields into a new object, a DTO. Only that object reaches CancelButton, and so the browser.Choosing fields in one place, in the DAL, means a new column added to the table later stays on the server until someone adds it to a DTO on purpose. The Authentication lesson did the same for users: only id and name.
Someone else's order
Order numbers are in the address: /orders/1 is Asha's. What if she types /orders/3? Reading someone else's data by changing an id in a request is common enough to have a name: an insecure direct object reference (IDOR). The id itself is fine to accept from the visitor. What's missing is the check that this visitor may have that object.
1import { notFound, redirect } from "next/navigation";2import { findOrder } from "@/lib/db";3import { readSession } from "@/lib/session";4import CancelButton from "./cancel-button";5 6export const instant = false;7 8export default async function OrderPage({ params }: PageProps<"/orders/[id]">) {9 if (!(await readSession())) redirect("/sign-in");10 const { id } = await params;11 const order = await findOrder(Number(id));12 if (!order) notFound();13 return (14 <main>15 <h1>Order {order.id}</h1>16 <p>17 {order.boxes} boxes of {order.tea}: {order.status}18 </p>19 <CancelButton order={order} />20 </main>21 );22}23 Line 9 checks that someone is signed in. Line 11 finds the order the address names. Nothing compares the two. Here it is in real Chrome, next to the version that asks getOrder:
app/orders/[id]/page.tsx1import { notFound, redirect } from "next/navigation";2import { findOrder } from "@/lib/db";3import { readSession } from "@/lib/session";4import CancelButton from "./cancel-button";5 6export const instant = false;7 8export default async function OrderPage({ params }: PageProps<"/orders/[id]">) {9 if (!(await readSession())) redirect("/sign-in");10 const { id } = await params;11 const order = await findOrder(Number(id));12 if (!order) notFound();13 return (14 <main>15 <h1>Order {order.id}</h1>16 <p>17 {order.boxes} boxes of {order.tea}: {order.status}18 </p>19 <CancelButton order={order} />20 </main>21 );22}Each step was taken in real Chrome against next start. Pick one to see the page after it.
2 boxes of Smoky lapsang: cancelled
- Focus
- nothing: the page itself
- Scrolled
- 0 px down
Requests the page made during this step (3), without the files it loaded
GET/orders/3200a whole pageGET/200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /orders/3
GET/200text/x-component- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /__PAGE__
- next-url
- /orders/3
Line 9 only checks that someone is signed in, and line 11 finds any order by its number. Chrome shows Ben's order to Asha: 2 boxes of Smoky lapsang, already cancelled by the changed form in the recording's requests.
In the second version, Chrome shows the not-found page. Its status was 200, because the page had started streaming before notFound() ran (Not found, forbidden and unauthorized explains why). A Route Handler can answer the real status. The order route asks getCurrentUser and getOrder:
1import { getOrder, getCurrentUser } from "@/lib/dal";2 3export async function GET(request: Request, { params }: RouteContext<"/api/orders/[id]">) {4 if (!(await getCurrentUser())) return Response.json({ error: "Sign in first." }, { status: 401 });5 const { id } = await params;6 const order = await getOrder(Number(id));7 if (!order) return Response.json({ error: "No such order." }, { status: 404 });8 return Response.json(order);9}10 Line 4 answers 401 when nobody is signed in. Line 6 asks getOrder, and line 7 answers 404 when it says no. The recordings sent it three requests for order 3:
/api/orders/3, three answers. The route asks getCurrentUser who is asking, and getOrder whether that person owns order 3 or is staff.The version that only checked for a session sent Asha all of Ben's row instead: his address, the note Refunded once before, his email and his password hash. Which code to answer when it says no is a choice. Each row is one code:
| Status | What it says | Use it when |
|---|---|---|
401 Unauthorized | Nobody is signed in. Sign in first. | There's no valid session. |
403 Forbidden | You're signed in, but this isn't for you. | The thing exists and everyone may know it, like the staff spreadsheet. |
404 Not Found | There's nothing here for you. | Saying it exists would tell people something, like which order numbers belong to someone. |
For an order, 404 is the safer answer: with 403, someone trying numbers would learn which ones exist. Pages can call forbidden() and unauthorized() for 403 and 401, which are still experimental in Next.js 16.4.3 (Not found, forbidden and unauthorized).
A check in every Server Action and Route Handler
The order page shows a Cancel order button, in a form with a hidden id field. Here is a first version of its action:
1"use server";2 3import { setStatus } from "@/lib/db";4 5// The page shows this button only on the visitor's own order, so no check here.6export async function cancel(previous: string, formData: FormData) {7 await setStatus(Number(formData.get("id")), "cancelled");8 return "Your order is cancelled.";9}10 Line 5 is the reasoning behind it: the button only shows on your own order. But the form is only HTML, and a hidden field is a field like any other. The recorder opened Asha's own order, /orders/1, with her cookie, changed the hidden id to 3, and posted the form, the way a browser sends it before the page's JavaScript has loaded. Then it opened order 3 with Ravi's cookie:
app/orders/[id]/actions.ts1"use server";2 3import { setStatus } from "@/lib/db";4 5// The page shows this button only on the visitor's own order, so no check here.6export async function cancel(previous: string, formData: FormData) {7 await setStatus(Number(formData.get("id")), "cancelled");8 return "Your order is cancelled.";9}2 boxes of Smoky lapsang: cancelled
Sent with cookie: session=eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJ1MyIsImlhdCI6MTc5MzMxODQwMCwiZXhwIjo0MTAyNDQ0ODAwfQ.3aFaYWiIbytBID0dmkkjpN4mgpaQkR6Mkq5PqxEDRQQ. The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.
Line 7 cancels whatever id the form sends. The form posted from Asha's own order said 3, so Ravi now sees Ben's order as cancelled.
1"use server";2 3import { cancelOrder } from "@/lib/dal";4 5export async function cancel(previous: string, formData: FormData) {6 return cancelOrder(Number(formData.get("id")));7}8 Line 6 passes the id to cancelOrder, whose getOrder checks the owner. The action doesn't need to know the rules: they live in one place. The page's check and the action's check are separate, because the page and the action are separate ways in. The docs say it for both: treat every Server Action and every Route Handler like a public API, and check inside each one.
Take the what from the request (an order id, a number of boxes) and the who from the session, never from a form field. Multi-tenant apps did the same for the shop: from the server's lookup, never from a hidden field. An app that serves many shops checks both the shop and the user.
Optimistic checks in proxy.ts
proxy.ts runs before every request its matcher lets in. The docs suggest using it for optimistic checks: quick ones that read only the cookie, to redirect early. The order pages have one:
1import { NextResponse, type NextRequest } from "next/server";2 3// Only looks for the cookie. Pages and routes check the session itself.4export function proxy(request: NextRequest) {5 if (!request.cookies.has("session")) {6 const signIn = new URL("/sign-in", request.url);7 signIn.searchParams.set("from", request.nextUrl.pathname);8 return NextResponse.redirect(signIn);9 }10}11 12export const config = { matcher: ["/orders/:path*"] };13 Line 5 asks only whether a cookie named session exists. If not, lines 6 to 8 redirect to /sign-in, with the page the visitor wanted in ?from=, so a sign-in form could send them back. It never checks the signature: proxy.ts runs for prefetches too, and the docs say to keep it to reading the cookie, with no database lookups.
proxy.ts1import { NextResponse, type NextRequest } from "next/server";2 3// Only looks for the cookie. Pages and routes check the session itself.4export function proxy(request: NextRequest) {5 if (!request.cookies.has("session")) {6 const signIn = new URL("/sign-in", request.url);7 signIn.searchParams.set("from", request.nextUrl.pathname);8 return NextResponse.redirect(signIn);9 }10}11 12export const config = { matcher: ["/orders/:path*"] };GET /orders/1307
- Response headers
location/sign-in?from=%2Forders%2F1
No HTML arrived: the body is only the new address, as plain text
/sign-in?from=%2Forders%2F1The Network tab opens on the last two requests. /orders/1 with no cookie got 307 with location: /sign-in?from=%2Forders%2F1, from proxy.ts. The same page with session=let-me-in, a cookie nobody signed, passed proxy.ts. Its payload holds a redirect to /sign-in without ?from=: that one came from verifySession, on line 8 of the page.
| Question | proxy.ts | lib/dal.ts |
|---|---|---|
| What does it read? | Only whether a cookie named session is there. | The cookie's signature, the user's row and the order's owner. |
| When does it run? | Before every request its matcher lets in, prefetches too. | Whenever a page, a Route Handler or a Server Action reads the data. |
| What did a made-up cookie get? | Let through. | Sent to /sign-in by verifySession. |
| What is it for? | Quick redirects, so visitors don't see a page they can't use. | Deciding who may read or change each piece of data. |
A matcher can also miss a route after a refactor, and in March 2025 a flaw in Next.js let one header skip middleware entirely. The case study The middleware bypass goes through it. Apps that checked next to their data were safe anyway.
Per-user data and Cache Components
With Cache Components, "use cache" keeps a function's result and gives it to every request with the same arguments (use cache). That's the opposite of per-user data. Four rules keep the two apart:
- Never read the session inside
"use cache". Next.js refuses: the build stops whencookies()is read inside a cached function, as Private and remote caches recorded. - Run the check outside the cache, on every request. Cache what's the same for everyone, and check before you hand it out.
- Never cache an answer made for one user under a key that doesn't name them. The next user with the same arguments gets the first one's answer.
- For data that belongs to one user, use
"use cache: private", or pass the user's id from the session to a cached function nobody else can call, as Authentication'sordersFordoes. A private cache isn't a store on the server that lasts between visits: in production, the server keeps its result for the one request, and the router may keep what was rendered in that visitor's own browser (Private and remote caches).
1// In lib/dal.ts: getOrder, with the row cached and the check outside the cache.2import { cacheLife } from "next/cache";3 4export async function getOrder(id: number) {5 const user = await getCurrentUser();6 const order = await orderRow(id);7 if (!user || !order) return null;8 if (order.userId !== user.id && user.role !== "staff") return null;9 return { id: order.id, tea: order.tea, boxes: order.boxes, status: order.status };10}11 12// The same for every visitor, so it may be cached by the id alone.13async function orderRow(id: number) {14 "use cache";15 cacheLife("minutes");16 return db.order.findUnique({ where: { id } });17}18 Line 5 finds the user on every request, outside the cache. Line 6 may get the row from the cache, which is fine: the row is the same whoever asks. Line 14 marks orderRow as cached by the order's id alone, so nothing in it may depend on the visitor. It isn't exported, so the only way to reach it is through the check.
Where does each check belong?
Some checks keep data safe; some only save visitors a step. Sort each one:
- Sending visitors with no session cookie to
/sign-inbefore/orders/1renders - Refusing order 3 unless the visitor owns it or is staff
- Hiding the Staff link in the nav from customers
- Returning only
id,tea,boxesandstatusfromgetOrder - Answering
403when a customer downloads the staff spreadsheet - Sending signed-in visitors from
/sign-into the home page - Checking the session cookie's signature before reading orders
- Checking who owns an order before the cancel action changes it
Sort each check: must it sit next to the data, or is it fine as a convenience in proxy.ts or a layout?
Common mix-ups
- "A layout protects every page under it." No. It isn't in front of Route Handlers or Server Actions, and it doesn't run again on a click inside its folder. The request with no cookie got every address.
- "Signed in means allowed." No. Asha was signed in, and order 3 still wasn't hers. Check the owner, not only the session.
- "The page checks, so its action is safe." No. The action is a separate way in. The changed hidden field cancelled Ben's order from Asha's own page.
- "proxy.ts checked the cookie, so the session is valid." An optimistic check only sees that a cookie is there.
session=let-me-ingot past it. - "A Client Component only receives the fields it shows." No. Props are sent whole: the row's address, note and password hash were in the payload.
- "React's
cache()remembers the user between requests." No. It shares one answer within one render. Every request reads its own cookie.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
This route sits in app/staff/orders/export/, under the staff layout. A request with no cookie asks for it. What status does it get?
import { allOrders } from "@/lib/db";
// Staff download every order as a spreadsheet.
export async function GET() {
const orders = await allOrders();
const rows = orders.map((order) => `${order.id},${order.tea},${order.boxes},${order.address}`);
return new Response(["id,tea,boxes,address", ...rows].join("\n"), {
headers: { "content-type": "text/csv" },
});
}
200, with every order and address. The staff layout wraps pages, not Route Handlers, and this route checks nothing itself. The recording sent it with no cookie.
Asha, signed in with her own cookie, asks /api/orders/3. Order 3 is Ben's. What status does she get?
import { getOrder, getCurrentUser } from "@/lib/dal";
export async function GET(request: Request, { params }: RouteContext<"/api/orders/[id]">) {
if (!(await getCurrentUser())) return Response.json({ error: "Sign in first." }, { status: 401 });
const { id } = await params;
const order = await getOrder(Number(id));
if (!order) return Response.json({ error: "No such order." }, { status: 404 });
return Response.json(order);
}
404 with No such order. getOrder returns null, because order 3 belongs to Ben and Asha isn't staff, and line 7 answers 404. The recording shows it.
This action cancelled Ben's order when a hidden field was changed. Which function from lib/dal.ts should it call instead of setStatus?
"use server";
import { setStatus } from "@/lib/db";
// The page shows this button only on the visitor's own order, so no check here.
export async function cancel(previous: string, formData: FormData) {
await setStatus(Number(formData.get("id")), "cancelled");
return "Your order is cancelled.";
}
"use server";
import { cancelOrder } from "@/lib/dal";
export async function cancel(previous: string, formData: FormData) {
return cancelOrder(Number(formData.get("id")));
}
cancelOrder, from lib/dal.ts. It asks getOrder, so an id that isn't the visitor's changes nothing. With it, the changed hidden field left Ben's order paid.
This page sends Asha the order's whole row, and shows her Ben's order. Which function should line 11 call instead of findOrder?
import { notFound, redirect } from "next/navigation";
import { findOrder } from "@/lib/db";
import { readSession } from "@/lib/session";
import CancelButton from "./cancel-button";
export const instant = false;
export default async function OrderPage({ params }: PageProps<"/orders/[id]">) {
if (!(await readSession())) redirect("/sign-in");
const { id } = await params;
const order = await findOrder(Number(id));
if (!order) notFound();
return (
<main>
<h1>Order {order.id}</h1>
<p>
{order.boxes} boxes of {order.tea}: {order.status}
</p>
<CancelButton order={order} />
</main>
);
}
import { notFound } from "next/navigation";
import { getOrder, verifySession } from "@/lib/dal";
import CancelButton from "./cancel-button";
export const instant = false;
export default async function OrderPage({ params }: PageProps<"/orders/[id]">) {
await verifySession();
const { id } = await params;
const order = await getOrder(Number(id));
if (!order) notFound();
return (
<main>
<h1>Order {order.id}</h1>
<p>
{order.boxes} boxes of {order.tea}: {order.status}
</p>
<CancelButton order={order} />
</main>
);
}
getOrder, from lib/dal.ts: it checks the owner and returns only id, tea, boxes and status. Its recorded payload has no address, note, email or hash, and Asha gets the not-found page for order 3.
Someone sends /orders/1 with the cookie session=let-me-in. proxy.ts lets it through. What in this page sends them to /sign-in?
import { notFound } from "next/navigation";
import { getOrder, verifySession } from "@/lib/dal";
import CancelButton from "./cancel-button";
export const instant = false;
export default async function OrderPage({ params }: PageProps<"/orders/[id]">) {
await verifySession();
const { id } = await params;
const order = await getOrder(Number(id));
if (!order) notFound();
return (
<main>
<h1>Order {order.id}</h1>
<p>
{order.boxes} boxes of {order.tea}: {order.status}
</p>
<CancelButton order={order} />
</main>
);
}
verifySession(), on line 8. proxy.ts let session=let-me-in through; getCurrentUser found no valid session, so verifySession redirected to /sign-in. The recorded payload holds that redirect, without proxy.ts's ?from=.
Asha, a customer, asks this route for the staff spreadsheet with her own cookie. What status does she get?
import { getAllOrders, getCurrentUser } from "@/lib/dal";
// Staff download every order as a spreadsheet.
export async function GET() {
if (!(await getCurrentUser())) return Response.json({ error: "Sign in first." }, { status: 401 });
const orders = await getAllOrders();
if (!orders) return Response.json({ error: "Staff only." }, { status: 403 });
const rows = orders.map((order) => `${order.id},${order.tea},${order.boxes},${order.address}`);
return new Response(["id,tea,boxes,address", ...rows].join("\n"), {
headers: { "content-type": "text/csv" },
});
}
403 with Staff only. Asha is signed in, so line 5 passes; getAllOrders returns null for anyone who isn't staff, and line 7 answers 403. Everyone knows the spreadsheet exists, so 403 gives nothing away.
Check your understanding
For each question, ask which way the request came in, and what checked it on that way.
Question 1 of 7The staff layout redirects anyone who isn't staff. A request for
/staff/ordersarrives with no cookie, and with the router's header saying the/stafflayout is already on screen. What does it get?Choose an answer to see the explanation.
Question 2 of 7
app/staff/orders/export/route.tssits inside theapp/stafffolder, under the staff layout. Does the layout's check run for it?Choose an answer to see the explanation.
Question 3 of 7
getCurrentUseris wrapped in React'scache(). Two components andgetOrderall call it while one page renders. How many times does its body run?Choose an answer to see the explanation.
Question 4 of 7In the DAL version, Asha asks
/api/orders/3, Ben's order, with her own valid cookie. What does she get?Choose an answer to see the explanation.
Question 5 of 7Why does the order route answer
404for someone else's order, and not403?Choose an answer to see the explanation.
Question 6 of 7proxy.ts only checks that a cookie named
sessionis there. Someone sendssession=let-me-into/orders/1. What happens?Choose an answer to see the explanation.
Question 7 of 7This is the staff spreadsheet route through the data access layer. What does Asha, a signed-in customer, get from it?
Read the code, then predictTypeScriptimport { getAllOrders, getCurrentUser } from "@/lib/dal"; // Staff download every order as a spreadsheet. export async function GET() { if (!(await getCurrentUser())) return Response.json({ error: "Sign in first." }, { status: 401 }); const orders = await getAllOrders(); if (!orders) return Response.json({ error: "Staff only." }, { status: 403 }); const rows = orders.map((order) => `${order.id},${order.tea},${order.boxes},${order.address}`); return new Response(["id,tea,boxes,address", ...rows].join("\n"), { headers: { "content-type": "text/csv" }, }); }Choose an answer to see the explanation.
Key takeaways
- Authentication says who is asking. Authorization decides what they may read or change, every time.
- A layout's check doesn't cover Route Handlers, Server Actions, or the router's requests after a click inside it.
- Put every read and change of private data in a server-only data access layer that checks the user, the owner and the role.
- Wrap the "who is asking" function in React's
cache(): one answer per render, however many parts ask. - Return DTOs with the fields pages need, never rows: props and payloads carry everything you pass.
- Take ids from the request and users from the session; answer
404for someone else's order. - proxy.ts is for quick, optimistic redirects. Keep per-user data out of shared caches.
Remember the one-liner.
Every way in reaches the same data, so check at the data: one server-only layer that asks who is asking, checks what they may have, and hands back only what the page needs.
Coming next: Securing Server Actions. Every action is a public endpoint: it calls one from outside the page, checks every argument with Zod, and looks at what Next.js protects for you, and what it doesn't.