← back to blog
5 min read
  • #books
  • #nextjs
  • #fullstack

Notes from "The Complete Developer" (Martin Krause)

Three ideas from the book that changed how I write Next.js apps.


I read "The Complete Developer" by Martin Krause earlier this year while I was building out parts of my own site. It is a full-stack book that walks through a Next.js app from the ground up, and I went in expecting a tutorial I would skim once and forget. Instead, three ideas stuck. They did not just teach me syntax. They changed how I structure a Next.js project before I write a single component. Here is what I took from it, in my own words, with the small examples that helped it land for me.

1. Decide where each component runs before you write it

The biggest shift was learning to think in server components and client components as a real decision, not an accident. In the Next.js App Router, every component is a server component by default. It runs on the server, never ships its code to the browser, and can talk to your database or read environment secrets directly. A client component is the opt-in: you add "use client" at the top, and now it ships to the browser and can use state, effects, and event handlers.

Before the book, I sprinkled "use client" everywhere out of habit, because that is how the old pages router felt. The book pushed me to start each component by asking a single question: does this thing need the browser? If it only displays data, it stays on the server. If it needs useState, onClick, or a browser API, it becomes a client component, and I try to keep it small.

// Server component (no "use client").
// Runs on the server, can read data directly, ships zero JS for itself.
export default async function PostList() {
  const posts = await getPosts(); // safe to query here
  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}

The practical win is that I fetch data on the server, inside the component that needs it, instead of building an API route and then calling it from the browser with useEffect. Less code, no loading spinner flicker, and the data is there on first paint. When I do need interactivity, I push the "use client" boundary as far down the tree as I can, so most of the page stays server-rendered.

2. Types are a contract between layers, not decoration

I used to treat TypeScript types as something I added at the end to quiet the editor. The book reframed them as a contract that connects the database, the data layer, and the UI. Define the shape once, and every layer agrees on it. When the shape changes, the compiler walks you through every place that now needs updating. That is the whole point, and I had been wasting it.

Now I define a type for the data a feature works with, and that type travels through the stack:

export type Post = {
  id: string;
  title: string;
  publishedAt: Date;
};

// The data function promises to return that shape.
export async function getPosts(): Promise<Post[]> {
  // ...query and map rows into Post objects
}

Because getPosts returns Promise<Post[]>, my PostList component already knows what it is getting. If I rename title to headline in the type, TypeScript flags the component, the data function, and anywhere else that touches a post. I am not hunting for broken spots at runtime. The contract does the bookkeeping for me.

The honest caveat I learned the hard way: a type is only a promise about the shape, not a guarantee the data is real. Types are erased when the code runs, so anything crossing a trust boundary, like a form submission or an external API response, still needs actual runtime validation. A type tells you what you expect. It does not check that you got it. That distinction matters a lot once you start thinking about security, which is where my head is most days right now.

3. Keep data access out of your components

The third idea sounds obvious written down, but I was not doing it. The book separates data access from UI. Components render. A separate layer talks to the database. They do not mix.

In practice that means I do not write a raw query inside a page or component anymore. I write a function in something like lib/data/posts.ts, and the component imports it.

// lib/data/posts.ts
import { db } from "@/lib/db";
import type { Post } from "@/types";

export async function getPostBySlug(slug: string): Promise<Post | null> {
  return db.post.findUnique({ where: { slug } });
}
// app/blog/[slug]/page.tsx
import { getPostBySlug } from "@/lib/data/posts";

export default async function Page({ params }: { params: { slug: string } }) {
  const post = await getPostBySlug(params.slug);
  if (!post) return <p>Not found.</p>;
  return <article>{post.title}</article>;
}

A few things got easier once I drew that line. The query lives in one place, so when I change how a post is loaded, I change it once. The component stays readable, because it is about layout, not SQL. And when I want to reuse the same query in a different page or a server action, I just import it again. It also makes the code easier to reason about for security review, since every path to the database goes through a small set of functions I can actually look at, instead of being scattered across twenty components.

What changed for me

None of this is exotic. It is the kind of structure experienced developers already use without thinking about it. What the book did was make the reasons explicit, so I stopped cargo-culting patterns and started making deliberate choices: run on the server unless you need the browser, let types carry a contract through the stack, and keep data access in its own layer.

I am still a learner working through this, and I will probably refine these habits again next year. If you are building your own Next.js project and want a small free starting point, I keep some of my notes and tools in the shop at aldowebsitellc.xyz/shop. Take what is useful, leave the rest.

$ share

community rating

$ ls ./comments

sign in or create an account to rate and comment.

no comments yet, be first.