Here's the short version first, so you have it before anything else. A good stack is not a list of popular tools. It's a chain. Each layer hands a promise to the next one about what the data looks like, and the tools are picked because they keep those promises checkable. Once you see the chain, you can swap any tool in it and still know what you're doing.
Who is this for? All three of you. If you're a beginner, read it top to bottom, and I'll define every word the first time it shows up. If you've already built a couple of projects, the sections on validation, authorization and webhooks are where the common habits hurt later. If you're the person making architecture calls, the deployment section and the part about what I would not pick are for you.
When someone asks me what stack to learn, the real question underneath is usually this: I've learned a bunch of separate things, and I still don't know how a real app fits together. I know that feeling well. When I finished my first courses, I felt like I knew nothing. I could write a loop and pull a name out of an array, but nobody had shown me where that loop lives in a real website, or what talks to what. I wrote about that whole problem in why courses leave you feeling like you know nothing.
A stack article can fix part of that, if it does one thing the logo lists skip. It has to show you the connections. Not "use Prisma", but what Prisma hands to the next layer, and what breaks when that hand-off fails. That's what we'll do.
Quick words first, for beginners. A stack is the set of tools that together run one application. The front end is everything that runs in the user's browser: what they see and click. The back end is everything that runs on a server: a computer somewhere else that stores data and makes decisions. A database is the place where the data lives long term. A framework is a big library that decides the overall shape of your app for you, so you don't have to invent it. If those four words feel shaky, start with the beginner's guide to web development and come back. I'll wait.
For about ten years, the normal way to build a web app was two separate apps. A front end, usually a single-page application, which is a big bundle of JavaScript that builds the page inside the browser. And a back end, an API, which is a program that answers requests with data. The two talked over HTTP, and you, the developer, kept them in sync by hand.
Think of it like a restaurant where the waiter and the kitchen don't share a language. Every order has to be written on a slip, carried over, read, and checked. If the kitchen renames a dish and nobody tells the waiter, customers get the wrong plate. That slip is your API. Every field on it is a place where the front end and back end can disagree.
Here's the thing that changed. With React Server Components and frameworks like Next.js, a lot of that slip went away. Some of your components run on the server, some run in the browser, and they live in the same project, in the same language, checked by the same compiler. The line between front end and back end didn't disappear, but it moved inside your codebase. Being full-stack today mostly means understanding where that line sits.
That's why so many stack articles feel confusing. They still describe the old two-app world with new logos on top. The cause of the confusion isn't you. It's that the map changed and most maps weren't redrawn.
Before any code, let me give you the one idea the rest of the article hangs on. A relay race. Four runners, one baton. It doesn't matter how fast the third runner is if the second one drops the baton. In an app, the baton is your data. The database hands a row to your server code. Your server code hands props to a component. A form hands user input back to your server. Every hand-off is a place to drop it.
Most bugs I've hit in real projects weren't clever algorithm mistakes. They were two layers disagreeing about the shape of the same data. The database says a date can be empty, the component assumes it's always there, and the page crashes for one user out of fifty. Nothing exotic. Just a dropped baton.
So when I pick tools, I'm not asking "which one is fastest on a benchmark". I'm asking "which one lets the next layer check what it receives". The stack in this article answers that question at every layer:
- Rendering: Next.js with the App Router, where components run on the server by default.
- Contracts: TypeScript in strict mode, so the compiler checks every hand-off inside your code.
- The edge: Zod, a runtime validator, for data coming from outside your code.
- Data: PostgreSQL with Prisma, where the database schema generates your types.
- Auth: a maintained library for sessions, plus your own authorization checks.
- Payments: Stripe, where the webhook is the real integration.
- Styling: Tailwind CSS inside typed components.
- Deployment: Vercel's serverless functions, with connection pooling for the database.
I work as a web developer, and this is the shape of stack I write every day. None of these tools is the only right choice. I'll tell you where each one stops being the right one. But they fit the chain, and that's the part worth learning.
Let's start where the user is. In the Next.js App Router, pages and layouts are Server Components by default. The official docs say it in one line: by default, layouts and pages are Server Components. That means the component runs on the server, can read your database directly, and sends finished HTML to the browser. No API endpoint in between. No loading spinner for the first view.
// Runs on the server. This code never ships to the browser.
import { db } from "@/lib/db";
import { LikeButton } from "./like-button";
export default async function BlogPage() {
const posts = await db.post.findMany({
where: { published: true },
orderBy: { publishedAt: "desc" },
});
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
{post.title} <LikeButton postId={post.id} />
</li>
))}
</ul>
);
}Let me walk through it, line by line.
- The comment on top is the most important line for you as a learner. This file runs on the server. The database code in it never reaches the user's browser.
- import { db } from "@/lib/db" pulls in our database client. The "@/" part is a path alias, a shortcut for "start from the project root", so you don't write ../../.. everywhere.
- import { LikeButton } brings in a small interactive component. We'll look at it in a second, because it's the other half of the picture.
- export default async function BlogPage is the page itself. The async keyword is the new part. A Server Component can be async, so it can wait for the database right there in the component.
- db.post.findMany asks the database for posts, only published ones, newest first. The where and orderBy objects are typed, so a typo like publishAt instead of publishedAt is a compile error, not an empty page.
- The return builds a list. key={post.id} tells React which item is which, so it can update the list without redrawing everything. Remove the key and React warns you, and updates get slower and sometimes wrong.
Now, a page that only shows data is fine. But buttons need to react to clicks, and clicks happen in the browser. That's what a Client Component is for.
"use client";
import { useState } from "react";
export function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false);
return (
<button
type="button"
aria-pressed={liked}
onClick={() => setLiked(!liked)}
data-post={postId}
>
{liked ? "Liked" : "Like"}
</button>
);
}- "use client" on the first line is the switch. It tells Next.js: this file and everything it imports runs in the browser too. Without it, useState would fail, because the server has no clicks and no state to remember.
- useState(false) gives the button a small memory: liked or not. setLiked changes it, and React redraws the button.
- The props are typed: { postId: string }. Props that cross from a Server Component into a Client Component must be things that can be sent over the network, like strings and numbers. You can't pass a database connection or a function from the server into this button.
- aria-pressed tells screen readers that this is a toggle and whether it's on. It's one attribute, and it's the difference between a button everyone can use and one only sighted mouse users can.
So that's the mental model. The first question you ask about any component is no longer "what state does it hold", it's "where does it run". Server by default. Client only when it needs to react to the user. Keep the "use client" files small and push them to the leaves of your tree, like this button, and most of your page stays server-rendered and light.
If you want the full story of that boundary, React Server Components, explained covers what can cross it and the errors you'll hit when you get it wrong. And why the App Router is the right default covers why Next.js, and what its caching costs you.
info
For advanced readers: in Next.js 16 the old middleware file convention is deprecated and renamed to proxy, and proxy runs on the Node.js runtime by default (Next.js docs, file conventions: proxy). If your project still has a middleware.ts, that's the migration to plan. At the time of writing, the current release line is Next.js 16.4.
TypeScript is JavaScript with types. A type is a description of what a value is allowed to be: a string, a number, an object with an email field. The compiler reads your code and complains before you run it if those descriptions don't match.
Why does that matter so much here? Because when you work alone, nobody reviews your code. Your reviewer is the compiler, or nobody. Strict mode is what makes the compiler a serious reviewer. Without it, null and undefined slip through anywhere, and the any type spreads like a hole in a fishing net: every value it touches stops being checked.
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true
}
}- "strict": true turns on a group of checks at once, the most useful being strict null checks. The compiler forces you to handle the case where a value might be missing.
- "noUncheckedIndexedAccess": true is not part of strict, and it's the one I'd add next. It says: when you read posts[0], it might not exist. Remove it and the compiler pretends every array index is filled, which is exactly how "cannot read properties of undefined" errors sneak into production.
A version note, because this changed recently. In TypeScript 6.0, released in March 2026, strict became true by default, as the TypeScript 6.0 announcement states. TypeScript 7.0, the native rewrite, followed in July 2026. I still write "strict": true explicitly. It costs one line, it documents intent, and it keeps older tooling and older TypeScript versions honest.
The first week with strict mode is annoying. You'll fight red underlines everywhere. After that, something better happens: you stop holding the whole app in your head, because the compiler holds it for you. Change a field in the database, and every component that uses it lights up red before you deploy, not on a Saturday when a user writes to you.
Here's a trap almost everyone falls into, and I want you to avoid it early. TypeScript only checks your code. It knows nothing about data that comes from outside: a form, a webhook, a third-party API, a URL. That data is unknown, no matter what type you write on it. Types are erased when the code runs, so they can't stop a bad value at runtime.
So at every edge of your app, you need a runtime check. That's what Zod does. You describe the shape once, and Zod gives you both a check that runs and a type for the compiler.
import { z } from "zod";
export const subscribeSchema = z.object({
email: z.email().max(254),
name: z.string().trim().min(1).max(100),
});
// The static type comes from the runtime check,
// so the two can never drift apart.
export type SubscribeInput = z.infer<typeof subscribeSchema>;- z.object({...}) describes an object with exactly these fields.
- z.email() checks that the value looks like an email. In Zod 4 this is the top-level form; the older z.string().email() still works but is deprecated, per the [Zod 4 changelog](https://zod.dev/v4/changelog). If you copy older tutorials, that's the line to update.
- .max(254) caps the length. 254 characters is the practical maximum for an email address, and a cap stops someone from sending you a megabyte of text in one field.
- z.string().trim().min(1).max(100) trims spaces first, then demands at least one real character. Without trim, a name made of three spaces passes as "filled in".
- z.infer<typeof subscribeSchema> creates the TypeScript type from the schema. You never write the type by hand, so the check and the type can't drift apart.
Now the server side, where that schema actually protects you. This is a Server Action, a function that runs on the server and that a form can call directly.
"use server";
import { z } from "zod";
import { subscribeSchema } from "@/lib/validation/subscribe";
import { db } from "@/lib/db";
export async function subscribe(formData: FormData) {
const result = subscribeSchema.safeParse({
email: formData.get("email"),
name: formData.get("name"),
});
if (!result.success) {
return { ok: false, errors: z.flattenError(result.error).fieldErrors };
}
await db.subscriber.create({ data: result.data });
return { ok: true };
}- "use server" marks this file's exports as Server Actions. The browser can call them, but the code runs only on the server.
- formData.get("email") reads what the form sent. Notice we don't trust it. It goes straight into safeParse.
- safeParse never throws. It returns an object with success true and the clean data, or success false and an error. I prefer it at the edge because a bad form is normal, not exceptional.
- z.flattenError(result.error).fieldErrors turns the error into a simple object like { email: ["Invalid email address"] }, which the form can show next to the right field. I ran this exact schema: an email of "emily@" and an empty name return exactly those two field errors.
- db.subscriber.create({ data: result.data }) only ever receives the parsed, trimmed data. If you pass the raw formData here instead, you've skipped the whole check.
You'll use the same schema in the browser for instant feedback, and on the server for real protection. The browser check is a courtesy. The server check is the lock. Here's a small front-end demo of that idea. It is only a demo: the "server" is simulated in the same page, and in a real app that part runs on your server. Try sending it an empty name, the email emily@, and then tick the box that skips the browser check, the way a script would.
Notice what happens when you skip the browser check. The bad data still gets stopped, by the server. That's the whole point. If your only check lives in the browser, anyone with a terminal walks straight past it. If you want to go deeper on this, Zod at trust boundaries covers webhooks, query strings and environment variables with the same pattern.
tip
If you take one habit from this article, take this one: never type outside data with "as". Writing "as SubscribeInput" is a promise you can't keep. The data doesn't care what you promised. Parse it, don't assert it.
A database stores your data so it survives restarts. A relational database, like PostgreSQL, stores it in tables with rows and columns, and it understands relationships between tables: a post belongs to a user, an invoice belongs to a subscription.
Some years ago the fashion was document databases, where you store loose JSON documents with no fixed shape. A lot of projects learned the same lesson at the same expensive moment: app data is relational whether you store it that way or not. Users have posts, posts have comments. If the database doesn't enforce those links, your app code has to, and app code is where bugs live.
PostgreSQL is the default for boring reasons, and boring is what you want under your data. It enforces unique values and foreign keys inside the database, where no forgotten code path can skip them. Its JSON columns cover most cases that used to need a document store. And managed hosting with a free tier is easy to find. If you're picking a host, the managed Postgres comparison goes through the options.
Between your code and Postgres sits an ORM, an object-relational mapper. It lets you query the database with typed function calls instead of raw SQL strings. In this stack, its most important job isn't saving you from SQL. It's generating types from your schema. Here's a Prisma 7 schema.
generator client {
provider = "prisma-client"
output = "../generated/prisma"
}
datasource db {
provider = "postgresql"
}
model User {
id String @id @default(cuid())
email String @unique
posts Post[]
}
model Post {
id String @id @default(cuid())
title String
slug String @unique
published Boolean @default(false)
publishedAt DateTime?
author User @relation(fields: [authorId], references: [id])
authorId String
}- The generator block says what to generate. In Prisma 7 the provider is "prisma-client", and the output path is required, as the [Prisma 7 upgrade guide](https://www.prisma.io/docs/orm/more/upgrade-guides/upgrading-versions/upgrading-to-prisma-7) explains. Older tutorials show "prisma-client-js" with no output; that's the dated version.
- The datasource block only names the database type now. The connection URL moved out of this file, into prisma.config.ts, which you'll see next.
- @id @default(cuid()) makes id the primary key and fills it with a random, collision-resistant string. You never have to invent IDs.
- @unique on email and slug makes the database refuse duplicates. Even if two requests try to create the same slug at the same moment, only one wins. An if-check in your code can't promise that.
- DateTime? with the question mark means publishedAt may be empty. That one character flows all the way into TypeScript as Date | null, and the compiler forces every component to handle the null. That's the relay baton not being dropped.
- The @relation line links each post to a user through authorId. The database won't let a post point at a user that doesn't exist.
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
datasource: { url: env("DATABASE_URL") },
});- defineConfig gives you type checking for the config file itself.
- env("DATABASE_URL") reads the connection string from your environment, never from code. Your database password must not live in git.
And this is how your app gets a database client in Prisma 7. Driver adapters are now required, so you pass in an adapter for Postgres.
import { PrismaClient } from "@/generated/prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
});
export const db = new PrismaClient({ adapter });- The first import comes from the folder you set as output in the generator. That's your generated, typed client.
- PrismaPg is the driver adapter. It uses the standard node-postgres driver under the hood to talk to Postgres.
- The exclamation mark after DATABASE_URL tells TypeScript "trust me, it's set". That's one of the few places I allow it, because the app can't run without it anyway. A safer Plan B is to validate your environment variables with Zod at startup, so a missing value fails loudly on boot instead of on the first query.
Rename publishedAt in the schema, regenerate, and every component that touches it fails to compile until you fix it. That's the chain doing its job. Prisma isn't the only good ORM; Drizzle stays closer to SQL and makes different trade-offs. I compare them properly in the Prisma vs Drizzle decision, and if you pick Prisma, Prisma from zero to production is the next step.
Two words people mix up, so let's split them. Authentication answers "who are you". Authorization answers "are you allowed to do this". A hotel key card is a good picture. The front desk checks your ID once and gives you a card. That's authentication. Your card opens your room and not the room next door. That's authorization.
Authentication looks simple from outside: a form, a password check, a cookie. It's the most dangerous part of the stack to build yourself, because the failure mode isn't a bug report, it's a breach. Password hashing, session handling, token rotation, OAuth callbacks: each one has a known right answer and a long list of people who got it slightly wrong. I'll be honest with you about my own blind spot here. For a long time I never thought about what happens when a thousand real people have accounts in something I built: protecting those accounts from attackers, password resets, helping someone when my own bug hurts them. That's when "just a login form" stops being small.
So use a maintained library. Which one is a real decision, and I compare the options in NextAuth vs Lucia vs Better Auth. One update since that comparison was written: Lucia, the library, was deprecated in March 2025, and its site is now a guide with reference code you copy into your own project (lucia-auth.com). That makes it a great way to learn how sessions work, and a harder way to ship one.
Authorization, though, is always your job. No library knows that only the author of a post may publish it. Here's what that check looks like in a Server Action.
"use server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
export async function publishPost(postId: string) {
const session = await auth();
if (!session) throw new Error("Not signed in");
const post = await db.post.findUnique({ where: { id: postId } });
if (!post || post.authorId !== session.user.id) {
throw new Error("Not allowed");
}
await db.post.update({
where: { id: postId },
data: { published: true, publishedAt: new Date() },
});
}- auth() returns the current session, or null if nobody is signed in. Every auth library has a function like this; the name differs.
- The "if not session" line throws for anonymous calls. Remember, a Server Action is a public endpoint. Anyone can call it, not only your own button.
- findUnique loads the post so we can look at who owns it.
- Comparing post.authorId with session.user.id is the authorization check. Signed in isn't enough; it has to be your post. Delete this line and any signed-in user can publish anyone's draft by sending a different postId. That bug has a name, IDOR, insecure direct object reference, and it's one of the most common holes in real apps.
- Only after both checks do we update the database.
warning
Hiding a button in the UI is not security. If the Publish button only shows for the author, that's good design, but the server must still check on every call. Anything the browser can send, an attacker can send without the browser.
Stripe is the default for payments for the same reason Postgres is the default for data: docs, tooling, and a huge number of edge cases already handled. The biggest rule takes care of itself: you never touch card numbers. The card form is Stripe's, and your server only sees IDs.
What surprises people is where the real work lives. Sending a user to Stripe Checkout is short. The real integration is the webhook. A webhook is Stripe calling your server later to say "this payment succeeded" or "this subscription changed". Your database has to be updated by the webhook, not by the success page, because users close tabs and networks drop. The success page is a hope. The webhook is the fact.
import Stripe from "stripe";
import { db } from "@/lib/db";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) return new Response("Missing signature", { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return new Response("Invalid signature", { status: 400 });
}
try {
await db.processedEvent.create({ data: { id: event.id } });
} catch {
return new Response("Already handled", { status: 200 });
}
if (event.type === "customer.subscription.updated") {
const subscription = event.data.object;
await db.user.update({
where: { stripeCustomerId: String(subscription.customer) },
data: { plan: subscription.status },
});
}
return new Response("ok", { status: 200 });
}- request.text() reads the body as the exact raw string. This matters a lot. Stripe's docs say the signature check needs the body exactly as Stripe sent it. If you parse it as JSON first and turn it back into a string, the bytes change and every signature fails.
- The stripe-signature header is Stripe's proof that this request is really from Stripe. Missing header, we answer 400 and stop.
- constructEvent checks that signature with your webhook secret. Anyone on the internet can send a POST to your URL. Without this line, anyone can tell your app "this user paid".
- The processedEvent insert is the idempotency guard. Idempotent means "doing it twice has the same result as doing it once". Stripe may deliver the same event more than once, so we store each event ID in a table with a unique key. The second insert fails, and we answer 200 so Stripe stops retrying, without doing the work twice.
- Inside the if, we react to one event type and update the user's plan from Stripe's data. Stripe stays the source of truth for subscription state; our table is a copy.
- The final 200 tells Stripe we received it. If you answer with an error, Stripe retries later.
Look at the shape: verify at the edge, then trust typed code inside. It's the same pattern as the form, just with a different visitor at the door. The full setup, including test mode and the Stripe CLI, lives in setting up Stripe in Next.js, and the signature details in Stripe webhook verification.
info
For advanced readers: storing the event ID before doing the work, as above, means a crash in the middle leaves the event marked as handled. For money-critical work, wrap the insert and the update in one database transaction, so they succeed or fail together.
Tailwind CSS gives you small classes, each doing one thing: p-4 for padding, text-sm for small text. People judge it by how the HTML looks, which is busy. The problem it solves is real, though. Classic CSS only grows. Every older project has a stylesheet nobody dares to delete from, because nobody knows which page still uses .card-header-alt-2. With utilities, styles live with the markup they style. Delete the component, and its styles are gone too.
The usual objection is "you repeat the same ten classes everywhere". The answer is the component. In React, the thing you reuse isn't a CSS class, it's a component. You write a Badge once and use it fifty times.
import { cn } from "@/lib/utils";
type BadgeProps = {
variant?: "default" | "success";
children: React.ReactNode;
};
export function Badge({ variant = "default", children }: BadgeProps) {
return (
<span
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
variant === "default" && "bg-zinc-100 text-zinc-800",
variant === "success" && "bg-emerald-100 text-emerald-800",
)}
>
{children}
</span>
);
}- cn is a tiny helper that joins class names and skips false values. That's how the variant lines work: when the condition is false, the string just disappears.
- variant?: "default" | "success" is a union type: only those two values are allowed. Write variant="sucess" with a typo and you get a compile error, not a silently unstyled badge in production. Even styling joins the chain.
- variant = "default" in the parameters sets a default, so the plain case needs no prop.
- The long class string is the shared look. It lives in one place. Change it here, and every badge in the app changes.
Tailwind v4 moved its configuration into CSS, and the setup is different from older tutorials. Tailwind CSS v4: what's new and how to migrate walks through it.
When you deploy this stack to Vercel, there's no server in the old sense, a machine you log into and patch. Static pages are built ahead of time and served from a CDN, a network of servers close to your users. Dynamic pages and Server Actions run as functions that the platform starts when requests arrive and scales up when traffic grows.
This part changed, and many articles still describe the old model, where each function instance handled exactly one request and then went idle. Since April 2025, Vercel enables Fluid compute by default for new projects, which lets multiple invocations share a single function instance (Vercel docs: Fluid compute). In plain words: one running copy of your function can serve several requests at once, which cuts down on cold starts and wasted idle time.
The sharp edge is still the same place: where functions meet Postgres. A classic server opens a few database connections and reuses them for its whole life. Many function instances, each opening their own connections, can use up a small database's connection limit fast. The fix is connection pooling: reusing a small set of connections instead of opening new ones. Vercel recommends pooling with Fluid compute, a low idle timeout, and its attachDatabasePool helper from @vercel/functions to close idle connections before an instance is suspended (Vercel guide: connection pooling with functions). Most managed Postgres providers also give you a pooled connection URL. Use it.
Environment variables, preview deployments and what to do when the build passes locally and fails online are covered in the Vercel deployment guide. And if you're wondering whether to host it yourself instead, Vercel vs self-hosting is an honest comparison.
tip
Preview deployments are the quiet best part of this model. Every branch gets its own live URL. "It works on my machine" stops being an argument when every change is already deployed somewhere you can click.
Let's step back and look at what we built, layer by layer. The database schema says what data exists. Prisma turns that into types. Server Components read the data and pass typed props down. Client Components handle clicks. Zod guards every door where outside data comes in: forms, webhooks, query strings. Auth tells you who's knocking, your own checks tell you what they may do. Stripe's webhook is just another door with a stronger lock. Tailwind lives inside typed components. Vercel runs it all, with a pool in front of the database.
So that's all it is. A chain of hand-offs, where every hand-off is checked: by the compiler inside your code, by a validator at the edges, by the database at the bottom. The logos are just the current best tools for each link. When one of them gets replaced in two years, and some will, you won't be lost. You'll ask the same question: does the new tool let the next layer check what it receives?
That's also why I don't learn tools as separate topics anymore. Each one only makes sense next to its neighbours. That's exactly what the courses I took never showed me.
Half of choosing a stack is saying no. Here's my list, with the reason for each.
Microservices for a solo or small-team product
Microservices split an app into many small services that talk over the network. They solve an organisation problem: dozens of engineers stepping on each other in one codebase. If that's not you, you're buying network calls where function calls used to be, partial failures, and versions drifting apart. A modular monolith, one app with clean internal boundaries, carries a small product much further. You can split a module out later if it really needs it. Gluing a distributed system back together is much harder.
GraphQL by default
GraphQL shines when many different clients, like a web app, a mobile app and partner integrations, each need different data from one API. If your only client is your own Next.js front end, Server Components already solve the over-fetching problem GraphQL was made for: the component asks the database for exactly what it needs. Adding a schema, resolvers and a caching client on top is work with no payoff yet.
A separate SPA and API in two repositories
Two deployments, two type systems to keep in sync, CORS settings, decisions about where to store tokens. All to rebuild what one full-stack framework gives you in one project. The split starts paying for itself the day a real second client shows up, usually a mobile app. Until then, it cuts the chain right in the middle.
Where this whole stack is wrong
- Heavy realtime, like multiplayer editing or thousands of open WebSocket connections. Functions are built for request and response, not for holding a connection open for an hour. A long-running Node, Go or Elixir service fits better.
- Native mobile apps. This stack gives you a website a phone can open, not an app in a store.
- A big team with a working platform. Don't migrate because an article said so. The switching cost is bigger than any benefit here.
- Your first week of learning. If you're brand new, this is too much at once. Learn HTML, CSS and JavaScript first, build a few plain pages, then come back. That's not a detour; it's the road.
I learned most of this by trying things and getting them wrong first. I used to start projects with no plan and no sketch, building from my head until it looked right, with no thought for semantics, SEO or security. That works right up until it doesn't. So here are the mistakes this stack invites, and what to do instead.
- Putting "use client" at the top of every file because an error went away. You've just shipped your whole page as JavaScript again. Plan B: move the interactive part into its own small component and keep the parent on the server.
- Trusting TypeScript types for outside data. Types disappear at runtime. Plan B: parse with Zod at every edge, then let types do the rest.
- Checking only that a user is signed in. Signed in is not allowed. Plan B: on every action that touches a record, check that this user owns it or has the right role.
- Updating the database from the payment success page. Plan B: the webhook is the only thing that changes payment state.
- Forgetting connection pooling. Everything works in development, then the database runs out of connections on the first busy day. Plan B: use the pooled connection URL from day one.
- No error handling between the layers. When one link fails, the user sees a blank page. Plan B: [error boundaries done right](/blog/error-boundaries-done-right) shows how to fail gracefully at each level.
- Public endpoints with no limits. Plan B: put rate limits on forms and auth routes, as in [API rate limiting in Next.js](/blog/api-rate-limiting-nextjs).
If your Plan B is "hope", that's not a Plan B. Each of these takes an hour to set up early, and days to fix after real users show up.
info
On AI tools: they can write most of the code in this article for you, and they're genuinely useful for it. But they write what you ask for. If you don't know that a Server Action needs an ownership check, you won't ask for one, and it won't be there. I check what an AI writes for me as far as I can, and knowing the chain is what makes that check possible.
This article is the map. The territory is in the deeper posts, and the order matters, because each layer builds on the promises the one before it set up.
Start with rendering: why the App Router is the right default, then React Server Components, explained, until "where does this run" becomes a reflex. Build something tiny that reads real data before you move on. Reading about the boundary isn't the same as tripping over it.
Then the data layer: settle your ORM with the Prisma vs Drizzle decision and connect it to a real Postgres database, migrations included. Add Zod at trust boundaries at the same time; the two belong together.
Auth comes third, with NextAuth vs Lucia vs Better Auth. Payments come last, with the Stripe setup guide, not because they're hardest, but because they touch every other layer. Then ship it with the Vercel guide.
When it runs, the question changes from "does it work" to "will it survive real users". That's the production-grade code checklist, and it's where I'd go once your first version is standing.
Don't try to learn all of it this week. A little every day, most days, beats a heroic weekend followed by a month off. Take a day or two off when it isn't going in, just don't let the breaks get long. You can do this, and you have to. If you weren't capable, I wouldn't spend my time on you. Just don't give up, and you'll always have me on your side.
