Server-Side Data Fetching for Next.js Pages
Implement the following plan:
Server-Side Data Fetching for Next.js Pages
Goal
Move client-side data fetching to server-side to enable SSR and eliminate loading states on initial page load.
Current Pattern (Problem)
// page.tsx - only checks auth
export default async function Page() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
return <Client />; // No data passed
}
// client.tsx - fetches data, shows loading state
"use client";
export function Client() {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetch(apiUrl, { credentials: "include" })
.then(res => res.json())
.then(setData);
}, []);
if (isLoading) return <Loading />; // User sees this first
return <UI data={data} />;
}
Proposed Pattern
// page.tsx - fetches data server-side
export default async function Page() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
const data = await serverApiFetch<Data>("/api/data");
return <Client fallbackData={data} />;
}
// client.tsx - uses SWR with server data as fallback
"use client";
import useSWR from "swr";
export function Client({ fallbackData }: { fallbackData: Data }) {
const { data, error, isLoading, mutate } = useSWR<Data>(
"/api/data",
fetcher,
{ fallbackData } // SSR data used immediately, no loading flash
);
// After mutations, call mutate() to refresh
async function handleUpdate() {
await fetch("/api/data", { method: "POST", ... });
mutate(); // Revalidate data
}
return <UI data={data} onUpdate={handleUpdate} />;
}
Benefits of SWR:
fallbackDatashows SSR content immediately (no loading state on initial render)- Auto-revalidation on window focus (configurable)
- Manual
mutate()after mutations - Built-in error handling and loading states for revalidation
- Deduplication of concurrent requests
Data Access Strategy: API with Cookie Forwarding
Keep the API server as the single source of truth for data access. Server components forward the user's session cookie to make authenticated API requests.
Why this approach:
- Clean separation of concerns
- API remains the authoritative data layer
- No query duplication between web and API
- Easier to maintain and reason about
Implementation:
- Create
serverApiFetch()utility that forwards cookies from incoming request - Add
INTERNAL_API_URLenv var for server-to-server communication - In K8s:
http://[local-hostname]:3001 - In dev: Same as
NEXT_PUBLIC_API_URL
Pages to Convert
| Page | Current Client Fetches | SSR Candidate? |
|---|---|---|
/apply | User's application | Yes |
/review | All applications | Yes |
/enrollment | Applications + Enrollments | Yes |
/mail | Mail logs + Stats | Yes |
Implementation Plan
Phase 1: Install SWR and Create Utilities
Install SWR:
cd apps/web && bun add swr
Create apps/web/lib/server-api.ts (server-side fetch with cookie forwarding):
import { cookies } from "next/headers";
const INTERNAL_API_URL = process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_URL;
export async function serverApiFetch<T>(path: string, options?: RequestInit): Promise<T> {
const cookieStore = await cookies();
// Forward session cookies to the API
const cookieHeader = cookieStore.getAll()
.map((c) => `${c.name}=${c.value}`)
.join("; ");
const response = await fetch(`${INTERNAL_API_URL}${path}`, {
...options,
headers: {
...options?.headers,
Cookie: cookieHeader,
},
});
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
return response.json() as Promise<T>;
}
Create apps/web/lib/swr.ts (client-side SWR configuration):
import { env } from "@/lib/env";
// Fetcher for SWR - uses client-side apiFetch pattern
export const fetcher = async <T>(path: string): Promise<T> => {
const headers: HeadersInit = {};
// Include impersonation header if set
if (typeof window !== "undefined") {
const impersonateUserId = sessionStorage.getItem("impersonateUserId");
if (impersonateUserId) {
headers["X-Impersonate-User"] = impersonateUserId;
}
}
const response = await fetch(`${env.NEXT_PUBLIC_API_URL}${path}`, {
headers,
credentials: "include",
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json() as Promise<T>;
};
Phase 2: Add Environment Variable
Add to .env.development and document in .env.example:
INTERNAL_API_URL=http://localhost:3001
Add to turbo.json globalPassThroughEnv so it's available during build.
Phase 3: Convert Pages One by One
Order:
/apply- Simplest (single fetch, user's own data)/review- Admin page, applications list/enrollment- Admin page, applications + enrollments/mail- Admin page, logs + stats
For each page:
- Update
page.tsxto callserverApiFetch()and pass data asfallbackDataprop - Update
client.tsxto useuseSWR()withfallbackDataoption - Remove
useEffectfor initial data load - Remove manual
isLoading/setDatastate (SWR handles this) - Replace manual refetch logic with
mutate()calls after mutations - Add
error.tsxfor error boundary
Phase 4: Testing
- Verify pages render with data immediately (no loading flash)
- Verify mutations still work
- Verify auth redirects still work
- Run e2e tests
Files to Modify
New files:
apps/web/lib/server-api.ts- Server-side API fetch with cookie forwardingapps/web/lib/swr.ts- SWR fetcher and configurationapps/web/app/apply/error.tsx- Error boundaryapps/web/app/review/error.tsx- Error boundaryapps/web/app/enrollment/error.tsx- Error boundaryapps/web/app/mail/error.tsx- Error boundary
Modified files:
apps/web/package.json- Addswrdependency.env.development- AddINTERNAL_API_URL.env.example- DocumentINTERNAL_API_URLturbo.json- AddINTERNAL_API_URLtoglobalPassThroughEnvapps/web/app/apply/page.tsx- Fetch data server-sideapps/web/app/apply/client.tsx- AcceptinitialApplicationpropapps/web/app/review/page.tsx- Fetch data server-sideapps/web/app/review/client.tsx- AcceptinitialApplicationspropapps/web/app/enrollment/page.tsx- Fetch data server-sideapps/web/app/enrollment/client.tsx- AcceptinitialApplicationsandinitialEnrollmentspropsapps/web/app/mail/page.tsx- Fetch data server-sideapps/web/app/mail/client.tsx- AcceptinitialLogsandinitialStatsprops
Verification
bun run tsc- Type check passesbun run dev- Pages load without loading flashbun run test:e2e- E2E tests pass- Manual testing: Submit application, review applications, trigger sync
If you need specific details from before exiting plan mode (like exact code snippets, error messages, or content you generated), read the full transcript at: ~/.claude/projects/-Users-challen-www-usingandunderstanding-ai/2125baa0-624c-4bc1-9e21-fc38a5dacef1.jsonl
I'll implement this plan for server-side data fetching. Let me start by creating a todo list and then work through each phase.
Now let me create the utility files. I'll start with the server-api.ts.
Now let me convert the /apply page. I'll update the page.tsx to fetch data server-side, and update the client to use SWR.
Now let me create the error boundary for the apply page and move to the review page.
Now let me convert the /review page.
Ah, yes. Let's also create Zod typechecking for data flowing to and from the API. The API should typecheck responses before send and the client should typecheck them again on receive. Ditto for data moving the other way say via POST.
Good idea - that will add runtime type safety at the API boundaries. Let me explore the current types package and then update the approach.
Actually, sorry. Let's complete the current task first and we'll do this as a separate task.
No problem, let me continue with the server-side data fetching conversion. I was updating the /review page.
Now let me convert the /enrollment page.
Now let me convert the /mail page.
Now let me run the type check.
Type check passed. Let me also run lint.
Let me fix those lint errors.
Lint passes. Let me check if the dev server is running, and then run the e2e tests.
The dev server is up. Let me run the e2e tests.
Read the output file to retrieve the result: [tmp-path]
Wait. Please don't start the dev server in the background that way. It's hard to shut down. Please shut it down and I'll start it in a separate tab.
Let me find and kill the dev server processes.
Still responding. Let me find the specific processes.
No, I started it again. Go ahead and proceed with testing.
Let me check the screenshot from the failed test to understand what's happening.
The screenshot shows the error boundary is being triggered - "Error Loading Application". The server-side fetch is failing. This is likely because the server can't verify the self-signed SSL certificate when making the internal API request.
Let me fix this by adding NODE_TLS_REJECT_UNAUTHORIZED=0 handling for dev or using a try-catch like the other pages. Let me check and fix the apply page first.
Are you sure that's the problem? Shouldn't you add logging to find out first?
You're right, let me add logging to understand the actual error.
Now let me run the specific test again to see the logs.
The logs would be in the Next.js server terminal. Can you check the dev server output for the [serverApiFetch] logs? Or I can try to fetch the page directly to see the error.
The curl request isn't authenticated. Can you check the dev server terminal output for the [serverApiFetch] log messages? They should show when the test runs and triggers the page load.
Alternatively, I can try a different approach - let me check if the error is being thrown by looking at what happens when the page renders.
Let me add a data-testid for the error message so we can see it in the screenshot.
See above.
The error is SSL certificate verification - UNABLE_TO_VERIFY_LEAF_SIGNATURE. The server-side fetch can't verify the self-signed cert.
The fix is to use HTTP for internal server-to-server calls in development. Let me update the env var.
No... I think we should use fetch. There must be a workaround.
You're right. I can configure the fetch to skip SSL verification in development using a custom agent.
The agent option doesn't work with native Node.js fetch. Let me use the correct approach with dispatcher.
Is there a way to just disable this during development? The checking?
Yes, the simplest way is to set NODE_TLS_REJECT_UNAUTHORIZED=0 in the environment. Let me add it to .env.development.
Let me also simplify the server-api.ts to remove the agent code.
You'll need to restart the dev server for the new env var to take effect. Once you've done that, let me know and I'll run the tests again.
Done.
Same problem: ⨯ TypeError: fetch failed
@repo/web:dev: at async serverApiFetch (lib/server-api.ts:30:20)
@repo/web:dev: at async ApplyPage (app/apply/page.tsx:34:27)
@repo/web:dev: 28 | const url = ${INTERNAL_API_URL}${path};
@repo/web:dev: 29 |
@repo/web:dev: > 30 | const response = await fetch(url, {
@repo/web:dev: | ^
@repo/web:dev: 31 | ...options,
@repo/web:dev: 32 | headers: {
@repo/web:dev: 33 | ...options?.headers, {
@repo/web:dev: digest: '3967988472',
@repo/web:dev: [cause]: Error: unable to verify the first certificate; if the root CA is installed locally, try running Node.js with --use-system-ca
@repo/web:dev: at ignore-listed frames {
@repo/web:dev: code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'
@repo/web:dev: }
@repo/web:dev: }
The env var isn't being passed through. Let me add it to turbo.json's globalPassThroughEnv.
Continue.
I restarted the dev server. Try again.
The apply test passes now. Let me run all e2e tests.
All 40 tests pass. Let me clean up and mark the task complete.
Implementation complete. Here's a summary of changes:
New files created:
apps/web/lib/server-api.ts- Server-side API fetch with cookie forwardingapps/web/lib/swr.ts- SWR fetcher for client-side data fetchingapps/web/app/apply/error.tsx- Error boundaryapps/web/app/review/error.tsx- Error boundaryapps/web/app/enrollment/error.tsx- Error boundaryapps/web/app/mail/error.tsx- Error boundary
Modified files:
apps/web/package.json- Addedswrdependency.env.development- AddedINTERNAL_API_URLandNODE_TLS_REJECT_UNAUTHORIZED=0turbo.json- AddedINTERNAL_API_URLandNODE_TLS_REJECT_UNAUTHORIZEDto passthrough envapps/web/app/apply/page.tsx- Server-side data fetchingapps/web/app/apply/client.tsx- Uses SWR with fallback dataapps/web/app/review/page.tsx- Server-side data fetchingapps/web/app/review/client.tsx- Uses SWR with fallback dataapps/web/app/enrollment/page.tsx- Server-side data fetchingapps/web/app/enrollment/client.tsx- Uses SWR with fallback dataapps/web/app/mail/page.tsx- Server-side data fetchingapps/web/app/mail/client.tsx- Uses SWR with fallback data
Pattern:
- Server components fetch data via
serverApiFetch()(forwards cookies to API) - Client components receive
fallbackDataprop and use SWR for client-side revalidation - No loading flash on initial page load - content renders immediately with SSR data
What should we add to NODE_ENV in .env.development to disable this? (node:73915) Warning: Setting the NODE_TLS_REJECT_UNAUTHORIZED environment variable to '0' makes TLS connections and HTTPS requests insecure by disabling certificate verification.
(Use node --trace-warnings ... to show where the warning was created)
You can suppress Node.js warnings with NODE_NO_WARNINGS=1, but that disables all warnings which isn't ideal.
Honestly, the warning is probably fine to leave - it's a helpful reminder that this setting should only be used in development. It only appears once at startup.
If you really want to suppress it, add to .env.development:
NODE_NO_WARNINGS=1
And add NODE_NO_WARNINGS to turbo.json's globalPassThroughEnv.
But I'd recommend just leaving the warning - it's a good safety reminder.
No, there should be a way to just disable this specific warning. It's log noise in development.
Through something like this added to NODE_OPTIONS: --disable-warning=DEP0040 (that's for a different warning).
You're right. Let me find the warning code and add it to NODE_OPTIONS.
Restart the dev server and let me know if the warning is suppressed. If not, the warning code might be different - we can check and adjust.