Is Next.js Frontend or Backend? 68 of 92 Components Never Reach the Browser
Both — and the split is countable: 8 Server Action modules, 4 Route Handlers, one HMAC verified over raw bytes, and no database, ORM or scheduler anywhere.
Next.js is both, and the split is measurable rather than philosophical. This storefront runs 92 components of which 68 never ship JavaScript to the browser, 27 pages, 8 modules of Server Actions, and 4 Route Handlers — one of which verifies an HMAC signature over raw request bytes. That last one is not something a frontend framework does.
The count
$ find src/components -name '*.tsx' | wc -l
92
$ grep -rl '"use client"' src/components --include=*.tsx | wc -l
24
$ ls src/lib/actions/
account.ts auth.ts checkout.ts contact.ts
entitlementsView.ts newsletter.ts redemption.ts refund.ts
$ find src/app -name 'route.ts' | sort
src/app/api/download/route.ts
src/app/api/webhooks/lemonsqueezy/route.ts
src/app/auth/callback/route.ts
src/app/blog/feed.xml/route.ts
| Layer | Count | Runs where |
|---|---|---|
| Server Components | 68 of 92 | Server only — never sent to the browser |
Client Components ("use client") | 24 of 92 | Browser, after hydration |
| Pages | 27 | Rendered on the server at build time |
Server Action modules ("use server") | 8 | Server only, invoked over the network by forms |
| Route Handlers | 4 | Server only, plain HTTP |
Three quarters of the component tree is server-side. That is the answer to the question in one number — but the interesting part is which work landed on each side, because it is not the split most people assume.
The frontend half is smaller than you'd guess
The 24 Client Components are not "the UI." They are specifically the parts that need browser state: a mobile menu, an FAQ accordion, a screenshot carousel, a preview modal, the forms. Everything else — every product page, every section of the home page, the whole blog — renders to HTML on the server and ships no component JavaScript at all.
That matters because the default is inverted from the React people learned. In a Vite + React app every component is a client component and you opt out by pre-rendering. In the App Router every component is a Server Component and you opt in with "use client", which is why the file count skews the way it does: nobody adds the directive unless something in the file actually needs the browser.
The boundary is enforced, not advisory. Marking a module server-only makes importing it from a Client Component a build error:
// src/lib/supabase/admin.ts
import "server-only";
That is the guardrail standing between a service-role database key and a JavaScript bundle. It is the clearest evidence that the framework takes the two halves seriously: there is a compiler-checked wall between them.
The backend half does real backend work
Four Route Handlers, and two of them do things no frontend framework has any business doing.
Verifying a webhook signature over exact bytes. The payment provider signs the raw body. Parsing JSON and re-serializing it can produce different bytes — key order, whitespace, number formatting — and the HMAC would then never match. So the handler reads text first, deliberately:
// src/app/api/webhooks/lemonsqueezy/route.ts
export const runtime = "nodejs";
export async function POST(req: Request) {
const raw = await req.text();
const signature = req.headers.get("X-Signature");
…
}
The runtime = "nodejs" line is there because this needs node:crypto. That is a backend concern in its most literal form: choosing a runtime because of a cryptographic primitive.
Work that outlives the response. The welcome email is scheduled with after() rather than fired and forgotten, and the comment in the file explains exactly why:
// Scheduled via `after()` (next/server) rather than invoked directly:
// on Vercel a fire-and-forget promise that isn't awaited has no
// guaranteed lifetime once the response is sent — the invocation can
// freeze/terminate before the Resend call completes, silently dropping
A dangling promise in a serverless function is a dropped email. Knowing that, and having a framework primitive for it, is backend engineering.
Trusting the right proxy header. The download route audits client IPs, and takes only the first hop:
function clientIp(req: NextRequest): string | null {
// First hop only (spec §4/R-13 abuse audit) — later entries in
// `x-forwarded-for` are attacker-controllable.
const forwardedFor = req.headers.get("x-forwarded-for");
const first = forwardedFor?.split(",")[0]?.trim();
if (first) return first;
return req.headers.get("x-real-ip");
}
Anyone can send an X-Forwarded-For header with as many entries as they like. Only the hop your own infrastructure appended is trustworthy. That is a detail you learn writing servers, not interfaces.
Server Actions are the part that confuses the taxonomy
The 8 modules under src/lib/actions/ are the reason "frontend or backend" is the wrong shape of question. A Server Action looks like a function call and is actually an HTTP request:
<form action={signInWithGoogle}>
That form posts to the server, which runs the function, which redirects. There is no route, no fetch, no JSON envelope, no client-side handler — and yet every byte of signInWithGoogle runs server-side and never reaches the browser.
Which means the ordinary backend obligations apply to it in full, and the framework will not remind you. From src/lib/actions/auth.ts:
if (error) {
console.error("auth: signInWithPassword error", error);
// Always the same message — never let a caller distinguish "wrong
// password" from "email exists but unconfirmed" (account enumeration).
return { ok: false, message: "Incorrect email or password." };
}
An action is a public endpoint. It has to validate its own input, refuse to leak which accounts exist, and allowlist any redirect target it is handed — the same duties an Express route has, wearing a function's clothes. The convenience is real and so is the exposure; treating Server Actions as "just frontend code" is how input validation gets skipped.
What it still is not
Being full-stack is not being a general-purpose server. The honest boundaries, as they show up in this project:
| Need | Does Next.js cover it | What this codebase does |
|---|---|---|
| HTTP endpoints | Yes | 4 Route Handlers |
| Form/mutation handling | Yes | 8 Server Action modules |
| Session management | Yes, via the proxy | src/proxy.ts, refreshed per request |
| Database | No | Supabase (Postgres), accessed through its own clients |
| Auth provider | No | Supabase Auth |
| Schema / migrations | No | supabase/migrations/, plain SQL |
| Authorization rules | No | Postgres row-level security, enforced in the database |
| Background jobs / cron | No | Platform-level (after() covers post-response work only, not scheduling) |
| Long-running processes, websockets | No | Not a fit for the serverless model it targets |
Next.js gives you the request/response layer and nothing under it. Every row in the bottom half of that table is a service this project had to bring, which is the practical meaning of "backend-for-frontend": it is a backend for its own frontend, not a replacement for your data tier.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
useState/useEffect "only works in a Client Component" | The file is a Server Component by default | Add "use client" — to the smallest leaf that needs it, not the page |
| A secret shows up in the browser bundle | It was read in a module a Client Component imports, or prefixed NEXT_PUBLIC_ | import "server-only" in that module; never prefix a secret |
fetch to your own API route from a Server Component | Unnecessary round trip — you're already on the server | Call the function directly; skip the HTTP hop |
| Webhook signature never validates | The body was parsed before hashing | await req.text() first, hash those exact bytes |
| Emails/analytics dropped intermittently in production | A promise not awaited after the response was sent | after() from next/server |
| Server Action accepts unvalidated input | It looks like a function, so validation felt unnecessary | Treat it as a public endpoint: validate, generic errors, allowlist redirects |
node:crypto / fs fails at runtime | The route ran on the Edge runtime | export const runtime = "nodejs" |
Frequently asked questions
Is Next.js a backend framework? It is a full-stack framework with a genuine server half, not a backend framework in the sense of Django or Rails. It gives you routing, request handling, mutations and session plumbing; it gives you no ORM, no migration tool, no admin, no job scheduler and no database. If your mental model is "Express, but with the UI attached," you will be right about the endpoints and wrong about everything below them.
Can I use Next.js purely as a frontend for an existing API? Yes, and it is a common setup. You would use Server Components to fetch from that API server-side — which keeps the credentials off the browser and removes a round trip versus fetching from the client — and simply not write Route Handlers or Server Actions. Nothing forces the backend half on you.
Do I still need a separate backend? You need the pieces Next.js doesn't have: a database, an auth provider, and somewhere to run scheduled work. Whether those arrive as managed services or as your own service is the real question. This storefront answers it with Supabase for the first two and the deployment platform for the third, which is why its "backend" is 8 action modules and 4 route handlers rather than an application server.
If 68 of 92 components are server-rendered, why ship any JavaScript? Because 24 of them genuinely need it — a carousel that responds to arrow keys, a modal that traps focus, a form that shows pending state. The value of the default being server-side is that those 24 are a deliberate, reviewable list instead of the whole tree by accident.
Templates in this post
ASoc Fade (a barbershop site), ASoc Fiscal (a financial-platform landing page) and ASoc Flow (a workflow-automation SaaS page) are Next.js 16 templates built on the Server-Component default described above.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For where the boundary actually falls, see Server Components vs Client Components; for choosing between the two server-side entry points, Server Actions vs API routes.
