# pean.dev — Full Writing Archive
> Full text of all articles published on pean.dev by Andrii Petlovanyi, a product-minded full stack developer from Rivne, Ukraine with practical AI product development experience.
Author: Andrii Petlovanyi
Role: Product-Minded Full Stack Developer
Site: https://www.pean.dev
GitHub: https://github.com/andrii-petlovanyi
LinkedIn: https://www.linkedin.com/in/andriipetlovanyi/
For the site index, see: https://www.pean.dev/llms.txt
---
## AI-Driven Development: When Code Stops Being the Bottleneck
URL: https://www.pean.dev/blog/ai-driven-development-when-code-is-not-the-bottleneck
Published: 2026-07-16
Description: AI-driven development shortens the path from product idea to tested software—without handing the important decisions to an AI agent.
There is a familiar moment in every product team.
Someone writes, “Can we add this small feature?” Twenty minutes later, the “small feature” has three UI states, two awkward edge cases, a new database field, an integration, analytics, an email, and one very uncomfortable question about what happens when the user clicks the button twice.
For a long time, the distance between that moment and something real was mostly implementation work. Find the right files. Reload the context. Look up the thing you half remember. Build the skeleton. Break the skeleton. Fix the skeleton. Explain all of it in a pull request.
Now, an AI can cover a surprising amount of that ground. It can map an unfamiliar codebase, draft a plan, build a first pass of a screen, find every call site for a type change, write tests, explain a strange log, and even argue with your approach if you ask it to.
That changes the pace of work.
But here is the important correction: **AI-driven development is not about AI building the product instead of the team.**
It is about making the path from a thought to a useful test much shorter. A person sets the direction and the bar. AI removes some of the mechanical drag. The team sees something real earlier, checks its assumptions sooner, and corrects course before getting emotionally attached to the wrong solution.
Code has become cheaper.
Being wrong about *what* to build has not.
## It is not autocomplete with a bigger ego
AI-assisted development is the familiar version: an assistant explains an error, finishes a function, writes a test, or produces the regular expression nobody wants to be personally responsible for.
AI-driven development is broader. AI becomes part of the whole loop:
- turning a fuzzy idea into a clearer problem;
- recovering codebase context faster than opening ten tabs in an editor;
- comparing implementation options and surfacing risks;
- building a small end-to-end slice instead of isolated snippets;
- handling repeatable work such as types, tests, docs, migrations, and refactors;
- running checks and helping investigate where a solution disagrees with reality;
- shortening the feedback loop after a release.
In other words, it is not just another item in the toolbox. It changes the rhythm of making software.
I have written separately about [how I use AI coding agents on real projects](/blog/how-i-use-ai-coding-agents-on-real-projects): where they genuinely save time and where I still keep a very close eye on the work. This is the wider view. What happens when AI is not only touching a line of code, but entering every stage of how a product gets made?
The old rhythm was easy to recognise: think for a long time, build for a long time, then show a big result.
The healthier rhythm now is: clarify quickly, build a small but complete scenario, check whether it is telling the truth, then make the next decision with better information.
Not because speed is a new religion.
Because the earlier you see the real product, the less likely you are to spend a week producing a beautiful answer to a question nobody asked.
## The biggest change happens before the first line of code
It is tempting to look at AI and think only about code generation. Fair enough: seeing a usable component appear in a minute is hard not to enjoy.
But the most valuable part often starts *before* code.
Imagine a backlog item that says: “Add repeat order.”
An AI can help turn that into better questions:
- Who is likely to repeat an order, and at what moment?
- What happens when an item is no longer available or its price has changed?
- Does the user see a pre-filled cart, or does the system try to charge them automatically?
- What counts as success: a click, a filled cart, or a paid order?
- Which data already exists, and which data would we need to add?
- What deliberately stays out of version one?
That does not replace product thinking. It forces it into the open.
Instead of “please add a button,” a team can make a real agreement:
> A customer should be able to repeat a previous order quickly, but before payment they must see current pricing, unavailable items, and have a chance to edit the cart.
That is the kind of brief an AI can become genuinely useful with. It is no longer guessing what you meant.
Give it a vague task, though, and it will give you vagueness back — confidently, neatly formatted, and with very respectable variable names.
## Progress is becoming a learning loop, not a growing diff
It used to be easy to measure movement by what you could see: tickets closed, screens built, lines changed.
Once a first version can appear almost instantly, that becomes a weak metric. It rewards more surface area, not more value.
A better unit of progress looks like this:
1. We have a hypothesis about a user or system problem.
2. We build the smallest scenario that can test it.
3. We look at real behaviour: users, data, failures, latency, cost.
4. We make the next decision a little smarter.
AI is excellent at compressing step two. It does not remove steps one, three, or four.
That is the real shift. The advantage does not go to the team that generates interfaces fastest. It goes to the team that learns from reality fastest, and does not mistake the first working version for the answer to every question.
## What a healthy AI-driven workflow actually looks like
There is no magic 800-word prompt here. There are just a few habits that make the difference between a genuine speed-up and an expensive lottery.
### 1. Start with context, not “build this”
A prompt like “add authentication” almost guarantees a demo. It may be a nice demo. It may even have polished animation. But it is still a demo.
Give the model a real frame instead:
> This is a Next.js app with email and Google sign-in, an existing design system, and a separate backend. We need registration for a B2B product. New users must confirm their email. Do not add dependencies. First inspect the closest existing flow, then show the plan, files involved, risks, and edge cases. Only write code after that.
Nothing about this is magic. It is just reality.
An AI does not know your old trade-offs, the constraint your mobile team depends on, or why that slightly scary file in `lib/` exists. If something matters, it needs to be in the context, not only in your head.
### 2. Ask for a map before asking for changes
For a non-trivial task, I want the AI to answer four things first:
- where the relevant logic lives now;
- which files and contracts the change will touch;
- what could break;
- what the smallest useful plan is.
That can sound like extra time. In practice, it saves you from reading a huge diff that was heading in the wrong direction from the first decision.
A plan is much easier to challenge than code. And killing a bad assumption before it touches ten files is one of the best kinds of productivity.
### 3. Cut work into vertical slices, not decorative chunks
“Build us a CRM” is not a task. It is a way to make both the team and the model miserable.
Take one complete scenario instead. A manager creates a contact, the system validates the email, the contact appears in the list, and a retry cannot create a duplicate.
That slice can travel from UI to database, be covered by a test, and be shown to someone. It has boundaries, data, behaviour, and an outcome.
This is where AI is especially useful: it can connect the frontend, API, types, data model, and checks quickly. Ask it to “do everything,” though, and it will just as eagerly spread logic across the whole project.
A small, living slice is almost always more useful than an ambitious chunk that exists only as a list of tickets.
### 4. Let the AI build, then let it annoy you
One of the best AI roles is an endlessly patient, slightly annoying opponent.
After a first solution, ask it to:
- find hidden states and failure paths;
- explain what happens on a slow connection or a double click;
- suggest a cheaper or simpler version;
- check whether the same logic already exists somewhere else;
- role-play the user who does everything in the wrong order;
- write negative tests, not only the happy path.
Do not treat the answer as truth. Treat it as a way to see your own blind spots before production finds them for you at 3am.
### 5. Build speed on checks, not trust
When code is generated faster, bugs are generated faster too. No surprise there.
That is why types, tests, linters, CI, migration checks, and a manual pass through the critical path are not bureaucracy. They are the seatbelt that lets you go faster.
Let the AI run checks, read failures, and fix the obvious issues before review. But a person still needs to decide *what* deserves testing. A model can happily test the easy part and miss the part of the flow the user came for in the first place.
## What I would hand to AI — and what I would not
There is no mystical “safe / unsafe” line. There is a cost-of-being-wrong line.
I am happy to give AI work where coverage, speed, and consistency matter most:
- codebase archaeology and dependency tracing;
- mechanical refactors;
- the first pass of a well-specified feature;
- matching changes across types, DTOs, clients, forms, and API contracts;
- first drafts of tests, documentation, and release notes;
- log analysis, suspicious-pattern hunting, and checklists;
- tedious changes across dozens of files where humans are likely to miss one.
These are the decisions I still keep close:
- the product “why” and priorities;
- architecture, data models, and choices we will live with for years;
- permissions, authentication, payments, and personal data;
- migrations against real data;
- critical integrations and trust-sensitive failure paths;
- the boundary of a feature: what we are *deliberately not building* yet.
This is not distrust for the sake of a nice slogan. Those decisions have history, consequences, and a price the model will not pay alongside you.
AI can prepare very good options.
The responsibility for choosing one is still human.
## Vibe coding is great. Just do not confuse it with the finish line
Vibe coding brought a healthy sense of play back to development. You describe an idea in plain language, and an hour later there is something to click, show a colleague, or put in front of a test user. For prototypes, internal tools, demos, and early validation, that is genuinely fun.
The problem starts when the prototype quietly becomes production.
“It works on my machine” does not mean:
- it is safe;
- it survives a bad connection;
- the next person understands how to maintain it;
- data does not duplicate when someone clicks twice;
- it can be changed in three months without an archaeological expedition.
So the rule I like is simple:
> Vibe code for discovery. Engineer for consequences.
Let AI help you find the shape of an idea and give it a first life. Before you ship it, though, make sure the foundation exists: data integrity, access control, failure states, performance, clarity, and a sane way to change the thing later.
## Context is becoming part of engineering
In the old model, context often lived in people’s heads, old Slack threads, and the phrase “you know how we do things here.”
AI makes the weakness of that setup painfully obvious.
If a project rule is nowhere to be found, an agent cannot infer it reliably. It will fill the gap with the most plausible answer. And “plausible” is not the same as “correct for this product.”
That is why a good AI-driven team collects more than code. It builds usable context:
- short architecture and style rules;
- domain terms that should not be interpreted by guesswork;
- examples of good implementations for similar flows;
- security boundaries and data that must not go to external services;
- commands to verify changes and clear definitions of done;
- the history of important decisions: not only what happened, but why.
This is not documentation for documentation’s sake. It means a new teammate, future you, or an AI agent does not need to start every task with an archaeological dig.
And yes, good context is usually more valuable than another “genius prompt.”
## Roles are not disappearing. They are becoming more honest
For developers, AI removes some mechanical typing and makes the valuable parts of the job more visible: seeing dependencies, feeling where a system is fragile, explaining complexity simply, and protecting quality over time.
For designers, it can accelerate alternative flows, content, states, and small prototypes. It does not replace taste, empathy, or knowing what a person on the other side of the screen will actually feel.
For product people, it is a chance to stop polishing a specification in a vacuum for weeks, build a living scenario in a day, listen to a reaction, and move past abstract arguments.
For founders, it creates a way to test more bets for less money. It also creates a temptation to throw ten “AI features” at the market, none of which makes a user’s life easier.
Across every role, one skill becomes more valuable: **being able to state an intent clearly.**
Not “build a dashboard.”
“Help an operations manager see where today’s process has stopped, and what to do next, in under thirty seconds.”
When an instruction can be executed very quickly, a vague instruction becomes more expensive than ever.
## The most dangerous illusion: fast output equals progress
AI is very good at making us feel productive.
In one evening, there can be four screens, three integrations, a stack of commits, and a beautiful pull request. It is extremely tempting to say, “we are flying.”
Sometimes you are.
Sometimes you are just moving in the wrong direction at an impressive speed.
So before a large change, I would make three questions non-negotiable:
1. What specific user or system behaviour are we trying to change?
2. What is the smallest piece of evidence that would prove the change worked?
3. What becomes expensive if we are wrong?
Those questions do not slow a team down. They remove fake speed — the kind that ends with an urgent rollback, an apology to users, and half a day spent finding the weird side effect nobody meant to create.
## It is not “AI will replace developers.” Development is getting closer to the product.
I am not very interested in the question of whether AI will “replace” developers. It is too flat.
The more interesting thing is that code is no longer such an expensive, slow bottleneck. That exposes the work that was always hard: understanding people, choosing the right problem, building a system that holds up, spotting risk before it becomes an incident, and owning the result.
AI does not remove the need to think.
It removes some of the excuses for thinking too little.
Used well, this does not make development less engineering-heavy. It makes it more product-aware, more honest, and hopefully a little more fun.
AI can drive the pace.
We still choose the direction.
## FAQ
### What is the difference between AI-driven development and AI-assisted development?
AI-assisted development is point help: explaining code, writing a function, or generating a test. AI-driven development puts AI into the full workflow, from clarifying the problem and planning through implementation, verification, documentation, and learning after release. It accelerates the process without taking responsibility for the decisions.
### Does AI-driven development mean teams can skip specifications?
No. The faster an AI can execute an instruction, the more important it is to make that instruction clear. You do not need a 40-page document, but you do need the user, desired behaviour, constraints, success criteria, and risks. Without that, AI will generate something plausible quickly, not necessarily something needed.
### Where does AI create the most value in software development?
It is often strongest in large-codebase research, mechanical refactors, first passes of well-specified features, coordinated frontend and backend changes, tests, documentation, and running checks. It works best when a task has clear boundaries and a short feedback loop.
### What should not be delegated to AI without human review?
Architecture, access control, authentication, payments, personal data, migrations, critical integrations, and big product decisions all deserve close human review. AI can help generate options and implementation, but people need to own the final decision and verification because the cost of being wrong is high.
---
## Next.js App Router Architecture in 2026: How I Structure Production Apps
URL: https://www.pean.dev/blog/nextjs-app-router-architecture-in-2026
Published: 2026-07-13
Description: A practical way to structure a production Next.js App Router project: routes, Server Components, Server Actions, Route Handlers, auth, validation, caching, and the folders that keep it understandable.
Most Next.js architecture advice looks clean because it stops before the hard
part.
You get a folder tree, a few arrows, and a confident rule like “keep business
logic out of components.” Then the real product arrives: authenticated
dashboards, public pages, forms, webhooks, background jobs, cached reads, file
uploads, and a mobile app that needs the same data.
That is when the neat diagram starts collecting exceptions.
I have rebuilt the same kind of App Router structure enough times to notice
which decisions keep paying rent and which ones only look impressive in a
repository screenshot. The architecture that survives is rarely the one with
the most layers. It is the one where every new piece of code has an obvious
home, and where crossing a boundary is a deliberate act.
So this is how I structure a production **Next.js App Router app in 2026**. It
is not the only valid structure, and it is not a starter template disguised as
a universal truth. It is a set of practical defaults for products that have
real users, real permissions, and a good chance of still existing a year from
now.
## The short answer
My default architecture is:
- **Routes compose the screen.** Pages and layouts decide what appears for a
URL.
- **Server Components read data.** They call server-side query functions
directly.
- **Client Components own interaction.** State, event handlers, browser APIs,
and instant feedback stay in small client boundaries.
- **Server Actions handle mutations from my own UI.** Forms and product actions
do not need a private HTTP endpoint by default.
- **Route Handlers expose HTTP contracts.** I use them for webhooks, public
APIs, mobile clients, extensions, feeds, and file responses.
- **A data access layer owns authorization.** Hiding a button is UX; checking
access beside the data is security.
- **Caching follows product semantics.** I cache because I know how stale a
result may be, not because a framework feature exists.
The sentence I keep in my head is:
> Routes compose, domain modules do the work, and every boundary validates what
> enters it.
That is almost the whole architecture.

## Start with product boundaries, not technical layers
The official [Next.js project structure documentation](https://nextjs.org/docs/app/getting-started/project-structure)
is intentionally flexible. The framework gives meaning to files such as
`page.tsx`, `layout.tsx`, `loading.tsx`, and `route.ts`, but it does not decide
how the rest of your product should be organized.
That freedom is useful. It is also how projects end up with folders called
`services`, `utils`, `helpers`, `repositories`, and `managers`, where the only
way to find code is to remember which synonym someone chose six months ago.
I prefer product or domain boundaries.
For a small-to-medium app, the structure often starts like this:
```txt
src/
app/
(marketing)/
page.tsx
pricing/
page.tsx
(app)/
layout.tsx
projects/
page.tsx
loading.tsx
error.tsx
[projectId]/
page.tsx
_components/
project-header.tsx
actions.ts
api/
webhooks/
stripe/
route.ts
layout.tsx
components/
ui/
button.tsx
dialog.tsx
lib/
auth/
dal.ts
projects/
queries.ts
mutations.ts
schemas.ts
integrations/
stripe.ts
db.ts
```
This tree is not sacred. The useful part is the direction of ownership.
The `app` directory owns routing and screen composition. `lib/projects` owns
the reusable server-side rules for projects. `lib/auth` owns session and
permission checks. `components/ui` contains genuinely shared interface pieces.
The Stripe webhook is public because a `route.ts` makes it public; the Stripe
client itself lives outside the route so other server code can reuse it.
I colocate a component inside a route when that route is the only place using
it. I move it to a shared folder only after it becomes shared. Starting with
everything in `components` feels organized for about a week, then turns the
folder into a warehouse.
Route groups such as `(marketing)` and `(app)` are useful because they let me
separate layouts and product areas without changing the URL. Private folders
such as `_components` make it clear that a folder is an implementation detail,
not another route segment.
The goal is not a perfect tree. The goal is that someone opening
`app/(app)/projects/[projectId]/page.tsx` can follow the feature without touring
the entire repository.
## Let routes compose; do not make them carry the business
A page should be readable as a description of the screen.
```tsx
// app/(app)/projects/[projectId]/page.tsx
import { notFound } from 'next/navigation';
import { getProjectForUser } from '@/lib/projects/queries';
import { ProjectHeader } from './_components/project-header';
import { ProjectActivity } from './_components/project-activity';
export default async function ProjectPage({
params,
}: {
params: Promise<{ projectId: string }>;
}) {
const { projectId } = await params;
const project = await getProjectForUser(projectId);
if (!project) notFound();
return (
);
}
```
The route knows the URL, chooses the data needed for the screen, and composes
the interface. It does not contain a forty-line database query, permission
rules, Stripe mapping, and email side effect.
I am not dogmatic about extracting every three-line query. A tiny page can be a
tiny page. I extract logic when it represents a reusable product rule, contains
security-sensitive behavior, or makes the route hard to scan.
That threshold matters. Architecture can become a way of hiding simple code
behind five jumps. If opening a user profile requires following
`page -> service -> repository -> adapter -> client` and each layer forwards
the same arguments, the layers are not protecting anything. They are charging
navigation tax.
## Server Components are the default, not the whole strategy
App Router pages and layouts are Server Components by default. I keep them that
way for as long as the browser is not required.
That means Server Components usually handle:
- initial data reads
- permission-aware rendering
- page and layout composition
- secret-dependent work
- expensive formatting
- SEO-critical content
- passing small, serializable props into interactive children
The browser boundary begins where interaction begins: state, event handlers,
effects, refs, browser APIs, or a library that depends on the DOM.
I do not measure success by having zero Client Components. A search input that
feels immediate is doing useful client work. A sortable table may be easier to
use when some state stays in the browser. The mistake is moving an entire page
behind `'use client'` because one leaf needs `onClick`.
Once a module becomes a client boundary, its imported module graph joins the
client bundle. Keeping that boundary low protects the rest of the route without
making the interface less interactive.
I wrote the component-level rules in more detail in
[Server vs Client Components in Next.js](/blog/nextjs-server-vs-client-components-article).
The architectural version is simpler: the server owns the screen; the client
owns interaction moments.
## Read data directly on the server
One habit from older React architectures is surprisingly hard to drop: calling
your own API from your own server-rendered page.
```tsx
// Avoid this inside a Server Component
const response = await fetch(`${process.env.APP_URL}/api/projects`);
const projects = await response.json();
```
That request leaves server code, enters an HTTP endpoint in the same
application, parses a response, and often repeats authentication that was
already available. It adds a network-shaped boundary without gaining an actual
external client.
I call the query function directly instead:
```tsx
const projects = await getProjectsForCurrentUser();
```
The query function can still enforce access, shape the returned data, and be
tested independently. A Route Handler can call the same underlying function if
a mobile app later needs an endpoint.
This separation gives me two reusable things:
- a server-side capability, such as `getProjectsForUser(userId)`
- an optional HTTP representation of that capability
The HTTP layer is no longer the business logic. It is one way into it.
That is the distinction that keeps a Next.js backend from turning into a set of
endpoints the frontend has to call even when both sides are already running in
the same process.
## Put authorization beside data access
Authentication answers “who is this?” Authorization answers “may this person
do this specific thing?” The second question is where production apps usually
get interesting.
I centralize those checks in a small data access layer and in domain query or
mutation functions.
```ts
// lib/projects/queries.ts
import { verifySession } from '@/lib/auth/dal';
import { db } from '@/lib/db';
export async function getProjectForUser(projectId: string) {
const session = await verifySession();
return db.project.findFirst({
where: {
id: projectId,
members: { some: { userId: session.userId } },
},
select: {
id: true,
name: true,
status: true,
updatedAt: true,
},
});
}
```
Notice that ownership is part of the query. I do not fetch any project by ID
and then hope every caller remembers to compare a `userId` afterward.
I also return only the fields the screen needs. That is less about ceremony and
more about making accidental data exposure harder. A Server Component does not
send all its code to the browser, but data passed into a Client Component still
crosses the server-client boundary. Smaller, intentional objects are easier to
reason about.
Layouts and Proxy can perform optimistic checks for navigation and UX, but I do
not treat them as the final security boundary. The official
[Next.js authentication guide](https://nextjs.org/docs/app/guides/authentication)
recommends secure checks close to data access and explicitly says to treat both
Server Actions and Route Handlers like public-facing entry points.
That matches the rule I use: every mutation re-checks authorization, even if
the button that triggered it was visible only to an admin.
## Use Server Actions for mutations owned by your UI
If a form or button in my Next.js interface triggers a mutation, I usually
start with a Server Action.
```ts
// app/(app)/projects/[projectId]/actions.ts
'use server';
import { updateTag } from 'next/cache';
import { verifySession } from '@/lib/auth/dal';
import { renameProjectForUser } from '@/lib/projects/mutations';
import { renameProjectSchema } from '@/lib/projects/schemas';
export async function renameProject(input: unknown) {
const session = await verifySession();
const parsed = renameProjectSchema.safeParse(input);
if (!parsed.success) {
return { ok: false, errors: parsed.error.flatten().fieldErrors };
}
await renameProjectForUser({
...parsed.data,
userId: session.userId,
});
updateTag(`project-${parsed.data.projectId}`);
return { ok: true };
}
```
This example assumes Cache Components are enabled; I will get to that detail in
a moment.
The important architecture is outside the syntax. The action is an entry
point. It verifies the session, validates untrusted input, calls a domain
mutation, and updates the relevant cached read. The deeper mutation owns the
database transaction and the rule that this user may rename this project.
That makes the action small enough to understand without making it useless.
I do not put a Server Action in a global `actions.ts` just because all actions
share a directive. I colocate route-specific actions with their route and move
shared actions into the relevant domain module when reuse becomes real.
For a deeper comparison, see
[Server Actions vs API Routes in Next.js](/blog/server-actions-vs-api-routes-in-nextjs-rules-i-use).
## Use Route Handlers when HTTP is part of the product
A Route Handler is the right tool when the URL itself is a contract.
I use one for:
- payment and authentication webhooks
- endpoints called by a mobile app or browser extension
- public or partner APIs
- OAuth callbacks
- RSS, XML, calendar, and file responses
- endpoints that need explicit HTTP methods, headers, or status codes
```ts
// app/api/webhooks/stripe/route.ts
import { handleStripeEvent } from '@/lib/integrations/stripe';
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 });
}
await handleStripeEvent({ body, signature });
return new Response('OK');
}
```
The route owns the HTTP concerns. The integration module verifies the signature,
interprets the event, and performs idempotent work. That split makes it possible
to test the integration without constructing a framework request for every
case.
I do not create a Route Handler only because “backend code belongs under
`/api`.” In an App Router project, server code can live wherever the server-side
domain needs it. A Route Handler is for a real request boundary.
The complete decision rules are in
[Next.js API Routes in 2026](/blog/nextjs-api-routes-in-2026-route-handlers-server-actions-when-to-use-each).

## Validate at the boundary, then work with trusted data
TypeScript does not validate a form submission, JSON body, URL parameter, or
webhook payload. It describes what our code expects after the value has entered
the program.
So I validate at every place untrusted data enters:
- Server Action arguments and `FormData`
- Route Handler bodies, query strings, and headers
- dynamic route parameters when the accepted format matters
- environment variables during startup
- responses from third-party services when a bad shape would be expensive
After validation, I pass a narrow typed object into the domain function. I do
not pass the whole `Request`, `FormData`, or framework-specific object through
three layers.
This keeps the center of the application boring in a good way. Domain code
works with values such as `{ projectId, name, userId }`, while entry points deal
with browsers, HTTP, parsing, and error responses.
It also makes testing much more direct. A permission rule or database mutation
does not need a fake Next.js request to prove that it works.
## Cache according to how the product may be stale
Caching is the part of App Router architecture where generic advice ages
fastest.
In Next.js 16, Cache Components are available behind the `cacheComponents`
configuration flag. When enabled, the model centers on `'use cache'`,
`cacheLife`, `cacheTag`, `updateTag`, and `revalidateTag`. If the flag is not
enabled, the previous caching model still applies.
I make that choice explicit in the project instead of mixing examples from both
models.
```ts
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;
```
For public data that can be a few minutes old, a cached query may look like
this:
```ts
// lib/catalog/queries.ts
import { cacheLife, cacheTag } from 'next/cache';
import { db } from '@/lib/db';
export async function getPublicCatalog() {
'use cache';
cacheLife('minutes');
cacheTag('catalog');
return db.product.findMany({
where: { published: true },
orderBy: { updatedAt: 'desc' },
});
}
```
After an editor changes a product, the invalidation depends on what the user
expects:
- `updateTag('catalog')` expires it immediately. This fits a
read-your-own-writes flow where the editor should see the change now.
- `revalidateTag('catalog', 'max')` serves stale data while refreshing in the
background. This fits content where a short delay is acceptable.
- `revalidatePath('/catalog')` invalidates a route. It is useful, but less
precise than invalidating the data shared by several routes.
The official [Cache Components guide](https://nextjs.org/docs/app/getting-started/partial-prerendering)
and [revalidation guide](https://nextjs.org/docs/app/getting-started/revalidating)
describe the current APIs. The architecture decision still belongs to the
product: how stale can this be, who must see a write immediately, and how large
is the cost of recomputing it?
I am conservative with personalized and permission-sensitive data. Passing a
verified user ID into a carefully scoped cached function is very different from
putting a broad cache around a function that reads session state and returns
private rows. If the security story is not obvious, I leave the query dynamic
until it is.
A page that is correct and slightly slower is easier to improve than a fast
page that occasionally shows the wrong person's data.
## Make loading, errors, and empty states part of the route
Production architecture is not only where successful data comes from. It is
also where waiting and failure live.
The App Router gives each segment natural places for those states:
```txt
projects/
page.tsx
loading.tsx
error.tsx
not-found.tsx
```
I keep a loading boundary near the slow work it represents. A dashboard should
not turn into one giant spinner because one activity panel is waiting on a
slower query. Suspense boundaries and route-level loading files are architecture
tools because they decide which parts of the screen can arrive independently.
The same goes for errors. A useful `error.tsx` should let the user recover or
retry when that makes sense. `not-found.tsx` should describe a missing resource,
not catch every permission failure and server exception under the same vague
message.
Empty states belong in the normal component path. “No projects yet” is not an
error; it is a valid product state, often with the most important call to action
on the page.
These files are easy to postpone because the happy path demos well. They are
also what makes an application feel deliberate once the network, database, and
users stop behaving perfectly.
## Keep third-party systems behind small server modules
Payment providers, email clients, analytics APIs, AI models, and storage SDKs
change faster than the product concepts around them.
I keep those SDK details in small modules under something like
`lib/integrations`. The rest of the app asks for an outcome:
```ts
await sendProjectInvitation({ email, projectName, inviteUrl });
```
It does not build a provider-specific payload inside a Server Action.
This is one place where an abstraction earns its keep. It protects the product
code from vendor types, centralizes retries and observability, and gives me one
place to handle a provider's strange edge cases.
I still avoid a grand universal `EmailService` with five implementations nobody
plans to use. A small function with a product-shaped name is usually enough.
Background jobs deserve a similarly explicit boundary. A Server Action or
Route Handler can enqueue work, but long-running or retryable tasks should not
pretend the original request will remain alive forever. The queue and worker
may live outside Next.js; the important part is that the request path hands off
the job intentionally and records enough state to retry it safely.
## The folder structure should grow after the problem does
I have a rough progression for Next.js project structure.
At the beginning, I colocate aggressively. A page, its small components, and an
action can live together. Shared UI goes into `components/ui`. Database access
can start in a focused `lib` module.
As the product grows, I extract around pressure:
- repeated permission checks become a data access function
- repeated business rules become a domain mutation
- an external client creates a real Route Handler
- a provider SDK gets an integration module
- several routes sharing data get a cache tag strategy
- a slow section gets its own Suspense boundary
I do not add a repository layer because a diagram says applications have one.
I add it when database details genuinely need isolation or multiple callers are
duplicating query behavior. I do not add a global state library because the app
has state. I add one when URL state, server state, local state, and context no
longer cover a real cross-screen interaction cleanly.
The best Next.js architecture is usually one step ahead of current complexity,
not six steps ahead of imaginary scale.
## A practical request walkthrough
Imagine a user renames a project from a dashboard.
Here is the full path I want:
1. A Server Component loads the project through `getProjectForUser`.
2. The page passes the project name and ID to a small interactive form.
3. The form calls a colocated Server Action.
4. The action verifies the session and validates the submitted values.
5. A domain mutation updates only a project the user may edit.
6. The mutation completes before the relevant cache tag is invalidated.
7. The UI shows the new name immediately and handles validation errors without
losing the form state.
Now imagine Stripe changes that project's subscription.
1. Stripe calls a Route Handler at a stable public URL.
2. The handler reads the raw body and required signature header.
3. The integration module verifies the signature.
4. The event handler checks idempotency before changing the database.
5. The relevant subscription and project cache tags are revalidated.
6. The handler returns the HTTP status Stripe expects.
The database mutation may touch the same project in both flows. The entry
points are different because the callers and contracts are different.
That is what I mean by boundaries. They are not folders for their own sake.
They describe who is calling, what can be trusted, and what kind of response is
required.
## Mistakes I would avoid in a new App Router project
These are the patterns I now treat as early warnings:
- putting `'use client'` on a page because one nested control is interactive
- fetching your own Route Handler from a Server Component
- trusting a layout redirect as the only authorization check
- putting every Server Action in one global file
- caching private data before the ownership model is clear
- invalidating the entire route when one tagged query changed
- returning full database records to interactive components
- passing `Request` or `FormData` deep into domain code
- creating `utils.ts` as the default home for unrelated logic
- adding service and repository layers that only forward arguments
- performing a long retryable job inside the original request
- treating `loading.tsx`, `error.tsx`, and empty states as final-week polish
None of these choices automatically ruins a project. I have shipped several of
them. The problem is accumulation. Each one makes the next feature slightly
harder to place, slightly harder to secure, or slightly harder to debug.
Good architecture is mostly the absence of that friction.
## The production checklist I actually use
Before I call an App Router feature complete, I ask:
- Can I understand the route by reading its page and layout?
- Is the client boundary limited to code that needs browser capability?
- Does server-rendered code call domain queries directly?
- Does every Server Action validate input and re-check authorization?
- Does every Route Handler behave like a public endpoint?
- Are sensitive reads scoped by ownership in the data access layer?
- Is the returned data narrower than the database record?
- Does the cache policy state how stale the data may be?
- Will the user see their own write when they expect to?
- Are loading, empty, not-found, and error states intentional?
- Can external events be retried without duplicating work?
- Is shared code actually shared, or merely predicted to be shared?
If the answers are clear, the folder tree is usually fine.
## FAQ
### What is the best Next.js App Router architecture in 2026?
There is no single best folder tree. A strong default is to let routes compose
screens, use Server Components for reads, keep Client Components focused on
interaction, use Server Actions for UI-owned mutations, use Route Handlers for
real HTTP contracts, and centralize authorization close to data access.
### How should I organize a Next.js App Router project?
Organize routes by URL and product area inside `app`, colocate route-specific
components and actions, keep truly shared UI in a shared components folder, and
group reusable server logic by domain such as `projects`, `billing`, or `auth`.
Add technical layers only when they remove real duplication or isolate a real
boundary.
### Should a Server Component call a Next.js Route Handler?
Usually no. If both live in the same application, call the shared server-side
query or domain function directly. Use a Route Handler when an external client
or an HTTP-specific contract actually needs the endpoint.
### Where should authentication and authorization live in Next.js?
Session verification can be centralized in a data access layer, while secure
authorization checks should also happen close to each sensitive query and
mutation. Layouts and Proxy can improve navigation and UX, but they should not
be the only protection for private data or actions.
### Should I use Server Actions or Route Handlers for mutations?
Use a Server Action when the mutation is triggered by your own Next.js UI. Use
a Route Handler when a webhook, mobile app, extension, partner, or other HTTP
client needs a stable endpoint. Both must validate input and check permissions.
### Do I need a service and repository layer in Next.js?
Not by default. Add a layer when it isolates meaningful complexity, protects a
domain rule, or removes duplication. If it only forwards the same arguments to
the next file, it is probably making the code harder to follow without making
it safer.
## Conclusion
The App Router gives us more useful primitives than older Next.js applications
had: Server Components, nested layouts, streaming boundaries, Server Actions,
Route Handlers, and explicit caching controls. More primitives do not require a
more elaborate architecture. They require clearer decisions about where each
kind of work belongs.
My production Next.js architecture in 2026 is intentionally plain. Routes
compose screens. Server Components read. Client Components interact. Server
Actions mutate for the UI. Route Handlers speak HTTP. Domain modules protect
the rules that should survive all of those entry points.
The structure will change as the product grows. That is healthy. The important
part is that it grows in response to real pressure, while the path from a user
action to a permission check to a database write stays short enough to hold in
your head.
That is the kind of architecture I trust in production: not the one that looks
most advanced on day one, but the one that still makes sense when the simple
app is no longer simple.
---
## MCP Is Not the Product: What AI Agents Actually Need Before Tool Access
URL: https://www.pean.dev/blog/mcp-is-not-the-product-ai-agent-tool-access
Published: 2026-07-06
Description: A human, practical look at MCP, AI agents, tool access, permissions, context, and why connecting an agent to more systems is not the same as designing a useful AI product.
There is a funny moment that happens with almost every new technical layer.
At first, nobody knows what it is.
Then suddenly everyone talks about it like it explains the future.
MCP is in that phase now.
If you spend time around AI products, coding agents, developer tools, or agentic workflows, you have probably seen it everywhere. MCP servers. MCP clients. MCP tools. MCP marketplaces. MCP for databases. MCP for browsers. MCP for internal systems. MCP as the missing piece that will finally let AI agents do real work.
Some of that excitement is justified.
[Model Context Protocol](https://modelcontextprotocol.io/docs/getting-started/intro) gives AI applications a more standard way to connect to external systems: files, databases, APIs, search tools, business software, workflows, and other sources of context. [Anthropic introduced it](https://www.anthropic.com/news/model-context-protocol) as an open standard for connecting AI assistants to the systems where data lives, and OpenAI's [Apps SDK](https://developers.openai.com/apps-sdk) also builds on MCP for ChatGPT apps.
That matters.
But I do not think MCP is the product.
It is infrastructure.
Useful infrastructure, yes. Important infrastructure, probably. But still infrastructure.
The product question is different:
**what should the agent be allowed to know, decide, and do for the user?**
That is the question I care about more.
## The old AI feature was mostly a box
A lot of early AI features had the same shape.
There was an input.
There was a prompt.
There was an answer.
Maybe the answer was a summary. Maybe it rewrote text. Maybe it generated a checklist. Maybe it answered a question from a document.
That kind of feature can be useful, but it is still mostly a box. The user puts something in. The model replies. The product displays the reply.
The newer version is different.
Now the agent can reach outside the box.
It can read a file. Search a codebase. Query a database. Open a browser. Create a ticket. Update a CRM record. Draft a pull request. Look at a calendar. Call another internal service. Combine several steps into one workflow.
That is a real shift.
It is also where the stakes change.
When an AI answer is wrong, the user can ignore it.
When an AI action is wrong, the system may already have changed something.
That difference is where product design starts to matter.
## More tools do not automatically make a better agent
The tempting idea is simple:
> If the agent can access more tools, it will become more useful.
Sometimes that is true.
An AI coding agent is more useful when it can read the repo, run tests, inspect errors, and understand the local project. A support assistant is more useful when it can see the ticket history and product docs. A research agent is more useful when it can search, cite sources, and keep notes.
But the sentence is still incomplete.
More tools help only when the agent knows when to use them, what not to touch, and how to recover when something is uncertain.
Without that, tool access becomes noise.
The agent sees too much. It calls the wrong thing. It mixes stale data with live data. It performs an action before the user has approved it. It sounds more capable while becoming harder to trust.
That is why I do not think "we added MCP" is a product milestone by itself.
It is closer to saying:
> We added a door.
Good. Where does it lead? Who has the key? What happens when someone walks through it? Can they delete anything? Can they spend money? Can they email a customer? Can they read private data? Can we see what they did afterward?
Those are the product questions.
## MCP solves one kind of mess
Before MCP, connecting AI tools to external systems was often a pile of one-off integrations.
One app had its own way to connect GitHub. Another had its own database connector. Another had a custom browser tool. Another had a plugin system. Every tool needed its own shape, every client needed its own integration, and the same work repeated again and again.
MCP helps with that.
The official docs describe MCP as an open standard for connecting AI applications to external systems. In plain language: it gives AI clients and external tools a common way to talk.
That is a good thing.
Standards reduce glue work. They make integrations more reusable. They make it easier to expose a tool once and connect it to different AI applications. They give developers a shared mental model instead of another custom connector for every product.
I like that.
But MCP does not answer the harder product questions for you.
It does not decide whether your agent should be allowed to update production data.
It does not decide when a user confirmation is required.
It does not decide how much context is too much.
It does not decide which actions should be reversible.
It does not decide how the interface should explain what happened.
It gives you a way to connect the agent to things.
You still have to decide what kind of relationship the agent should have with those things.
## Tool access needs a job, not just a capability
When I think about giving an agent access to a tool, I try to start with the job.
Not the technology.
Not the protocol.
Not "would it be cool if the agent could call this?"
The job.
For example:
- help a developer understand why a test is failing;
- help a user find the right moment in a long video;
- help a support person prepare a reply with the right account context;
- help a founder compare several landing pages before a launch;
- help an operator spot broken links before publishing a site;
- help a team turn a messy request into a scoped implementation plan.
Once the job is clear, tool access becomes easier to reason about.
The agent does not need every possible tool.
It needs the tools that help complete that job without creating unnecessary risk.
For a coding workflow, reading files and running tests may be reasonable. Pushing to `main` without review probably is not.
For a support workflow, reading customer history may be useful. Sending a refund without confirmation may be too much.
For a browser workflow, reading the current page may be enough. Submitting a form, accepting permissions, or purchasing something should be a separate boundary.
The point is not to make agents weak.
The point is to make them legible.
The user should understand what the agent can do, what it cannot do, and where the user is still in control.
## Context is not the same as permission
One mistake I see in AI product thinking is treating all access as the same thing.
It is not.
There is a big difference between:
- reading a document;
- searching a database;
- seeing metadata;
- drafting a change;
- writing to a system;
- deleting something;
- sending a message;
- spending money;
- changing permissions;
- publishing content.
Those are not just different API calls.
They are different levels of trust.
An agent may need read access to understand a situation. That does not mean it should have write access. It may need to draft an update. That does not mean it should apply the update. It may need to inspect a customer's account. That does not mean it should change billing.
This sounds obvious when written out.
It becomes less obvious when a product team says:
> Let's connect the agent to our internal tools.
That sentence hides everything important.
Which internal tools? Which data? Which actions? Which users? Which environments? Which logs? Which approval steps? Which failure modes?
The product is not "connected to internal tools."
The product is the boundary around that connection.
## The best agent flows have a pause in the right place
I do not think every agent action needs a confirmation.
That would make the product painful.
If the agent has to ask before every harmless read operation, the user will stop using it. If it needs approval to summarize a document the user just uploaded, the workflow becomes silly.
But high-impact actions need a pause.
Not a fake pause. Not a tiny "Are you sure?" modal that everyone clicks through.
A meaningful pause.
Something like:
> I found the issue. I can update these three files and run the tests.
Or:
> I drafted the customer reply from the ticket history. Review it before sending.
Or:
> I can create this Linear issue with the following scope and acceptance criteria.
Or:
> This action will change production data. I need your approval before I continue.
That pause does a few things.
It gives the user a chance to correct the agent's understanding.
It makes the agent show its work before acting.
It turns a black-box operation into a shared decision.
And it creates a natural place to explain risk.
That is good product design.
It is also good safety design.
## Audit trails are part of the interface
When an AI agent can use tools, logs stop being only a backend concern.
They become part of the user experience.
The user should be able to answer:
- what did the agent read?
- what did it call?
- what did it change?
- what did it decide not to do?
- what failed?
- what needs my review?
- where did this answer come from?
This does not mean showing raw JSON to everyone.
Most users do not want that.
But the product should expose enough traceability that the agent does not feel like a ghost moving through the system.
In developer tools, that might mean showing commands, files touched, test output, and diffs.
In support tools, it might mean showing the sources used for a reply.
In research tools, it might mean showing citations and search history.
In browser extensions, it might mean showing what page data was inspected and what stayed local.
The exact interface depends on the product.
The principle is the same:
**if the agent can act, the user needs a way to inspect the action.**
Without that, trust becomes vibes.
And vibes are not enough when software starts changing real things.
## A real example from coding agents
Coding agents make this easier to see because the workflow is concrete.
When I use an AI coding agent, I am comfortable giving it a lot of read access inside the repo. It can search files, inspect call sites, read tests, and understand how a feature is wired.
I am also comfortable letting it run checks.
Types, linting, tests, local builds, maybe a browser preview if the task touches UI.
That kind of tool access is useful because the job is clear: understand the codebase and verify a change.
But I still want boundaries.
I want to review the diff.
I want to decide whether an abstraction belongs in the codebase.
I want to approve commits and pushes.
I want anything touching auth, data access, migrations, payments, or destructive operations to be treated differently from a copy change.
The agent can do a lot.
It should not own the decision.
That is the balance I keep coming back to: let the agent do the work that benefits from speed and context, but keep the expensive decisions visible.
MCP can make the connections cleaner.
It does not remove the need for judgment.
## A real example from product workflows
Imagine a small SaaS dashboard with an AI assistant.
The naive version is:
> The assistant can access everything in the dashboard and take actions for the user.
That sounds powerful.
It is also too vague to be useful.
A better version is more specific:
> The assistant helps users understand account activity, draft follow-up tasks, and prepare changes, but it asks for confirmation before sending messages, changing billing data, deleting records, or updating permissions.
Now the product has shape.
You can design the tool list around it.
Read account activity. Search docs. Draft a note. Create a task. Suggest a setting change. Show a confirmation before applying it.
The user can understand that.
The team can test that.
The logs can reflect that.
The permissions model can support that.
This is where AI product work becomes interesting. Not at "add agent." Not at "add MCP server." At the moment when you decide what the agent is actually allowed to do on behalf of a person.
## The UI matters more than people think
A lot of agent discussion is backend-heavy.
Protocols. Servers. Tools. Schemas. Transports. Auth. Hosting.
All of that matters.
But the user meets the agent through the interface.
If the interface makes the agent look more certain than it is, the product becomes risky.
If the interface hides the sources, the user cannot verify.
If the interface hides pending actions, the user does not know what is about to happen.
If the interface does not separate "drafted" from "sent", people will misunderstand the state.
If the interface buries permissions in settings nobody reads, tool access becomes invisible.
That is why I like agent interfaces that show state clearly:
- reading;
- thinking;
- planning;
- asking for approval;
- running a tool;
- waiting;
- failed;
- completed;
- needs review.
Those states sound boring.
They are not.
They are how the user keeps their footing.
The more capable the agent becomes, the more important those small state cues become.
## The dangerous version feels effortless
There is a version of AI product design that tries to remove every bit of friction.
One prompt.
The agent does everything.
No steps. No confirmation. No exposed reasoning. No visible sources. No boundaries. No audit trail. Just a smooth answer or a completed task.
That can feel impressive in a demo.
It can also be the wrong shape for real work.
Some friction is useful.
Reviewing a diff is friction.
Confirming a payment is friction.
Approving an email before it is sent is friction.
Choosing which data source the agent should use is friction.
Reading a warning before a production change is friction.
But that friction exists because the action matters.
The trick is not to remove all friction.
The trick is to put friction where it protects the user, and remove it where it only slows them down.
That is the difference between a powerful agent and a reckless one.
## What I would decide before building
Before building an AI agent feature with MCP or any other tool-access layer, I would answer a few questions in plain language.
Not architecture language.
Product language.
What job is the agent helping with?
What data does it need to read?
What data should it never see?
Which actions can it perform automatically?
Which actions require confirmation?
Which actions should only be drafted?
Can the user undo the action?
What should be logged?
What should be shown to the user?
What happens when the tool call fails?
What happens when the agent is unsure?
What happens when the user asks for something outside the allowed boundary?
Those answers should shape the MCP server, the tool definitions, the prompts, the UI, the permissions model, and the tests.
If those answers are unclear, adding a protocol will not make the product clearer.
It may only make the unclear product more capable of doing the wrong thing.
## Where MCP fits in my mental model
I do think MCP matters.
I would not ignore it.
If I were building an AI product that needed to connect to external tools, internal systems, user files, business data, or cross-app workflows, I would want to understand MCP properly. I would want to know where it fits, where it is overkill, and where a simple direct integration is still enough.
But I would keep it in the right layer.
MCP is the connection layer.
The product is the workflow.
The trust comes from boundaries.
The usefulness comes from context.
The quality comes from choosing the right job.
The safety comes from permissions, confirmations, and logs.
The user experience comes from making all of that understandable.
That is the part I do not want to lose in the excitement.
It is easy to talk about agents like the main problem is giving them more power.
I think the better problem is giving them the right amount of power, in the right moment, with the user still able to see and steer what is happening.
That is less flashy than "connect everything."
It is also much closer to a product people can trust.
## Conclusion
MCP is useful because it gives AI applications a more standard way to reach tools, data, and workflows.
That is real progress.
But the protocol is not the product.
The product is what you decide to expose. What you decide to hide. What you ask the user to confirm. What you log. What you make reversible. What context you provide. What actions you refuse to automate.
An AI agent with tool access is not just a smarter chatbot.
It is a user acting through software with a second layer of interpretation in the middle.
That deserves careful design.
Not fear.
Not hype.
Care.
Because once the agent can do things, the most important question is no longer:
> Can it?
The question is:
> Should it, right now, for this user, with this context?
That is where the real product work begins.
## FAQ
### Is MCP only for developers?
No. Developers build MCP servers and clients, but the product impact is broader. MCP can affect how AI assistants connect to business tools, documents, databases, internal workflows, and customer-facing applications.
### Does every AI product need MCP?
No. If a product only needs one narrow integration, a direct API connection may be simpler. MCP becomes more interesting when the product needs reusable tool access across multiple AI clients, workflows, or external systems.
### Is tool access safe if it goes through MCP?
Not automatically. A standard protocol can make integration cleaner, but safety still depends on permissions, authentication, tool design, confirmations, logging, and what actions the product allows the agent to perform.
### What is the first product decision to make?
Decide the job before deciding the tools. Once the job is clear, it becomes much easier to decide what the agent should read, what it can draft, what it can change, and where the user must approve the next step.
---
## What an AI-Ready Website Actually Means in 2026
URL: https://www.pean.dev/blog/what-an-ai-ready-website-actually-means-in-2026
Published: 2026-06-26
Description: A practical, human look at AI-ready websites in 2026: what still matters for people and search, where llms.txt can help, and why most GEO tricks are less important than clear, crawlable, useful content.
Every few months, the web gets a new phrase that sounds like everyone should panic.
Right now one of those phrases is **AI-ready website**.
Sometimes it is called GEO. Sometimes AEO. Sometimes LLMO. Sometimes someone adds another acronym and says SEO is dead again.
I understand why people pay attention. Search is changing. AI answers are showing up in more places. People ask ChatGPT, Perplexity, Google AI Mode, Claude, and browser agents questions they used to type into a search box. The path from "I need something" to "I found a website" is less predictable than it was a few years ago.
But when I look at the actual work, I keep coming back to a less dramatic version:
**an AI-ready website is mostly a website that explains itself clearly.**
Not only to a person.
To search engines.
To crawlers.
To AI systems trying to pull a useful answer from a messy internet.
To agents that may open your site, read a few pages, and decide whether your content is relevant to the task.
That sounds smaller than the hype. It is also more useful.
## The part I do not want to pretend
I do not think there is a secret AI switch you can add to a site.
There is no magic meta tag that makes a page cited by AI systems. There is no guaranteed "AI answer engine ranking factor" that works like a vending machine. Add `llms.txt`, receive citations. Add schema, receive traffic. Rename SEO to GEO, become future-proof.
That is not how any of this works.
[Google's own guidance for AI features in Search](https://developers.google.com/search/docs/appearance/ai-features) says the same basic thing in a more official voice: there are no extra special requirements for AI Overviews or AI Mode beyond being eligible for Search and following the fundamentals. Pages still need to be crawlable, indexable, useful, and eligible to show snippets. Preview controls such as `nosnippet`, `data-nosnippet`, `max-snippet`, and `noindex` still matter.
So the first thing I would say to a founder, client, or developer is:
> Do not rebuild your whole site around AI search anxiety.
Start with the boring questions.
Can the page be crawled?
Can the page be indexed?
Does the title say what the page is?
Does the content answer a real question?
Can someone tell who wrote it or who is responsible for it?
Are important pages connected with internal links?
Is the content visible in the HTML, not trapped inside a fragile client-only experience?
Does the page have enough context to stand on its own?
That is still the center of the work.
The AI layer does not remove the basics. It makes weak basics more obvious.
## What actually changed
The old mental model was simple:
1. write a page;
2. let Google index it;
3. hope a search result sends traffic.
That model still exists, but it is no longer the only path.
Now a user might ask an AI assistant a broad question:
> What are the best tools for checking whether my website is ready for AI search?
Or a more specific one:
> Is this developer a good fit for building a Chrome extension with an AI feature?
Or:
> Compare these three product pages and tell me which one is clearer.
In those moments, your site might not be visited in the old way first. It might be read, summarized, compared, quoted, skipped, or used as one source among several.
That changes what "good website content" needs to do.
It is not enough for a page to look nice in a browser. It has to survive being reduced to text. It has to make sense when pulled out of its layout. It has to contain the important facts in a way that a machine can find without guessing too much.
That does not mean writing for robots.
It means not making robots guess what humans would also struggle to understand.
## I think about three readers now
When I work on a page in 2026, I usually think about three readers.
The first reader is still a human.
They are impatient. They scan. They want to know if they are in the right place. They care about tone, trust, examples, friction, and whether the page feels like it was written by someone who understands the problem.
The second reader is search.
Search needs crawlable URLs, internal links, canonical signals, titles, headings, metadata, structured data where it makes sense, and content that is not hidden behind needless technical friction.
The third reader is an AI system or agent.
This reader is strange because it is not really a reader in the human sense. It does not admire your layout. It does not care that a section had a beautiful animation. It may only see a small slice of the page. It may compress the content. It may use your page as context for a user request rather than as a destination.
But it still benefits from the same things people benefit from:
- clear page purpose;
- plain explanations;
- specific examples;
- stable URLs;
- visible text;
- useful internal links;
- author and organization context;
- schema that matches the page;
- concise summaries of what matters;
- no fake authority;
- no walls of vague marketing copy.
That is why I dislike the phrase "write for AI".
Most of the time the better instruction is:
**write so the page can be understood without you standing beside it explaining what you meant.**
## `llms.txt` is useful, but not magic
I like the idea behind [`llms.txt`](https://llmstxt.org/).
The proposal is simple: put a Markdown file at `/llms.txt` that gives language models and agents a cleaner map of the important parts of your site. It can link to docs, product pages, articles, project pages, policies, or any other content that helps a system understand what the site is about.
That is a sensible idea.
In fact, this site has an [`/llms.txt`](/llms.txt) route and a fuller [`/llms-full.txt`](/llms-full.txt) route because it fits the kind of site pean.dev is: a personal portfolio, project hub, and writing archive. If an agent wants a clean index of who I am, what I build, and which articles matter, I would rather provide that context directly than make it scrape random navigation.
But I would not sell `llms.txt` as an AI SEO cheat code.
It is better to think of it as a front desk.
It says:
> Here is what this site is. Here are the important rooms. Here is what you should read first.
That can help agents and tools. It can make your site easier to process. It can be especially useful for documentation, product catalogs, personal sites, universities, API references, and any site where a curated map is genuinely helpful.
But if the underlying pages are thin, confusing, or untrustworthy, `llms.txt` will not rescue them.
A clean map to weak content is still weak content.
## The page has to answer better than the snippet
One thing AI search has changed for me is the bar for content.
A lot of older SEO content was built around capturing a keyword, answering the obvious question, and keeping the reader moving just long enough to convert. That can still get clicks in some places, but it feels weaker now.
If an AI answer can summarize the generic version in five seconds, the page needs a reason to exist beyond the generic version.
That reason might be:
- real experience;
- original examples;
- a clear point of view;
- implementation details;
- tradeoffs;
- screenshots or product evidence;
- a better explanation than the average answer;
- a strong comparison;
- trust that comes from showing how something was built.
This is why I keep writing posts from the angle of "how I think about this while building real products" instead of trying to produce neutral encyclopedia pages.
Neutral summaries are easy to generate.
Experience is harder to fake.
So if someone asks me how to make a site more ready for AI discovery, I do not start with a plugin.
I start with the content.
Does this page say anything a generic answer would not say?
Does it include details that prove someone actually worked through the problem?
Does it help the reader make a decision?
Could another developer, founder, or product person use this page as a real reference?
If the answer is no, the problem is not only AI readiness. The problem is that the page is not useful enough yet.
## The technical layer still matters
I do not want to make this sound like writing alone fixes everything.
The technical layer matters a lot.
For a normal content or product site, I would check:
- `robots.txt` and whether important pages are allowed to be crawled;
- `sitemap.xml` and whether important URLs are included;
- canonical URLs;
- page titles and meta descriptions;
- Open Graph images and social preview data;
- article, product, organization, or local business schema where appropriate;
- clean headings;
- server-rendered or easily extractable content;
- internal links between related pages;
- redirects and broken links;
- whether preview controls are intentionally set;
- whether public pages are actually public to the crawlers you care about.
None of that is new.
But the reason is slightly broader now.
You are not only helping a search result page understand your content. You are helping a wider set of systems decide what your page is, whether it can be trusted, and whether it is worth using as context.
This is where I think tools like [Crowra](https://www.crowra.pean.dev/) make practical sense. Not because they can guarantee AI citations. They cannot. But because they can help review the signals around the page without turning the process into twenty tabs and a spreadsheet.
The goal is not to please a machine.
The goal is to remove unnecessary ambiguity.
## Control is becoming part of the conversation
There is another side to this that people sometimes skip.
AI readiness is not only about being included.
It is also about deciding what access you actually want.
Some websites want maximum discovery. Some publishers want tighter control. Some companies want search indexing but do not want their content used in certain AI training contexts. Some businesses want product pages to be easy to cite, but support docs to stay behind authentication. Some creators are asking whether crawler access should be free, paid, blocked, or negotiated.
That conversation is getting more real.
Cloudflare's [Pay Per Crawl](https://blog.cloudflare.com/introducing-pay-per-crawl/) experiment is one visible sign of that shift. It is not something every small site needs to implement tomorrow. But it shows where the web is moving: crawler access is becoming a product and business decision, not only a technical default.
For a small business site, personal site, or product landing page, the decision can be simpler:
- what should be public?
- what should be indexed?
- what should be easy for agents to understand?
- what should not be exposed at all?
- which crawlers do we want to allow?
- are we using `Google-Extended`, `nosnippet`, or other controls intentionally?
I like making those choices explicitly.
Default openness can be fine.
Default blocking can be fine too.
The mistake is not knowing which one you chose.
## What I would actually fix first
If I had one afternoon to make a site more AI-ready, I would not start by chasing every new acronym.
I would do this:
1. Pick the 5 to 10 pages that matter most.
2. Make sure each page has a clear purpose in the title, H1, opening paragraph, and metadata.
3. Add or improve internal links so important pages are not isolated.
4. Check that the main content is crawlable and visible without requiring a fragile client-only flow.
5. Add structured data only where it honestly matches the page.
6. Create a simple `/llms.txt` if the site has enough public content to justify a curated map.
7. Review `robots.txt`, sitemap, canonical URLs, and preview controls.
8. Remove vague copy that sounds good but says very little.
9. Add real examples, proof, screenshots, project details, or decision-making context.
10. Re-read the page as if an AI assistant had to answer questions from it without guessing.
That last step is surprisingly useful.
Ask questions like:
> What does this company actually do?
> Who is this for?
> What problems can this person solve?
> What projects prove it?
> What should I read next?
> Is this page a real source, or just a polished brochure?
If the page cannot answer those questions for a person, an AI system will not magically understand it either.
## The best version still sounds human
This is the part I care about most.
A lot of AI optimization advice quietly pushes people toward worse writing.
More definitions. More repeated keywords. More "comprehensive guide" language. More awkward sections created because someone thinks an answer engine might like them. More pages that feel like they were written for a content machine, reviewed by a content machine, and published for another content machine to summarize.
That is a miserable direction for the web.
The better version is not less human. It is more human and more structured.
Say what you mean.
Use headings that help.
Explain tradeoffs.
Link to sources.
Show your work.
Do not pretend certainty where there is none.
Make the page easy to scan, but do not flatten every thought into a bullet list.
Use schema and metadata as support, not as a substitute for substance.
If you are writing from experience, let the experience show.
That is what I trust when I read a page. It is also what I want AI systems to find when they read mine.
## How I think about pean.dev
For this site, "AI-ready" does not mean turning every page into an SEO landing page.
That would make it worse.
pean.dev is a personal site. It should feel like a real developer's home on the web: projects, writing, contact, context, and enough technical clarity that someone can understand what I build without needing a sales call first.
So the useful work is pretty grounded:
- keep project pages specific;
- write posts from actual product and engineering decisions;
- expose clean metadata;
- keep the sitemap and canonical URLs healthy;
- provide `llms.txt` and `llms-full.txt` for agent-friendly context;
- make public pages easy to read as text;
- connect related posts and projects;
- avoid pretending that any of this guarantees AI visibility.
That is a calmer strategy.
It is also the one I believe in more.
## Conclusion
An AI-ready website in 2026 is not a website that blindly chases AI.
It is a website with less ambiguity.
Clearer pages. Better source signals. Useful content. Honest structure. Crawlable routes. Thoughtful access controls. A good map for humans, search engines, and agents.
Some of the work is technical.
Some of it is editorial.
Some of it is just having enough respect for the reader to say something concrete.
That last part is easy to underestimate.
But the more AI gets involved in how people discover and compare information, the more important it becomes to have a site that can stand on its own.
Not because AI needs special treatment.
Because clarity travels better.
## FAQ
### Does an AI-ready website need `llms.txt`?
No. `llms.txt` is not required for Google AI Overviews or AI Mode. I still like it for sites with useful public content because it gives agents and tools a clean, curated map of the site.
### Is GEO replacing SEO?
I do not think so. The work overlaps too much. Crawlability, useful content, internal links, structured data, and trust signals still matter. GEO is better treated as an extra lens on good web publishing, not a full replacement.
### Should I block AI crawlers?
It depends on the site. A public portfolio, product site, or documentation site may benefit from being easy to discover. A publisher, paid knowledge product, or private community may want stricter controls. The important thing is to choose intentionally.
### What is the first thing I would fix?
I would fix the pages that matter most. Make their purpose obvious, make the main content crawlable, connect them with internal links, and add enough specific context that the page is useful even when the design is stripped away.
---
## How I Actually Use AI Coding Agents on Real Projects (and Where I Still Don’t Trust Them)
URL: https://www.pean.dev/blog/how-i-use-ai-coding-agents-on-real-projects
Published: 2026-06-10
Description: A practical, senior-level look at using AI coding agents like Claude Code on production codebases: where they save real time, where I keep full control, and how I structure work so the output is actually shippable.
A year ago, if someone told me they were shipping production code where an AI agent wrote most of it, I'd have asked "which production." A landing page, sure. A real backend with real users and real data? I wasn't convinced.
I'm a lot less skeptical now. Not because the models suddenly got smarter overnight — they got better, but that's not the main thing. What changed is how I work with them.
So this isn't a "10x your output" post. It's closer to notes from someone who's been doing this daily for months: what an agent is genuinely good at on a real codebase, what it still gets wrong, and the habits that keep the wrong parts from costing me anything.
I work across a Next.js site, an Expo app with a NestJS backend and PostgreSQL, and a few Chrome extensions (Cuelio, Crowra, TableSnap). Different stacks, different stakes, same agent. The lessons mostly transfer.
## The mistake I made early on
My first instinct was to treat the agent like a junior dev I could hand a ticket to and walk away.
"Here's what I need. Go build it."
For small, boring, low-risk stuff, that's actually fine. But the moment a task touches architecture, data flow, or "how we do things in this codebase," that approach falls apart in a specific way: you get code that compiles, looks reasonable on a skim, and is wrong in some quiet way you only notice a week later.
It took me a while to realize this isn't really an "AI" problem. "Here's what I need, go build it" is a bad spec for a human too — we just don't feel it, because a human teammate fills in the gaps using context they already have. They remember why the last attempt at this broke. They know which shortcut is fine here and which one bit us in production six months ago.
An agent doesn't have any of that unless you hand it over. And it won't sit there confused — it'll just fill the gap with whatever sounds plausible. Plausible and correct-for-your-codebase are not the same thing, and the gap between them is exactly where the subtle bugs live.
What actually changed for me wasn't "trust it more" or "trust it less." It was treating context as part of the job, not an optional extra. I now think of the agent as a very capable engineer who started an hour ago and hasn't read the codebase yet — because, functionally, that's what it is on every single task.
That one mental shift did more for the quality of the output than any prompt trick I've tried.

## Where it actually earns its keep
Once I stopped expecting it to "just know" things, a few areas turned out to be genuinely strong — stronger than I expected.
**Codebase archaeology.** "Where does this value actually get computed?" "What breaks if I change this function's signature?" "Why does this component refetch on every route change?" These used to be ten minutes of grep and scrolling through files I half-remembered. Now it's one prompt and an answer with file paths and line numbers I can check in seconds. Honestly, this alone would justify the workflow change for me — it's the thing I do most, all day, on every project.
**Mechanical refactors.** Renaming a prop across forty components, migrating a batch of API routes to Server Actions, updating every call site after a helper's signature changes. Correctness here is mostly about not missing a spot, and an agent doesn't get sloppy on file thirty-one the way I do at 4pm.
**First drafts of well-specified features.** If I can describe the shape of something precisely — inputs, outputs, edge cases, where it slots into the existing structure — the agent gets to a working first version faster than I'd type it myself. Not the final version. Maybe 80% there. But 80% there, on the first try, is a great place to start steering from.
**Wiring things across the stack.** Add a field, and it needs to exist in the PostgreSQL schema, the NestJS DTO and service, the sync payload, the Expo screen, and the Next.js dashboard. That used to be an afternoon of repetitive, low-creativity work. Now the agent reads how one similar field is wired and drafts the whole chain in a few minutes.
**Edge cases I forgot.** Empty arrays, null timestamps, duplicate sync writes, what happens if the device comes back online mid-write. I don't agree every single one matters, but having the list in front of me beats trying to brainstorm it cold.
**Debugging across boundaries.** When something breaks between, say, the Apple Watch app and the dashboard, the agent can hold the whole chain in its head at once — mobile write, NestJS endpoint, Postgres constraint, frontend read. I can do that too, it just takes me longer to load it all back in after I've been doing something else for an hour.
None of this is the agent designing my product. It's the agent closing the gap between "I know what needs to happen" and "it exists in the codebase."
## Where I don't let it run unsupervised
This is the part that matters more, because it's where things go wrong if you get lazy.
**Architecture and data model decisions.** One table with a status column, or two tables and a join? Does this state live in the URL, a server component, or client state? These choices ripple for months. The agent will give you an answer — often a perfectly reasonable one — but it doesn't carry "we'll be living with this for two years and three other features will depend on it." That weight is mine, so the call stays mine. The agent can help me think it through. It doesn't get a vote on the final answer.
**Anything touching auth or data access.** This is where I'm closest to paranoid, on purpose. Code can look like it checks permissions correctly and not actually do it — especially across an ownership chain like "user owns a place, place belongs to a shared group, group membership decides who can see it." I read every line of this myself. Doesn't matter who or what wrote it.
**Scope creep.** Agents are eager to be helpful, which sounds nice until you ask for a one-line fix and get the fix plus a "while I was in there" refactor, plus a new abstraction "for reusability," plus a validation layer nobody asked for. On a side project that's mildly annoying. On anything shared, it turns a five-minute review into a forty-minute one. A diff that's bigger than I expected is my cue to stop and ask why before I read another line.
**Naming and abstractions, over time.** It'll happily produce a working `useThing` hook or another `helpers.ts`. What it's much worse at is noticing "we already have three things that almost do this, and this is now a fourth, slightly different one." That kind of drift doesn't show up in any single diff — it shows up six months later when you're staring at the folder structure wondering how it got like this. Catching that is still on me.
**Anything where "it compiles and the demo looks fine" isn't the actual bar.** Sync logic that has to survive a phone losing signal mid-write. An extension that has to behave when the tab closes mid-request. A migration that has to run safely against a table with real rows in it. The agent can write code for all of these — if I describe the scenario. It won't reliably think of the scenario itself, because it's never been the one paged at 2am when this kind of thing goes sideways. I have. That experience is still doing work here, even if I'm not the one typing.
If there's a pattern across all of these, it's this: the agent is excellent at execution once a problem is well-defined, and it's not the one who should decide what the problem is or what "done" actually means. That's still the job.

## How I actually work, day to day
A handful of habits made the biggest difference, and none of them are clever.
I plan before I let it touch any files. For anything that isn't trivial, I have it read the relevant code first and tell me how it's going to approach the change before writing a line. Reading a plan takes ten seconds. Reading a thousand-line diff to discover we disagreed from the start takes a lot longer, and by then it's already written.
I scope things the way I'd brief a contractor I trust but haven't worked with yet — specific files, specific behavior, specific things not to break. "This has to keep working offline." "Don't touch the API shape, the mobile app depends on it." "Match the pattern in `lib/posts.ts`, don't invent a new one." Every piece of context I make explicit is one less thing it has to guess, and guessing is where things drift.
I try to keep diffs small enough to actually read. If something's going to touch twenty files, I'd rather it happen in a few steps I can review properly than one big change I skim because I'm tired by file twelve.
I let it run its own checks. Type checker, linter, tests, even firing up the dev server to look at the actual page — having it do this and fix what it finds before showing me anything has probably been the single biggest time saver. The "run it, copy the error, paste it back" loop basically disappears.
And I review its code differently than I'd review a teammate's PR — not less carefully, just differently. With a person's PR I'm partly checking whether they understood the requirement. With the agent, I already know what I asked for, so the review is: did it do that, did it do *only* that, and does it look like it belongs in this codebase or like it was dropped in from somewhere else. That last one is the one I see people skip most.
The rest — what to build, what not to build, when "good enough" is actually good enough versus a problem waiting to happen — stays mine. The agent doesn't have an opinion on whether a feature is worth building in the first place, and on a side project, that question is most of the actual work.
## A real example: the Expo/NestJS sync layer
The clearest case from my own work is the [sync architecture for the Expo app](/blog/expo-nestjs-postgresql-sync-architecture) — Apple Watch and iPhone capture GPS places, sync through NestJS, land in PostgreSQL, show up later in the Next.js dashboard.
Here's what the agent handled well:
- scaffolding the NestJS DTOs and service methods once I'd nailed down the sync payload shape
- writing the Postgres migration for the new sync columns, matching the style of the existing migrations
- generating the Expo-side API client and TypeScript types from the NestJS contract
- a first pass at tests for "what happens if the same place gets synced twice"
What I kept for myself:
- how conflicts get resolved when the same place is edited offline on two devices — last-write-wins, per-field merge, manual resolution. That's a product decision wearing a technical costume, and it has UX consequences I had to sit with myself
- the idempotency strategy for sync writes, because getting it wrong means duplicate places quietly appearing, and that's the kind of bug that makes people stop trusting the app
- reading the auth check on the sync endpoint myself, line by line, since that's the line between "your data" and someone else's
By lines of code, the agent probably wrote more than I did. But that's not really the split that matters. It handled the surface area; I handled the handful of decisions where being wrong is expensive. That's roughly the ratio I aim for on most real features.
## The same pattern shows up in the Chrome extensions
Cuelio, Crowra, and TableSnap are smaller and more contained than the Expo/NestJS side, which makes the same pattern easier to see at a different scale.
With [Crowra](/blog/why-i-built-crowra-side-panel-seo-ai-readiness-inspector) — a side-panel tool that audits a page for SEO, schema, and AI-readiness signals — the agent is great at adding a new check once the existing ones establish the pattern. "Also flag pages missing an `og:image`" is mechanical: the existing checks are basically the spec, and getting one wrong has a small blast radius.
What I keep for myself is deciding whether a check is worth adding at all. A side panel like this lives or dies on not being noisy, and every new check is a tradeoff between "more thorough" and "more overwhelming." That's a product call, and the agent has no stake in it either way.
With [TableSnap](/blog/building-tablesnap-local-first-chrome-extension-web-table-workflows), the constraint that matters most is that table data never leaves the browser — it's local-first by design. That's exactly the kind of rule an agent will respect if you say it out loud, and might quietly step around if you don't, the moment "add an export option" starts to sound like it'd be easier with some convenient API call. I don't read that as the agent being careless. It's a reminder that a constraint that only lives in my head doesn't exist as far as it's concerned.
## The one rule that covers most of this
If I had to boil it down: give the agent a well-defined problem with a short feedback loop, and hold on to the decisions that are expensive to get wrong.
"Well-defined" means it isn't guessing at your conventions or priorities, because if it has to guess, it will — confidently, plausibly, and sometimes wrong in ways that are hard to spot until later. "Short feedback loop" means it can check its own work against the types, the tests, the running app, before you're the one finding the bug. And "expensive to get wrong" is a short list — architecture, data integrity, security boundaries, scope. Almost everything else is fair game.
That list isn't fixed, either. A year ago mine was longer. It'll probably be shorter again next year. But right now, on real projects with real users and real data, that's roughly where I draw the line — and it's why I can lean on these tools heavily without it feeling like a gamble.
## Conclusion
Whether an AI agent can write code isn't really the interesting question anymore. It can, and it's gotten good at it fast.
What's more interesting is what changes about the job once that's true. For me, the answer has been: less than the hype would have you believe, and more than I expected when I started.
The work shifts from typing out the implementation to specifying the problem precisely, checking the result honestly, and owning the decisions that are hard to undo. That was always the harder half of the job. It's just that now it's nearly all of it — and if I'm honest, it's the half I find more interesting anyway.
---
## Context Optimization in AI Products: How to Make AI Features Faster, Cheaper, and More Useful
URL: https://www.pean.dev/blog/context-optimization-in-ai-products-en
Published: 2026-05-30
Description: AI product quality often depends less on the model and more on the context you give it. A practical look at context optimization, cost, latency, source grounding, and real product workflows.
When people talk about AI features, they often start with the model.
Which model should we use?
How big is the context window?
Can we send the whole document?
Can it read a full video transcript?
Would the answer be better if we just gave it more context?
Those are fair questions. I ask them too.
But in real products, I rarely start there.
The more useful question is usually this:
**what context does the AI actually need to help the user right now?**
Not the maximum context. Not everything we have. Not a long prompt with every possible detail just in case.
The right context.
That is the part I keep coming back to when I design AI features. The model matters, of course. But the product often becomes faster, cheaper, and more useful when we get better at choosing what the model should see.
That is how I think about context optimization.
## The model is not always the problem
It is easy to blame the model.
If the answer is weak, use a stronger model.
If the answer is vague, use a bigger context window.
If the feature is slow, try another provider.
If the feature is expensive, switch to a cheaper model.
Sometimes that is the right move.
But very often the real issue is simpler: we are giving the model the wrong material to work with.
Take a long YouTube video as an example.
The lazy version is to extract the entire transcript and send it to the model every time the user asks a question. For a demo, that can look good enough. The user asks something, the AI replies, and the feature feels impressive for a few minutes.
Then the product starts to behave like a real product.
Long videos contain a lot of text. A lot of text means more tokens. More tokens mean higher cost, slower responses, and more room for the model to drift around the transcript instead of focusing on the exact part that matters.
At that point, the issue is not that the AI is bad.
The issue is that we asked it to work with a pile of unsorted context.
## More context is not always better
There is a natural temptation to think:
> If we give the model more context, the answer will be better.
That can be true for broad tasks.
If the user asks, “What is this document about?” or “Can you summarize the main ideas from this video?”, then wider context helps. The model needs a larger view to understand the whole thing.
But many product questions are not broad.
They are specific.
A user asks:
> What did the speaker say about pricing?
Or:
> Where does the tutorial explain the deployment step?
Or:
> Did anyone mention this issue in the comments?
In those cases, sending everything can make the answer worse, not better.
The model might find the right part. It might also pick a similar section, blend two different moments together, or return a generic answer that sounds correct but does not really help.
For specific questions, I usually prefer a different flow:
1. find the most relevant pieces first;
2. send those pieces to the model;
3. ask the model to answer from that context;
4. show the user where the answer came from.
That is less magical than “AI reads everything”.
But it is usually better product design.
## Context is a product decision
I do not see context optimization as only a backend or prompt engineering detail.
It is a product decision.
The context we send to AI directly affects the user experience:
- how fast the answer feels;
- how specific the answer is;
- whether the user can verify it;
- how much each request costs;
- how predictable the system is;
- how painful the product will be to scale;
- whether the AI feature feels useful or random.
A lot of AI products have the same basic shape:
there is an input, a button, a loading state, and an answer.
That is not enough.
The harder questions are hidden underneath:
- what did the model actually see?
- did it see the right source?
- can the user go back to that source?
- what happens when the content is long or noisy?
- what should happen when the answer is incomplete?
- what data should never be sent at all?
This is where the real work starts.
Not in adding an AI box to the interface, but in deciding how AI fits into the workflow.
## What I try to send to the model
When I design an AI feature, I try not to ask “how much can we send?” first.
I ask:
**what is the smallest useful context for this moment?**
Depending on the product, that might include:
- relevant text fragments;
- sections with the closest semantic match;
- a page title, video title, or document metadata;
- a small amount of surrounding context;
- a user-selected item or range;
- timestamps or source references;
- a short instruction about how the answer should behave;
- constraints the model should not ignore.
For example, if a user asks about one topic in a video, I do not need to send the whole transcript by default. I need to find the parts that are most likely to contain the answer, send those, and ask the model to stay grounded in them.
That is less exciting than a huge prompt.
But it is much closer to how a reliable product should work.
## What I try not to send
Choosing what not to send is just as important.
I try to avoid sending:
- long chunks of text just in case;
- repeated content;
- irrelevant sections;
- raw HTML noise;
- navigation text, boilerplate, and layout clutter;
- content that does not affect the answer;
- private or sensitive data unless it is truly required;
- an entire document when the user only needs one part.
This is not only about saving money.
Less noise often means a better answer.
When the model receives cleaner context, it has fewer distractions. The answer becomes more direct. The user does not get a polished paragraph that vaguely touches the topic. They get something they can actually use.
That is the difference between an AI demo and an AI feature that belongs in a product.

## Cost matters earlier than people think
At the beginning of a product, it is easy to say:
> We do not have many users yet, so cost does not matter.
I understand that thinking, but I do not fully agree with it.
AI cost is not only a billing problem. It is also an architecture signal.
If the first version of a product sends too much context on every request, you are not just spending more money. You are building the user experience around a slow and expensive operation.
The code starts to assume it can send everything. The interface starts to assume the user will wait. The product behavior starts to depend on a pattern that may not survive real usage.
Then usage grows, and suddenly every active session is more expensive than expected.
That is a bad moment to realize the architecture was wasteful from the start.
So I like thinking about cost early.
Not because every feature needs to be cheap at all costs. Some AI features are worth paying for. But a product should know where the money is going and why.
If we can make the answer faster and cheaper by sending better context, that is usually worth doing.
## Latency changes how the feature feels
A correct answer can still feel bad if it takes too long.
This matters a lot in browser workflows.
When someone is inside a YouTube video, a dashboard, a document, or an internal tool, they are already in the middle of a task. They do not want a separate AI process that interrupts everything. They want help in the same flow.
Find the thing.
Check the detail.
Jump to the source.
Copy the result.
Move on.
Latency is not just a technical metric here.
It is part of the product experience.
If the AI responds quickly, it feels like a tool. If it takes too long, it starts to feel like a separate workflow the user has to wait for.
Context optimization helps with that. Less irrelevant context means fewer tokens, faster processing, and less friction.
That does not make every AI feature instant. But it does make the feature feel more intentional.
## Grounded answers need traceable context
One of the things I care about most in AI products is whether the user can check the answer.
AI should not feel like a black box.
If the product answers based on a video, document, page, transcript, or comment thread, the user should have a way to understand where the answer came from.
This is especially important for video.
A summary can be helpful, but it often removes the path back to the original source. You get the conclusion, but not the moment that supports it.
I prefer a more grounded flow:
- find the relevant part;
- generate the answer;
- show the timestamp or source reference;
- let the user jump back to the original moment;
- keep the answer inspectable.
That is not just a nice detail.
It is trust design.
A timestamp is not only navigation. It is a way for the user to verify the AI instead of blindly accepting it.
## How this applies to Cuelio
This way of thinking directly affects how I am building [Cuelio](/blog/building-cuelio-search-first-ai-extension-for-youtube).
Cuelio is a YouTube extension I am working on and testing. The goal is not to make another AI summarizer.
I want YouTube to feel more like a searchable knowledge base.
The first version is focused on the workflow that feels most useful in long videos:
- search the transcript;
- jump to the exact timestamp;
- ask AI questions based on the video content;
- keep answers connected to sources;
- search comments without endless scrolling;
- save videos for later;
- export transcripts when needed.
Context optimization is a very practical problem there.
If a user asks a question about a long video, sending the full transcript to the model every time is not the best default. It can be slow, expensive, and less focused than it should be.
A better approach is to find the relevant transcript parts first, pass those to the AI, and return an answer that still points back to the original video moment.
That makes the product cheaper to run, but also more honest for the user.
The answer is not floating in the air. It has a source.
## How this applies to client work
This problem is not unique to YouTube.
It shows up in almost every product where AI needs to work with real content:
- internal knowledge bases;
- CRM notes;
- support tickets;
- educational platforms;
- documentation;
- video and audio archives;
- browser extensions;
- SEO and content audit tools;
- internal research workflows.
A client might describe the request very simply:
> We want to add AI to the product.
But the real questions start after that.
What data should the AI see?
What should it never see?
How do we find the relevant context?
How do we make the answer verifiable?
How do we avoid burning money on tokens?
How do we keep the UX fast?
How do we fit the feature into the real workflow instead of adding a chat box on top?
That is where AI product engineering becomes more interesting than just connecting an API.
The value is not only in making the model respond.
The value is in making the response useful at the exact point where the user needs it.
## The practical rule I use
My rule is simple:
**find the right context first, then ask AI to help.**
Not the other way around.
If an AI feature starts with “let's send everything to the model”, I usually want to pause and look closer.
What is the user actually asking?
Where is the answer likely to be?
Which parts of the content matter?
Can we show the source?
Can this be faster?
Can this be cheaper?
Can this be easier to trust?
Very often, those questions improve the product more than a bigger model or a longer prompt.
The best AI features I have used rarely feel like magic.
They feel like the product understands the task, brings the right context forward, and uses AI only where it adds value.
## Conclusion
AI products do not become useful just because they include AI.
They become useful when the AI sees the right context, appears in the right part of the workflow, and helps the user do something faster, clearer, or with more confidence.
That is why I treat context optimization as a product decision, not only a technical detail.
Especially at the beginning of a product.
Early on, it is easy to overbuild, overspend, and hide messy thinking behind a large prompt. I would rather start smaller:
understand the task, find the relevant context, give the model what it needs, and keep a path back to the source.
It does not sound like magic.
But in real products, that is often what makes AI useful.
---
## Why AI Summaries Are Not Enough for Long YouTube Videos
URL: https://www.pean.dev/blog/why-ai-summaries-are-not-enough-for-long-youtube-videos
Published: 2026-05-29
Description: AI summaries are useful, but long YouTube videos often need something more: transcript search, timestamped answers, source checking, comment search, and a way to return to the exact moment.
AI summaries are useful.
I use them. I understand why people want them. If a YouTube video is 48 minutes
long and you only want a rough idea of what it covers, a good summary can save
time.
But the more I work with long videos, tutorials, interviews, technical talks,
product reviews, and research-heavy content, the more I notice the same thing: a
summary is only one layer of the problem.
Sometimes I do not need a shorter version of the video.
I need to find the exact moment where something is explained. I need the
timestamp. I need the original context. I need to check what the speaker
actually said. I need to search the transcript. Sometimes I also need to see
whether people in the comments corrected something, shared a link, added a
warning, or asked the same question I had.
That is a different workflow.
It is not only about summarizing YouTube videos with AI. It is about making long
videos searchable, inspectable, and easier to return to.
That is the thinking behind Cuelio, a YouTube extension I am currently testing.
I do not want it to be another AI YouTube summarizer. The first version is
focused on transcript search, timestamped AI answers, comment search, saved
videos, and transcript export.

## Summaries solve only one part of the problem
A summary is good when the question is broad.
For example:
- What is this video about?
- Is this worth watching?
- What are the main points?
- Can I get a quick overview before spending time on it?
That is a valid use case.
The problem starts when people expect a summary to solve every long-video
workflow.
A summary flattens the content. It compresses the video into a few paragraphs or
bullets. That can be helpful, but it also removes a lot of the structure that
makes the original video useful: timing, order, emphasis, examples, small
details, and the ability to verify the answer.
For some videos, that does not matter much.
For others, it matters a lot.
If I am watching a programming tutorial, I do not only want to know that the
author talked about authentication. I want to find the exact part where they
explain the bug with cookies, headers, middleware, or callback URLs.
If I am watching a product review, I do not only want “the product is good but
has trade-offs.” I want to find the moment where the reviewer talks about
battery life, build quality, software issues, or long-term use.
If I am watching a lecture, I do not only want the topic list. I may need the
definition, the example, or the part where the lecturer compares two ideas.
A summary gives me the shape of the content.
Search gives me access to the content.
That distinction is important.
## Long videos are often search problems
The longer the video, the more it starts to behave like a small knowledge base.
A one-hour interview, a course lesson, a conference talk, or a technical
walkthrough can contain dozens of useful moments. The value is not always evenly
distributed. Sometimes only three minutes matter to me. Sometimes I remember a
phrase but not the timestamp. Sometimes I watched the video last week and want
to return to one section without scrubbing through the timeline again.
This is why “search inside YouTube video” is a more interesting problem than it
first looks.
The user does not always want an AI-generated answer immediately. Sometimes the
user wants to search the YouTube transcript first, see matching lines, and jump
to the right moment. That is a simpler, more transparent workflow.
I think a good AI YouTube extension should respect that.
AI should not be the first answer to every problem. Sometimes plain transcript
search is faster, cheaper, and easier to trust.
That is one of the product decisions I care about with Cuelio: search first, AI
when the question needs context.
## A summary removes the path back to the source
This is the part that bothers me most about many AI summary tools.
They produce a clean answer, but they often make it harder to inspect the
source.
For casual browsing, that might be fine. For learning, research, technical work,
or decision-making, it is not enough.
If an AI answer says that the video recommends a specific approach, I want to
know where that came from. Was it stated directly? Was it inferred? Did the
speaker qualify it? Did they mention an exception ten seconds later?
Without a path back to the original moment, the answer becomes harder to trust.
That is why timestamped answers matter.
A timestamp is not just a convenience feature. It is part of the trust model.
When an AI answer points back to the exact part of the video, the user can
verify it. They can listen to the original wording. They can check the context
around the answer. They can decide whether the AI understood the video
correctly.
For me, that is the difference between an AI feature that feels useful and one
that feels like a black box.
## Timestamps are not a bonus feature
A lot of tools treat timestamps as a nice extra.
I see them as part of the interface.
Long videos are temporal content. The timeline is the source structure. If a
tool ignores that structure, it loses one of the most important ways users
navigate the video.
A good timestamped workflow should let the user move between three states:
1. The question or search query.
2. The relevant answer or transcript match.
3. The exact moment in the original video.
That loop matters.
Search result → timestamp → video moment.
AI answer → source timestamp → video moment.
Comment result → surrounding discussion → video context.
This is also where AI product design gets practical. It is not only about model
quality. It is about whether the interface helps the user understand and verify
the result.
For long YouTube videos, I do not think AI answers should float separately from
the video. They should stay connected to the transcript, timestamps, and
original source.
## Transcript search is different from summarization
Transcript search and summarization solve different problems.
Summarization says:
> “Here is what the video is mostly about.”
Transcript search says:
> “Here are the exact places where your topic appears.”
Both are useful. But they should not be treated as the same feature.
If I search for “pricing,” “OAuth,” “camera overheating,” “protein intake,”
“Next.js caching,” or “export settings,” I do not necessarily want the AI to
rewrite the whole video for me. I want matches. I want context around those
matches. I want timestamps. I want to decide which part to open.
This is why I like the search-first approach.
It keeps the user in control.
The AI can still help, especially when the question is more complex. But the
basic layer should be searchable and inspectable before it becomes generative.
That is also better for performance. Searching a transcript is usually much
cheaper and faster than sending a large amount of text to a model. For an early
product, those details matter.
## Comments can be part of the knowledge layer
YouTube comments are messy.
They can be noisy, repetitive, and sometimes useless.
But for many types of videos, they are also valuable.
In technical tutorials, comments often contain fixes, version updates, errors
people hit, or alternative approaches. In product reviews, comments often
include long-term user experience. In educational videos, someone may add a
correction or a better explanation. In creator videos, the discussion can reveal
what people actually cared about.
That makes comment search more than a convenience.
If I am researching a video, I do not always want to scroll through hundreds or
thousands of comments. I want to search them. I want to find whether someone
mentioned a specific tool, issue, feature, mistake, link, or follow-up question.
This is one of the reasons I want Cuelio to include YouTube comment search as
part of the workflow.
Not because comments should replace the transcript.
Because they can add another layer of context around the video.
For some use cases, the comments are where the practical reality shows up.
## AI answers need grounded context
The most useful AI answer is not always the most confident one.
It is the one that can show where it came from.
For a YouTube video, that means grounding the answer in the transcript and
connecting it to timestamps. If the answer is based on a specific part of the
video, the user should be able to jump there.
This matters because long videos create a lot of room for misunderstanding.
A model can compress too much. It can miss a condition. It can blend two parts
of the video together. It can turn a small example into a general
recommendation. It can answer in a way that sounds right but is hard to verify.
The interface should make verification easy.
That is why I think AI answers for YouTube videos should be designed less like
chat messages and more like source-linked navigation.
The answer is useful.
The source is what makes it trustworthy.
## Context optimization matters more than it sounds
One of the technical decisions behind Cuelio is context optimization.
Long YouTube videos can contain thousands of words. Sending the entire
transcript to an AI model for every question is the lazy solution. It can work
for a prototype, but it is not always the best product decision.
It can be slower.
It can be more expensive.
It can be less predictable.
It can also make the answer worse if the model receives too much irrelevant
context.
For an early product, this matters a lot. If every user question becomes
expensive and slow, the product becomes harder to test, harder to price, and
harder to scale.
So the better question is not “how much context can I send?”
The better question is:
> What is the smallest useful context the model needs to answer this question
> well?
That is the direction I am thinking about with Cuelio. Find the relevant
transcript parts first. Keep the answer grounded. Avoid sending unnecessary
text. Make the product faster and cheaper without making it feel worse.
This is also the kind of AI engineering decision that often gets hidden behind
the word “AI.”
From the outside, a feature looks simple: ask a question, get an answer.
Inside the product, the important work is deciding what context the model sees,
how the user verifies the output, and how the system stays fast enough to feel
useful.

## How this thinking shaped Cuelio
Cuelio is still in the final testing stage, so I am intentionally keeping the
first version focused.
The goal is not to ship every possible AI feature at once.
The goal is to make one workflow reliable:
- search the YouTube transcript;
- ask AI questions based on the video content;
- connect answers back to timestamps and sources;
- search comments without endless scrolling;
- save useful videos for later;
- export the transcript when needed.
That is enough for a first version.
I do not want to position Cuelio as a generic AI summarizer. I also do not want
the first version to pretend it can do everything: full comment analytics,
automatic transcript generation for every possible video, large account systems,
or a complete research platform.
Those things may become useful later.
But early products need focus.
For Cuelio, the focus is simple: turn YouTube into something closer to a
searchable knowledge base.
Not by replacing the video.
By making the useful parts easier to find, ask about, verify, and return to.
## What this means for AI product design
The bigger lesson for me is that AI features should not start with the model.
They should start with the workflow.
For long YouTube videos, the workflow is not always “summarize this.” Sometimes
it is:
- find the exact explanation;
- check the original wording;
- jump to the right timestamp;
- search a phrase in the transcript;
- ask a question that needs context;
- inspect the comments;
- save the video for later;
- export the transcript into another tool.
Once that workflow is clear, AI becomes easier to place.
It does not need to do everything. It needs to help at the right moment.
That is the product direction I care about more and more: AI that stays close to
the user’s context, reduces friction, and makes the output easier to verify.
Cuelio is a small product, but the same decisions show up in client work too.
When I build custom AI features, browser extensions, internal tools, or product
workflows, the hard part is rarely “connect an AI API.” The hard part is
deciding what the user actually needs, what context the system should use, where
the answer should appear, how the user verifies it, and how to keep the product
fast and affordable enough to use.
That is where AI product engineering becomes interesting.
Not in making everything shorter.
In making the right information easier to find.
---
## Building Cuelio: Why I’m Designing a Search-First AI Extension for YouTube
URL: https://www.pean.dev/blog/building-cuelio-search-first-ai-extension-for-youtube
Published: 2026-05-26
Description: A behind-the-scenes look at how I am designing Cuelio, a search-first AI browser extension for YouTube with transcript search, timestamped AI answers, comment search, saved videos, and transcript export.
Most AI tools for YouTube start with the same promise: summarize this video.
That is useful sometimes. If I open a 90-minute interview and only want a quick idea of what it covers, a summary helps. But when I use YouTube as a learning tool, a research source, or a technical reference, a generic summary is rarely the real thing I need.
I usually need something much more specific.
I want to find the exact moment where someone explains a concept. I want to search the transcript instead of dragging the timeline. I want to ask a question and see where the answer came from. I want to jump back to the source before trusting the AI response. Sometimes I also want to search the comments, because that is where people add corrections, extra context, links, warnings, and practical experience.
That is why I am building **Cuelio** as a search-first AI extension for YouTube, not just another AI summarizer.
Cuelio is currently in the final testing stage, so this is not a launch announcement. It is a product and engineering note about the decisions behind it: transcript search, timestamped AI answers, comment search, saved videos, transcript export, and context optimization for better performance and lower early-stage cost.

## YouTube is becoming a knowledge base
YouTube is not only entertainment anymore.
For many people it is where they learn software, compare products, watch lectures, follow tutorials, research tools, understand market opinions, and listen to long technical discussions. A single video can contain the answer you need, but the answer might be hidden at minute 38, inside a small explanation, or in a comment under the video.
That changes the product problem.
The issue is not always _watching_ the video. The issue is _finding_ the right piece of information inside it.
That is especially true for:
- students reviewing lectures or tutorials;
- developers looking for one implementation detail;
- researchers collecting arguments or references;
- marketers analyzing audience reactions;
- creators reviewing feedback in comments;
- product people comparing opinions across long videos.
In all of these cases, YouTube behaves less like a video platform and more like a messy knowledge base. But the default interface still makes you consume content mostly linearly.
Cuelio is my attempt to make that knowledge easier to search, question, verify, and return to.
## I do not want to build another YouTube summarizer
I have nothing against summaries. They are useful when the user genuinely needs a quick overview.
But I do not think “summarize this video” should be the default answer to every YouTube productivity problem.
A summary compresses the video. That is the point. But compression also removes details, examples, nuance, and sometimes the exact part the user actually needed. If the user is trying to learn, cite, debug, compare, or verify something, the summary is only a starting point.
The more useful workflow is often this:
1. search inside the video;
2. find the relevant section;
3. ask a focused AI question;
4. check the answer against the timestamped source;
5. jump back to the original moment.
That is the core difference I care about.
Cuelio is not designed as a tool that hides the video behind an AI answer. It is designed as a tool that helps the user reach the right part of the video faster.
## The real problem is not watching. It is finding.
Long videos are not always a waste of time. Sometimes they are valuable because they contain depth.
The problem is that the useful part is hard to reach.
If I am watching a technical tutorial, I may already know 80% of the topic. I do not want to sit through the whole video just to find one configuration detail. If I am watching a product review, I may only care about one comparison point. If I am watching a lecture, I may want to revisit the explanation of one term.
This is why transcript search is such an important base layer.
Before AI answers, before summaries, before any advanced workflow, the user needs a fast way to search the content that is already there.
That is where Cuelio starts.
## How Cuelio helps search inside a YouTube video
The basic workflow is intentionally simple:
1. open a YouTube video;
2. search the transcript;
3. jump to the exact timestamp;
4. ask an AI question if the answer needs context;
5. check the source before trusting the response;
6. search comments when the discussion around the video matters too.
That flow matters because it keeps the user close to the original material.
A lot of AI interfaces create a separate layer between the user and the source. They answer confidently, but the user still has to wonder where the answer came from. For YouTube, I think that is the wrong experience.
A good AI YouTube extension should not only answer. It should help the user verify.
## Transcript search should feel like search, not scrolling
YouTube transcripts are useful, but they are not always comfortable to work with.
The user may need to scan a long transcript, search for a phrase, understand where it appears, and then move from text back to video. That sounds simple, but the experience can become slow if it is treated like a secondary feature.
In Cuelio, transcript search is one of the main interactions.
The goal is to make it feel like searching inside the video itself:
- type a keyword or phrase;
- see matching transcript parts;
- understand the surrounding context;
- click the timestamp;
- continue watching from the exact moment.
This is also why I like the phrase **search-first**. It describes the real user behavior better than “summary-first”.
Most people do not open a long video because they want a smaller version of the whole thing. They open it because they believe the answer is somewhere inside.
## AI answers need sources and timestamps
AI answers are only useful if the user can trust them.
For Cuelio, that means answers should stay connected to the transcript and to the video timeline. If the extension answers a question about the video, the user should be able to see which parts of the transcript supported that answer and jump to the related timestamp.
That is important for two reasons.
First, it reduces the feeling that the AI response came from nowhere. The answer is not just a generated paragraph. It is tied to source material.
Second, it keeps the video as the primary source. Cuelio should help users understand the video faster, not replace the video with an unverifiable answer.
This is especially important for educational and technical content. If a developer asks about a command, a student asks about a concept, or a marketer asks about a claim, the exact context matters.
A timestamp is not just a navigation feature. It is part of the trust model.
## Comments are part of the knowledge layer
YouTube comments are messy, but they are often useful.
For tutorials, reviews, product comparisons, and educational videos, comments can contain:
- corrections from other viewers;
- links to related resources;
- warnings about outdated information;
- practical examples;
- answers from the creator;
- alternative opinions;
- follow-up questions.
The problem is that comments are hard to search manually. Scrolling through them is slow, and YouTube’s default interface is not designed for focused research.
That is why comment search belongs in Cuelio’s workflow.
It is not the same as full AI comment analytics, and I do not want to overpromise that as the main current feature. The first practical value is simpler: help the user find relevant comments without endless scrolling.
For some videos, the transcript explains the content. The comments explain how people reacted to it.
Both can matter.
## Saved videos and transcript export are small but important workflow features
Not every useful feature has to be AI.
Saved videos and transcript export are simple, but they support the real workflow around research and learning.
If a user finds a useful video, they may want to return to it later. If they are collecting notes, they may want to export the transcript. If they are comparing multiple videos, they may want to keep the source material organized.
These features are not flashy, but they make the product feel less like a one-time tool and more like part of a working process.
That is something I try to keep in mind when building small products. The best feature is not always the most impressive one. Sometimes it is the one that removes the next small friction point.
## Why context optimization matters in an AI YouTube extension
Long YouTube videos can contain a lot of transcript text.
The lazy approach would be to send the entire transcript to an AI model every time the user asks a question. Sometimes that may work in a prototype, but it is not a great product decision for an early extension.
It can be slower. It can be more expensive. It can make responses less focused. And if the product is still trying to prove its first workflow, unnecessary AI cost can become a real constraint too early.
So context optimization is an important part of how I think about Cuelio.
The goal is not to send more context. The goal is to send the right context.
For a question about a specific part of the video, the extension should first identify relevant transcript segments, then use those segments to generate an answer that still points back to the original timestamps. That gives the user a better experience and gives the product a more realistic cost structure.
This is one of the places where AI product engineering becomes more interesting than just connecting an API.
You have to think about:
- what the user is really asking;
- which transcript parts are likely relevant;
- how much context is enough;
- how to keep the answer grounded;
- how to reduce unnecessary model calls;
- how to make the workflow fast enough to feel native.
For an early product, these decisions matter a lot.
## What I am intentionally not building yet
Cuelio is still in the final testing stage, so I am intentionally keeping the first version focused.
I am not trying to turn it into a full research platform from day one. I am not positioning it as a generic AI chat app. I am not treating full AI comment analytics as the main promise of the current product. I also do not want to promise automatic transcript generation for every video without subtitles as if that is already the core experience.
The first version is about one clear workflow:
find useful information inside a YouTube video faster, understand it with enough context, and jump back to the original source when needed.
That focus helps with product quality. It also helps with engineering decisions. When the workflow is clear, it becomes easier to decide what belongs in the first version and what should wait.
## Who Cuelio is for
Cuelio is useful for people who treat YouTube as a source of information, not just a feed.
That includes students who need to search lectures, researchers who collect references, developers who look for exact technical explanations, marketers who analyze videos and comments, creators who review audience feedback, and anyone who wants to return to useful videos later.
The common pattern is simple: the user does not want to consume everything linearly.
They want to find the right part.
That is the product space I care about with Cuelio.
## What this taught me about AI product engineering
Building Cuelio has reinforced a simple idea for me: AI features are strongest when they are designed around a workflow, not around a demo.
A demo can summarize a video.
A product needs to help a user get from question to source, from source to answer, and from answer back to trust.
That is why I care about transcript search, timestamps, comment search, saved videos, transcript export, and context optimization as much as the AI response itself. The value is not in having AI somewhere in the interface. The value is in making YouTube easier to use as a searchable knowledge base.
That same thinking applies to custom development work too.
When I build AI-assisted tools, browser extensions, or product workflows, I do not want to add AI as decoration. I want to understand where the user gets stuck, what context the system already has, what should stay verifiable, and how to make the first version useful without overbuilding it.
Cuelio is a small product, but the product decisions behind it are the same decisions that matter in larger software: start with the real workflow, keep the interface close to the user’s context, optimize costs early, and make the output traceable back to the source.
---
## Building TableSnap: How I designed a local-first Chrome extension for web table workflows
URL: https://www.pean.dev/blog/building-tablesnap-local-first-chrome-extension-web-table-workflows
Published: 2026-05-22
Description: Why I built TableSnap: a local-first Chrome extension for copying web tables, cleaning messy extracts in preview, and exporting structured data as CSV, TSV, Markdown, HTML, or JSON.
Copying a table sounds simple until the table is messy, the destination expects
structure, and the user needs to trust the result before it reaches the
clipboard.
That was the starting point for [TableSnap](https://www.ts.pean.dev/).
I kept running into this in places that looked boring on the surface and costly
in practice: pricing pages, comparison tables, docs tables, admin screens, and
public datasets that were clearly meant to be read, but not clearly meant to be
reused.
At first glance, the problem looks almost too small to deserve a product.
You see a table on a page. You copy it. You paste it into Excel, Google Sheets,
Notion, Airtable, or a Markdown doc. Done.
Except that it is usually not done.
The rows break. The headers shift. Pricing cards pretend to be tables without
actually behaving like tables. Comparison layouts mix icons, notes, buttons,
and duplicated labels into something that looks structured on screen but falls
apart the moment you try to reuse it somewhere else.
That is the gap I wanted to solve.
Not a giant scraping platform. Not a heavy data pipeline. Not another dashboard
you open in a separate tab.
I did not want to build something that sounds impressive in a product deck and
still feels annoying the first time someone actually uses it.
A focused local-first Chrome extension for the moment when you need to move
structured data from the web into a tool that expects structure.

## The problem was never "copy"
The more I looked at this workflow, the more obvious it became that the hard
part was not copying.
The hard part was preserving meaning.
When someone says they want to copy a web table, they usually mean something
closer to this:
> I need the useful structure from this page to survive the trip into the next
> tool.
That is a very different product problem.
It is not about whether text can reach the clipboard. Browsers already do that.
It is about whether the exported result still feels usable after it leaves the
page.
That matters because the destination is not neutral.
A spreadsheet wants rows and columns that line up cleanly. A Markdown doc wants
something readable in plain text. JSON wants a predictable shape for scripts or
automation. A research workflow may need something quick to clean, annotate, and
share. A product team might want to drop the result into Notion or Airtable
without another round of manual repair.
So the real task is not "copy web tables."
It is:
> Detect the structure, let the user verify it, clean the messy parts, and
> export it in the format the next tool actually needs.
That became the center of the product.
I like product problems like this because they look small until you try to make
them feel reliable.
That is usually where the real work is.
## Why this became a Chrome extension
I did not want this workflow to start with a dashboard.
If the table is already on the page, the product should stay close to the page.
That sounds obvious, but it rules out a lot of awkward product shapes.
A hosted tool that asks for a URL can work for some extraction jobs. It feels
wrong for this one. The user is already looking at the source. They are already
scrolling, comparing columns, checking whether the page version is the one they
actually want, and deciding what should make it into the export.
Sending them away from that moment creates unnecessary friction.
So the browser itself became the right product surface.
That decision shaped the browser extension UX:
- the workflow had to feel immediate
- the result had to stay visually close to the source page
- the extension had to help with trust, not just output
- the product had to stay focused instead of becoming general-purpose scraping
TableSnap works better as a browser-native tool because web table workflows are
already happening inside the browser. The extension does not need to invent a
new workspace. It needs to make the existing one less fragile.
## Why I did not start with a bigger data product
It would have been easy to frame this as a larger SaaS from day one.
Projects. Saved URLs. Cloud jobs. Team workspaces. Extraction history. Shared
pipelines. Maybe even scheduled scraping.
Some of that could make sense later.
I did not want to start there.
The first product question was much narrower:
> Can I make copying web tables feel trustworthy enough that people stop doing
> repair work after every paste?
If that part is weak, a bigger backend does not fix the product. It only hides
the weakness behind more surface area.
Starting with a local-first Chrome extension keeps the product honest.
The table detection has to work. The preview has to help. The export has to make
sense immediately. The workflow has to earn its place the first time someone
needs it.
That constraint is useful.
## The workflow had to create trust before export
One thing I did not want was a "click and hope" product.
That is the easiest way to make table extraction feel unreliable.
If the extension instantly copies something and the user only discovers the
damage after pasting it into another tool, the workflow already lost. The error
arrives too late.
That is why TableSnap is built around a compact three-step flow:
1. detect the table on the live page
2. clean the extracted structure in preview
3. export in the format the destination expects
The preview step matters more than it might seem.
Preview is not decorative UI. It is the trust boundary.
It gives the user a chance to answer the questions that actually matter:
- Did the structure come through correctly?
- Are the headers useful?
- Did noise from the page leak into the result?
- Is this ready for Sheets, Notion, Markdown, or JSON?
Without that checkpoint, the extension becomes a black box. With it, the
workflow starts to feel dependable.
That is a product rule I keep coming back to:
> When the output is meant to travel, users need confidence before the handoff,
> not after it.
## "HTML table to CSV" is only part of the story
If I described TableSnap too narrowly, I could call it an `HTML table to CSV`
tool.
That would be technically true and product-wise incomplete.
Some pages use clean, textbook `
` markup.
Many do not.
Some use ARIA grids. Some use comparison layouts that are built from stacked
containers and still behave like tables to the human eye. Some pages mix icons,
badges, empty cells, repeated labels, or sticky headers into the layout. Some
tables are clearly meant for reading, but not clearly authored for export.
That is where DOM table extraction stops being trivial.
The job is not just to grab text from the page. The job is to interpret enough
structure that the result is still useful somewhere else.
That is also why I wanted TableSnap to support more than ideal HTML tables,
including ARIA grids and supported layout-based comparison tables.
Real-world workflows do not happen on perfect demo pages.
They happen on pricing pages, documentation sites, vendor comparisons, admin
surfaces, public datasets, and random pages where the author cared more about
presentation than export.
If the product only works on polite markup, it misses the point.
## Why local-first mattered
TableSnap is local-first because this workflow should feel lightweight.
I did not want the main experience to depend on creating an account, waiting for
remote processing, or sending a simple page-level extraction task through a
backend before the user gets value.
For this kind of product, local-first is not only a technical preference. It is
part of the UX.
The user is already inside a page. They want a quick result. They want to tweak
the output, export it, and move on.
That pushes the product in a clear direction:
- the core extraction workflow should stay in the browser
- settings and presets should stay close to the workflow
- site-specific recipes should stay local to the user
- the product should feel useful before any account-shaped idea enters the room
Local-first also helps the permission story stay understandable.
Broad browser access is only acceptable when the product purpose is narrow and
clear. In this case, the purpose is simple:
> The user opens a page and asks TableSnap to help them extract a table from
> that page.
That is a much healthier boundary than vague background behavior.

## Export formats are product decisions, not just checkboxes
I wanted the export layer to reflect real destinations, not just technical
possibilities.
That is why TableSnap exports to CSV, TSV, Markdown, HTML, and JSON.
Each format solves a different handoff:
- CSV and TSV are for spreadsheets and quick data cleanup in Excel or Google
Sheets
- Markdown is for docs, notes, issues, and workflows that need readable plain
text
- HTML is useful when table structure needs to travel with richer formatting
- JSON is for scripts, automation, and structured downstream processing
This part matters because people do not extract tables for fun. They extract
them because the data needs to go somewhere.
The export format is not the end of the feature. It is the start of the next
workflow.
That sounds small, but it changes how the product should be designed.
A table to Markdown flow should feel different from a table to JSON flow. One is
optimized for human reading. The other is optimized for machine handling. A
spreadsheet export often needs a shape that feels clean immediately, because the
user will notice broken columns in seconds.
Good product design respects the destination, not just the source.
## What I wanted TableSnap to avoid
Focused tools get worse when they drift into feature theater.
For TableSnap, I want to avoid a few common traps:
- pretending that extraction alone is enough without a cleanup step
- hiding the result until after the export
- turning the product into a generic scraping platform
- requiring an account before the first useful action
- supporting only one export shape and forcing every workflow into it
- collecting permissions that do not map back to the core job
- optimizing for perfect demo pages instead of messy real ones
The product should stay honest about what it is.
TableSnap is not trying to replace every data pipeline. It is trying to make one
frequent, annoying browser task feel clean and trustworthy.
That is enough.
## What building TableSnap reinforced for me
TableSnap reminded me that some of the best product opportunities are hiding
inside workflows that look too small to notice.
"Copy a table" sounds boring.
But a lot of useful software lives in exactly that territory: moments where the
job sounds simple, yet the current workflow is full of friction, uncertainty,
and tiny repeated cleanup costs.
Those are often good product surfaces because the pain is real, even if it does
not sound glamorous.
I trust repeated friction more than big category language.
If people keep hitting the same annoying edge between one tool and the next,
there is usually product space there.
Building this also reinforced a broader product-engineering lesson:
> The product is not the extraction. The product is the confidence that the
> extracted result will still make sense in the next tool.
That is why TableSnap is not only about DOM table extraction.
It is also about browser extension UX, trust before export, local-first product
boundaries, and choosing formats that match how people actually work with
structured data after it leaves the page.
If the product does those things well, then a very ordinary action starts to
feel much less fragile.
And that is usually a good sign.
## FAQ
### What is TableSnap?
TableSnap is a local-first Chrome extension for detecting web tables, cleaning
messy extracts in preview, and exporting usable data as CSV, TSV, Markdown,
HTML, or JSON.
### Is TableSnap just an HTML table to CSV tool?
No. CSV export is part of the workflow, but the product is broader than that.
TableSnap is designed around web table workflows: detection, preview cleanup,
and export for different destinations such as spreadsheets, docs, and
automation.
### Does TableSnap only work with normal HTML tables?
No. TableSnap is designed to support more than textbook HTML tables, including
ARIA grids and supported layout-based comparison tables where the page behaves
like a table even if the markup is less direct.
### Why make it local-first?
Because the workflow should feel lightweight, fast, and close to the page.
Keeping the main extraction flow in the browser reduces friction and makes the
product useful without turning a simple task into an account-first system.
### What export formats does TableSnap support?
TableSnap supports CSV, TSV, Markdown, HTML, and JSON export formats.
### Who is TableSnap for?
TableSnap is for people who move structured data from webpages into
spreadsheets, docs, databases, research notes, Notion, Airtable, or JSON-based
automation workflows.
---
Related reading:
- [TableSnap](https://www.ts.pean.dev/)
- [Why I built Crowra as a side-panel SEO and AI readiness inspector](/blog/why-i-built-crowra-side-panel-seo-ai-readiness-inspector)
- [How I think about building products as a developer, not just features](/blog/product-minded-developer-building-products-not-features)
---
## Next.js API Routes in 2026: Route Handlers, Server Actions, and When to Use Each
URL: https://www.pean.dev/blog/nextjs-api-routes-in-2026-route-handlers-server-actions-when-to-use-each
Published: 2026-05-19
Description: A practical guide to API routes in modern Next.js: what changed with the App Router, when to use Route Handlers, when Server Actions are enough, and how to avoid building the wrong abstraction.
People still search for **Next.js API Routes** because the phrase is familiar.
For years, the answer was simple: put a file in `pages/api`, export a handler,
and call it from the client.
That mental model still exists if you are using the Pages Router.
But in a modern App Router project, the conversation changed. You now have
Server Components, Server Actions, Route Handlers, server-side `fetch`, cached
reads, revalidation, and a lot of small decisions that did not exist in older
Next.js apps.
So when someone asks:
> Should I create an API route in Next.js?
The real answer is usually:
> What kind of server boundary do you actually need?
That question matters more than the file name.
This article is how I think about API routes in Next.js in 2026. Not as a
framework history lesson. Not as a documentation rewrite. Just the practical
rules I use when building App Router projects that need forms, dashboards,
webhooks, mobile clients, integrations, and real production behavior.
## Quick answer
If you are using the **Pages Router**, API Routes live in `pages/api`.
If you are using the **App Router**, the closest equivalent is usually a
**Route Handler** inside the `app` directory:
```txt
app/api/users/route.ts
```
Use **Route Handlers** when you need a real HTTP endpoint.
Use **Server Actions** when the server code belongs to your own app UI: forms,
buttons, dashboard mutations, user settings, internal product workflows, and
other actions triggered by your interface.
Use **Server Components** when you only need to read data and render a page.
That is the simple version.
The rest of the article is about the messy part: knowing which one fits the
job when a real product starts growing.

## API Routes are not gone
The first thing to clear up: API Routes are not gone.
If your project uses the Pages Router, `pages/api` is still a valid way to build
server-side endpoints inside a Next.js app.
A classic API Route looks like this:
```ts
// pages/api/hello.ts
import type { NextApiRequest, NextApiResponse } from 'next';
type ResponseData = {
message: string;
};
export default function handler(
req: NextApiRequest,
res: NextApiResponse
) {
res.status(200).json({ message: 'Hello from Next.js' });
}
```
That model is easy to understand. A request comes in, a handler runs, a response
goes out.
The confusion starts when the project uses the App Router.
In the App Router, you normally do not create `pages/api` routes. You create
**Route Handlers** with `route.ts` or `route.js`.
```ts
// app/api/hello/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({ message: 'Hello from Next.js' });
}
```
The goal is similar: expose an HTTP endpoint.
The shape is different: Route Handlers use the Web `Request` and `Response`
model and live naturally inside the App Router.
So when people say “Next.js API Routes” in 2026, they may mean one of two
things:
- the older `pages/api` feature from the Pages Router
- the broader idea of creating server endpoints in Next.js, which usually means
Route Handlers in the App Router
That is why search results and conversations often feel slightly mixed.
Everyone is using the same phrase, but not always talking about the same file
convention.
## Route Handlers are the App Router version of API endpoints
For most new App Router projects, Route Handlers are the endpoint primitive.
A Route Handler is useful when you need a URL that can be requested directly:
```txt
POST /api/webhooks/stripe
GET /api/public/products
POST /api/mobile/places
GET /api/feed.xml
```
The important part is not that it sits under `/api`. The important part is that
it is a real HTTP interface.
That means another system, browser, client, service, or script can call it
without knowing anything about your React component tree.
That is the main reason I reach for a Route Handler.
Not because it is “more backend”.
Because the thing I am building needs an endpoint.
## Where Server Actions fit
Server Actions solve a different problem.
A Server Action is server code that can be called from your own app. It is very
useful for product interactions that start inside your interface.
For example:
```ts
// app/settings/actions.ts
'use server';
export async function updateProfile(formData: FormData) {
const name = String(formData.get('name') || '').trim();
if (!name) {
return { error: 'Name is required' };
}
// check auth
// update database
// revalidate UI
return { success: true };
}
```
Then a form can use it directly:
```tsx
```
That is the part that feels different from older Next.js code.
In many older apps, I would create an API Route only because I needed a server
place to handle a form submission. The client would call `/api/profile`, the API
Route would validate the input, update the database, and return JSON.
In an App Router project, that API endpoint may not be necessary. If the action
is only used by my own UI, a Server Action is often a cleaner fit.
That does not mean Server Actions replace every API route.
It means they replace a specific kind of API route: the internal endpoint that
only existed to support one app-specific mutation.
## The question I ask first
Before choosing between a Route Handler and a Server Action, I ask one question:
> Does this need to be an HTTP endpoint, or is it just an app action?
That question removes a lot of noise.
If the answer is “this needs a stable URL that another client can call”, I use a
Route Handler.
If the answer is “this happens inside my own Next.js interface”, I usually start
with a Server Action.
If the answer is “I just need to read data for a page”, I probably do not need
either one. I can fetch data in a Server Component or call a server-side query
function directly.
The worst default is creating an API endpoint for everything just because that
was the old habit.
It works, but it often adds boilerplate without adding clarity.
## When I use a Route Handler
I use a Route Handler when the server code needs to behave like an API.
That usually means one of these cases.
### 1. Webhooks
Webhooks are the easiest example.
Stripe, Lemon Squeezy, GitHub, Clerk, Resend, and many other services need to
call your application at a public URL.
That is not a Server Action job. The request is not coming from your form or
button. It is coming from an external service.
```txt
POST /api/webhooks/stripe
```
A Route Handler gives you the right shape for that:
```ts
// app/api/webhooks/stripe/route.ts
export async function POST(request: Request) {
const body = await request.text();
// verify signature
// process event
// return response
return new Response('OK', { status: 200 });
}
```
You get the request body, headers, status codes, and response control you need.
### 2. External clients
If a mobile app, browser extension, another website, or external dashboard needs
to call your backend, use a Route Handler.
For example, imagine a mobile app that saves places to the same database your
Next.js web platform uses.
```txt
POST /api/mobile/places
```
That is an API contract. It should not depend on a React form existing in the web
app.
The mobile client needs a URL, a request shape, authentication, validation, and a
clear response.
That is Route Handler territory.
### 3. Public or shared APIs
Sometimes you want to expose data intentionally.
```txt
GET /api/public/tools
GET /api/public/status
GET /api/integrations/projects
```
If other clients are supposed to call it, treat it like an API.
That means a Route Handler, not a Server Action.
### 4. Custom response formats
Route Handlers are also the right choice when the response is not a normal UI
mutation result.
Examples:
- file downloads
- CSV exports
- RSS feeds
- XML
- text responses
- streamed responses
- custom cache headers
- redirects from an endpoint
- special status codes
A Server Action is not meant to be your general response formatting layer.
If you need to control the HTTP response, create an endpoint.
### 5. Integration boundaries
This is the most important product reason.
A Route Handler creates a boundary.
That boundary can be useful when different parts of a system need to talk to the
same backend behavior.
For example:
- web app
- mobile app
- browser extension
- admin panel
- automation script
- third-party service
If all of them need the same operation, hiding that operation inside a Server
Action tied to one UI is probably the wrong shape.
A shared service layer behind a Route Handler will usually age better.
## When I use a Server Action
I use a Server Action when the action belongs to the app interface.
Examples:
- submit a contact form
- update profile settings
- create a dashboard item
- delete a saved record
- change a project status
- invite a team member
- save a preference
- mark a notification as read
- run a simple internal mutation from a button
Those are not public API problems.
They are product interaction problems.
A Server Action keeps that flow close to the UI without forcing me to create a
separate endpoint, write a client-side `fetch`, parse JSON, return custom status
codes, and manually connect all the pieces.
For many internal mutations, that is less code and a clearer mental model.
But there is one important warning.
A Server Action is not magic security dust.
It still runs on the server, and you still need to validate input, check auth,
check ownership, handle errors, and avoid trusting the client. The fact that an
action is called from your UI does not mean the input is safe.
I treat Server Actions as server entry points.
Small ones are fine. Careless ones are not.
## When I use neither
A lot of Next.js code does not need a Route Handler or a Server Action.
This is easy to forget.
If a page needs to read data and render it, I usually start with a Server
Component:
```tsx
// app/projects/page.tsx
import { getProjectsForCurrentUser } from '@/features/projects/queries';
export default async function ProjectsPage() {
const projects = await getProjectsForCurrentUser();
return ;
}
```
No `/api/projects` call.
No client-side loading state just for the first render.
No unnecessary endpoint.
The page is already running on the server, so it can call server-side code.
That is one of the biggest mindset shifts in App Router projects. You do not
need to fetch from your own API every time you need data on a page.
If the code is already on the server, call the server function directly.
## The mistake I see most often
The most common mistake is carrying the old SPA habit into the App Router.
The old habit looks like this:
```txt
React component -> fetch('/api/something') -> API route -> database
```
That was a reasonable pattern in many apps.
But in the App Router, it is not always the best default.
For a server-rendered page, this can often become:
```txt
Server Component -> server query -> database
```
For a form mutation, this can often become:
```txt
Form -> Server Action -> service -> database
```
For a real endpoint, it should still be:
```txt
External request -> Route Handler -> service -> database
```
The goal is not to avoid API routes at all costs.
The goal is to stop creating them when the app does not need an API boundary.

## My practical decision table
This is the table I usually keep in my head.
| Scenario | What I usually use | Why |
| --- | --- | --- |
| Render a dashboard page with user data | Server Component | The page is already on the server |
| Submit a settings form inside the app | Server Action | It is an internal mutation |
| Delete an item from an admin table | Server Action | It belongs to the UI workflow |
| Handle a Stripe webhook | Route Handler | External service needs an endpoint |
| Build an endpoint for a mobile app | Route Handler | External client needs a stable API |
| Return a CSV export | Route Handler | You need custom response behavior |
| Serve an RSS feed | Route Handler | It is a URL-based response |
| Update data from a browser extension | Route Handler | The extension is a separate client |
| Fetch data for a Server Component | Direct server function | No endpoint needed |
| Share business logic between several entry points | Service function | Keep the rules outside the transport layer |
The last row is the one that saves projects from becoming messy.
Server Actions and Route Handlers are entry points. They should not become the
only place your business logic exists.
## Keep business logic out of the transport layer
A Route Handler is a transport layer.
A Server Action is also an entry point.
Neither one should automatically become the home for all product logic.
For real projects, I prefer this shape:
```txt
app/
api/
projects/
route.ts
dashboard/
projects/
actions.ts
page.tsx
features/
projects/
service.ts
queries.ts
validation.ts
```
Then the entry points stay small.
A Server Action can parse input, check the current user, call a service, and
revalidate the right page.
A Route Handler can parse a request, verify auth or signatures, call the same
service, and return a response.
The product rules live in the service layer.
That matters when the app grows.
At first, you may only have a dashboard button. Later, you may add a mobile app,
browser extension, webhook, scheduled job, or public integration.
If the logic is trapped inside one Server Action, reuse becomes awkward. If the
logic lives behind a service function, the entry point can change without
rewriting the product rules.
## Example: contact form
A contact form on your own site is usually a Server Action.
The user fills the form, clicks send, and your app sends an email or stores a
lead.
You do not need a public API just for that.
```tsx
```
The action can validate input, rate-limit if needed, send the message, and
return a result.
Would a Route Handler also work?
Yes.
But if nothing outside your own app needs that endpoint, the Route Handler is
probably extra ceremony.
## Example: Stripe webhook
A Stripe webhook is the opposite.
Stripe needs to send an HTTP request to your app. You need to read the raw
payload, verify the signature, handle the event, and return a response.
That is not an app action.
That is an integration endpoint.
Use a Route Handler.
```ts
// app/api/webhooks/stripe/route.ts
export async function POST(request: Request) {
const payload = await request.text();
const signature = request.headers.get('stripe-signature');
// verify event
// update subscription state
return new Response('OK');
}
```
The URL is part of the integration. The HTTP contract matters.
## Example: dashboard mutation
Imagine an internal dashboard where a user changes the status of a project.
```txt
Draft -> Active -> Archived
```
If that mutation only happens inside your web app, I would usually start with a
Server Action.
```ts
'use server';
export async function archiveProject(projectId: string) {
// check auth
// check project ownership
// update database
// revalidate dashboard
}
```
This keeps the mutation close to the product flow.
But if the same status change must also be available to a mobile app, an external
integration, or an automation script, I would move the product rule into a shared
service and expose a Route Handler for the external client.
The important thing is not the first file you create.
The important thing is whether the boundary still makes sense when the product
gets another client.
## Example: browser extension talking to a Next.js app
A browser extension is a separate client.
Even if the same person owns the extension and the web app, the extension still
needs to call a URL.
So if a Chrome extension needs to save an audit, submit page data, fetch account
limits, or generate a report, I would use a Route Handler.
```txt
POST /api/extension/audits
GET /api/extension/account
```
That gives the extension a normal HTTP contract.
The route can verify a token, check the user, validate the payload, call a shared
service, and return JSON.
Trying to model that as a Server Action would fight the shape of the problem.
## Example: read-only data for a page
Now imagine a page that lists the current user's projects.
My first choice is not an API route.
It is a server query called from a Server Component.
```tsx
export default async function ProjectsPage() {
const projects = await getProjectsForCurrentUser();
return ;
}
```
The page is rendered on the server. The data is needed for that render. The
client does not need to fetch it again just to make the architecture look like an
older SPA.
This is one of the places where modern Next.js can feel simpler if you let it.
## What about client-side fetching?
Client-side fetching still has a place.
I use it when the data is truly client-driven:
- search suggestions while typing
- infinite scrolling
- polling a temporary state
- loading optional panels after the page is interactive
- user-triggered filters that do not need to be part of the first render
In those cases, a Route Handler may be useful because the browser needs a URL to
fetch.
But I try not to use client-side fetching as the default for everything.
If the page needs the data to render properly, and the server already knows the
user and the context, server-side rendering is usually cleaner.
## How I think about migration from pages/api
If I inherit an older Next.js project with many `pages/api` routes, I do not
rewrite everything immediately.
I first group the routes by purpose.
### Keep endpoint-style routes as endpoints
Webhooks, public APIs, mobile endpoints, file responses, and integrations should
stay as endpoints.
If the project is moving to the App Router, those can become Route Handlers over
time.
### Move app-only mutations to Server Actions
Some API routes only exist because a form needed somewhere to send a request.
Those are good candidates for Server Actions.
Not always. But often.
### Move page reads into server queries
Some API routes exist because the client was fetching data that the server page
could now load directly.
Those may not need a route at all anymore.
They may become plain server-side query functions.
### Extract shared logic first
Before moving files around, I usually extract the real business logic into a
service module.
Then the transport can change without changing the rule itself.
That makes migration less risky.
## The hidden cost of unnecessary API routes
An API route is not bad.
But an unnecessary API route has a cost.
It adds another request. Another response shape. Another error format. Another
place to validate input. Another place to check auth. Another place to keep in
sync with the UI.
Sometimes that is worth it because the endpoint is a real boundary.
Sometimes it is just leftover architecture.
In small apps, that cost is easy to ignore.
In production apps, it shows up as confusion:
- the UI expects one error shape, the API returns another
- the same mutation exists in two places
- auth is checked differently across routes
- the database query is duplicated
- loading states exist only because the page avoided server rendering
- a simple form has more client code than product logic
That is what I try to avoid.
Not API routes.
Unnecessary boundaries.
## The hidden risk of overusing Server Actions
There is also an opposite mistake.
Some teams discover Server Actions and try to use them for everything.
That gets messy too.
A Server Action is not a public API strategy. It is not a webhook endpoint. It is
not the right interface for a mobile app. It is not a clean contract for a
browser extension. It is not a general replacement for every HTTP route.
If another system needs to call your app, give it an endpoint.
If the response format matters, give it an endpoint.
If the operation is part of an integration boundary, give it an endpoint.
Server Actions are excellent when they fit the product flow. They are awkward
when you force them to act like APIs.
## The rule that keeps things simple
The rule I actually use is this:
> Use the smallest server boundary that matches the job.
For rendering, that may be a Server Component.
For app mutations, that may be a Server Action.
For external access, that is usually a Route Handler.
For shared product rules, that should be a service function behind whichever
entry point needs it.
This keeps the app understandable.
The UI does not need fake APIs for every action.
External clients do not need to depend on UI-specific Server Actions.
Business logic does not get trapped inside one route file.
And the codebase has a better chance of staying readable after the first version
ships.
## How this relates to Server Actions vs API Routes
I wrote a more focused breakdown of the decision in
[Server Actions vs API Routes in Next.js: the rules I actually use](/blog/server-actions-vs-api-routes-in-nextjs-rules-i-use).
This article is the broader version.
If you are asking:
> Should this mutation be a Server Action or an API route?
Read that one.
If you are asking:
> How should I think about API routes in modern Next.js at all?
This is the mental model I would start with.
## Final rules I use
Here are the rules I come back to in real projects.
Use **Server Components** for server-rendered reads.
Use **Server Actions** for private app mutations triggered by your own UI.
Use **Route Handlers** for HTTP endpoints, webhooks, external clients, custom
responses, browser extensions, mobile apps, and integrations.
Keep **business logic** in service functions when it needs to be reused.
Do not create API routes just because older React apps did everything through
client-side `fetch`.
Do not use Server Actions as if they were public APIs.
And when the choice is unclear, ask the boring but useful question:
> Is this an endpoint, an app action, or just server-rendered data?
Most Next.js architecture decisions get easier after that.
## FAQ
### Are API Routes still used in Next.js in 2026?
Yes. API Routes still exist in the Pages Router. In App Router projects, the
modern equivalent for endpoint-style server code is usually a Route Handler in
`app/api/.../route.ts`.
### What replaced API Routes in the Next.js App Router?
For HTTP endpoints, Route Handlers are usually the App Router replacement for
API Routes. They let you define handlers like `GET`, `POST`, `PUT`, `PATCH`, and
`DELETE` inside a `route.ts` or `route.js` file.
### Should I use Server Actions or Route Handlers?
Use Server Actions for app-specific mutations triggered by your UI. Use Route
Handlers when you need an endpoint that can be called by external services,
mobile apps, browser extensions, webhooks, or client-side fetch requests.
### Can Server Actions replace API Routes?
Server Actions can replace some internal API routes that only existed for forms
or dashboard mutations. They should not replace webhooks, public APIs, mobile
endpoints, custom response routes, or integration endpoints.
### Where do API routes go in the Next.js App Router?
In the App Router, endpoint-style routes usually go in the `app` directory with a
`route.ts` file. For example, `app/api/users/route.ts` can define a `GET` or
`POST` handler.
### Should I fetch from my own API inside a Server Component?
Usually no. If the code is already running on the server, it is often cleaner to
call a server-side query function directly. Fetching from your own API is more
useful when the browser or another external client needs that endpoint.
### Are Route Handlers only for APIs?
No. Route Handlers are useful for any custom HTTP response: JSON APIs, webhooks,
RSS feeds, file downloads, redirects, XML, plain text, streams, and other URL-based
responses.
### Can a mobile app call a Server Action?
I would not design a mobile API around Server Actions. A mobile app should call a
clear HTTP endpoint, which usually means a Route Handler. That gives you a stable
contract, explicit auth, validation, and response formats.
### Are Server Actions secure?
Server Actions run on the server, but you still need to treat them like server
entry points. Always validate input, check authentication, check authorization,
and avoid trusting values from the client.
### What is the best default for a new Next.js App Router project?
A good default is: Server Components for reads, Server Actions for internal
mutations, Route Handlers for endpoints, and shared service functions for
business logic. That keeps the architecture flexible without adding unnecessary
API boilerplate.
## Related guides
- [Next.js App Router Architecture in 2026: How I Structure Production Apps](/blog/nextjs-app-router-architecture-in-2026)
- [Server Actions vs API Routes in Next.js: the rules I actually use](/blog/server-actions-vs-api-routes-in-nextjs-rules-i-use)
- [Server vs Client Components in Next.js: the rules I actually use](/blog/nextjs-server-vs-client-components-article)
- [What Actually Makes a Frontend Feel Fast](/blog/what-actually-makes-a-frontend-feel-fast)
If you are building a custom Next.js app and the architecture is starting to feel
unclear, I can help shape the first version, clean up the structure, or build the
product with a production-ready foundation.
[Send me the product context](/contact)
---
## Custom Web App vs No-Code: When to Build Instead of Assemble
URL: https://www.pean.dev/blog/custom-web-app-vs-no-code-when-to-build-instead-of-assemble
Published: 2026-05-17
Description: A practical guide for founders and small teams on when no-code is enough, when it starts to break down, and when custom web app development becomes the better choice.
No-code tools are useful. I want to start there because the conversation often
gets framed in the wrong way.
Custom development is not automatically better. No-code is not automatically
weak. A spreadsheet, a form builder, a simple Airtable base, or a few
automation steps can be the smartest possible first version of a product. I have
seen early ideas move faster because the team did not start by over-engineering
the system.
But I have also seen the other side.
A no-code setup starts as a shortcut. Then it becomes the source of truth. Then
it becomes the admin panel, workflow engine, database, notification system, and
customer experience at the same time. At that point, the problem is no longer
whether the team can keep assembling tools together. The problem is whether the
product has become important enough to deserve its own system.
That is the real decision behind custom web app development.

## The wrong question: should we use code or no-code?
The first mistake is treating this as a technology debate.
A better question is:
> What is the product trying to prove, and how much structure does it need to
> prove it?
If the goal is to test demand, collect interest, run a manual workflow, or learn
how users behave, no-code can be a very good choice. You do not need a custom
application to validate every idea. Sometimes a landing page, a form, a payment
link, and a manual back office are enough.
But if the product depends on a specific workflow, repeated usage, clean data,
user accounts, permissions, integrations, or a polished customer experience, the
decision changes.
Then you are not only building a prototype. You are designing a system.
## When no-code is the right choice
No-code is usually a good fit when the product is still mostly a learning tool.
That can mean:
- you need to validate whether people care about the problem
- you are building an internal prototype
- the workflow is simple and mostly linear
- the data model is small
- there are only one or two user roles
- automation is helpful, but not business-critical
- the team can still manually fix edge cases
- speed matters more than long-term structure
For example, if a founder wants to test a new booking idea, they may not need a
custom booking platform on day one. A form, a calendar tool, email notifications,
and a spreadsheet can be enough to understand whether users want the service.
That is not a compromise. That is good product discipline.
The same applies to internal tools. If a team needs a quick dashboard for a
temporary process, no-code can be more practical than spending weeks building a
custom interface.
The important thing is to be honest about what the no-code version is for. It is
often excellent for validation, operations, and learning. It becomes risky when
the team starts treating a fragile prototype as a long-term product foundation.
## Where no-code starts to break down
No-code usually starts to bend before it breaks.
At first, the warning signs are small. A few duplicated fields. A few naming
conventions that only one person understands. A Zapier or Make scenario that
everyone is afraid to touch. A database table that now has twenty columns
because it has slowly absorbed every edge case.
Then the product grows.
Users need accounts. Some data should be private. Some actions should be
available only to admins. A client should see one version of a page while the
internal team sees another. The product needs exports, filters, history,
notifications, file uploads, or more reliable integrations.
This is usually the moment when no-code stops feeling like speed and starts
feeling like friction.
Common signs include:
- the team is building workarounds instead of features
- small changes take too long because everything is connected indirectly
- data is duplicated across several tools
- permissions are hard to express cleanly
- the user experience feels like a stack of tools, not one product
- performance becomes unpredictable
- integrations fail silently or create messy states
- the team cannot easily test changes before shipping them
- only one person understands how the system really works
None of these signs mean the original no-code decision was wrong. They often
mean the opposite: the idea has grown far enough to need a more durable shape.
## The moment assembly becomes infrastructure
There is a point where a no-code system becomes more than a prototype.
It becomes infrastructure.
That can happen with a spreadsheet. It can happen with Airtable. It can happen
with Notion, Bubble, Webflow, Zapier, Make, Google Apps Script, or a collection
of small tools that were never meant to behave like one product.
The problem is not that these tools are bad. The problem is that the business
logic is now spread across too many places.
A custom web app starts to make sense when the product needs one clear place for
its core workflow, data model, permissions, and user experience.
Not because code is more impressive. Because the product needs fewer hidden
dependencies.
## A practical decision table
A simple way to think about the decision is to separate validation from
operation.
| Situation | No-code is usually enough | Custom development starts to make sense |
| --- | --- | --- |
| Product stage | Testing an idea | Repeating a proven workflow |
| Workflow | Simple and linear | Specific, multi-step, or conditional |
| Users | Few users, mostly manual support | Real accounts, roles, and permissions |
| Data | Small, flexible, easy to edit | Structured, relational, or sensitive |
| Integrations | Nice to have | Central to the product |
| UX | Good enough for learning | Part of the product value |
| Maintenance | One person can manage it | Team needs confidence and stability |
| Goal | Validate demand | Build a product foundation |
The line is not always obvious. But if a team is already using a no-code setup
every week, customers depend on it, and the workflow keeps getting more complex,
it is worth asking whether the product has outgrown the prototype.
## Custom does not have to mean big
One fear I often hear is that custom development means rebuilding everything.
It should not.
A good custom web app does not need to be the final version of the product. It
should be the smallest durable version of the workflow that already matters. I
wrote about this more directly in
[How to Scope a Custom MVP Before Hiring a Developer](/blog/how-to-scope-a-custom-mvp-before-hiring-a-developer).
The first custom version should usually focus on:
- the main user journey
- the core data model
- the parts of the workflow that break most often
- the permissions that actually matter
- the integrations that are truly central
- the admin actions the team performs repeatedly
- the minimum reporting needed to operate the product
It should not try to rebuild every automation, dashboard, report, and internal
edge case from the no-code version.
That is how custom projects become too large before they become useful.
## What to keep manual in the first custom version
Even when a product is ready for custom development, not everything needs to be
automated immediately.
Some parts can stay manual for a while:
- advanced admin dashboards
- rare support actions
- complex billing logic
- deep analytics
- custom notification rules
- one-off reports
- onboarding steps that still need human review
- integrations that are not used often
Keeping these parts manual is not a weakness. It protects the first custom
version from becoming a rebuild of the entire company process.
The goal is not to remove every manual step. The goal is to remove the fragile
parts that block the product from growing.
## Example: from spreadsheet to custom web app
Imagine a small team managing client requests in Google Sheets.
At first, the spreadsheet works well. There are columns for client name, status,
assigned person, due date, notes, and links. The team adds a few formulas. Then
they add a form. Then they add email notifications. Then they add file links,
manual status changes, review steps, and weekly reports.
Nothing is obviously broken. But everything is now fragile.
A client request is no longer just a row in a spreadsheet. It has a lifecycle.
Someone creates it. Someone reviews it. Someone changes its status. Someone
uploads files. Someone needs to see history. Someone else should not see private
notes. The team needs reminders, filters, and reliable reporting.
That is not a spreadsheet problem anymore.
That is a workflow product.
A custom web app in this case does not need to be huge. The first version might
only include:
- client request creation
- user roles
- status tracking
- file uploads
- internal notes
- basic notifications
- a clean admin view
- export or reporting for the team
That small system can already be more valuable than a much larger no-code setup
because it gives the workflow one clear home.
## Example: from no-code prototype to real product
A founder might start with a no-code landing page, a form, and manual
onboarding. This is often the right move.
The founder learns what users ask for. They see which use cases repeat. They
notice which data needs to be saved. They discover which parts of the process
are painful. They learn what should not be built.
Then users start asking for accounts. They want to return later. They want saved
history, exports, collaboration, reminders, and a cleaner experience.
At that point, the job changes. The founder is no longer validating whether the
problem exists. They are trying to turn the repeated workflow into a product.
That is where custom development becomes less about writing code and more about
designing the product's operating model.
## What a developer should help you clarify
If you are considering custom development, a good developer should not only ask
for a feature list.
They should help clarify:
- who the users are
- what the first useful workflow is
- what data needs to be stored
- which roles and permissions are real
- what can stay manual
- what must be reliable from the beginning
- which integrations are critical
- what should not be built yet
- how the product might grow without overbuilding now
This is especially important for early products. The most expensive mistake is
not choosing the wrong framework. It is building the wrong scope with too much
confidence.
A product-minded developer should reduce ambiguity before adding complexity.
## How to migrate without losing what works
Moving from no-code to a custom web app should not be treated as a dramatic
rewrite unless the current system is completely broken.
A better approach is usually:
1. Map the existing workflow.
2. Identify what users and the internal team already rely on.
3. Separate core product logic from temporary operations.
4. Design the first custom data model.
5. Build the smallest durable version of the core workflow.
6. Keep non-critical processes manual or external for now.
7. Migrate data carefully.
8. Replace the fragile pieces step by step.
This matters because no-code prototypes often contain real product knowledge.
They show how the team works, what users ask for, and which edge cases appear in
practice.
Do not throw that away. Translate it into a better system.

## Questions to ask before hiring a developer
Before hiring someone to build a custom web app, it helps to prepare answers to
a few practical questions:
- What is the main workflow we want to support?
- What are users currently doing before and after using the product?
- What part of the current setup breaks most often?
- Which data is essential?
- Which data is sensitive?
- Which user roles do we actually need?
- Which integrations are required for the first version?
- What can remain manual?
- What should we intentionally avoid building now?
- What would make the first custom version successful?
You do not need perfect answers. But if you can describe the workflow clearly,
the development conversation becomes much more productive.
## The real value of custom development
The value of custom development is not that everything is written from scratch.
The value is that the important parts of the product can be designed around the
actual workflow instead of being forced into the shape of existing tools.
That can mean:
- cleaner UX
- more reliable data
- better permissions
- fewer manual fixes
- easier maintenance
- stronger performance
- a system that can evolve with the product
The point is not to replace no-code because it is no-code. The point is to
replace workarounds when they start limiting the product.
## Final thought
No-code is often the right way to start. It helps teams learn quickly without
committing too early. But when a workflow becomes central to the business, when
users depend on it, and when the system starts bending under its own
workarounds, custom development becomes a strategic decision.
The best next step is not to rebuild everything.
The best next step is to identify the workflow that already works, remove the
fragile parts, and build the smallest custom system that can support it
properly.
If your no-code setup is starting to feel like a product held together by
workarounds, I work on custom web apps, MVPs, browser extensions, and backend
systems for teams that need product-minded development without overbuilding the
first version.
[Tell me what you are trying to build](/contact).
---
## How to Scope a Custom MVP Before Hiring a Developer
URL: https://www.pean.dev/blog/how-to-scope-a-custom-mvp-before-hiring-a-developer
Published: 2026-05-12
Description: A practical guide for founders and small teams on turning a product idea into a focused, buildable MVP scope before starting custom development.
Most MVPs do not get into trouble because the first version is too small. They
get into trouble because the first version is trying to be five products at
once.
I have seen this pattern many times. A founder or a small team starts with a
clear product idea, then the scope grows before anyone writes code. The first
version needs authentication, a dashboard, payments, an admin panel,
notifications, AI, analytics, roles, exports, onboarding, settings, and maybe a
mobile app too.
None of those things are wrong by themselves. The problem is that they often
show up before the team has answered the most important question:
**What is the smallest useful workflow we need to prove?**
That is what MVP scoping is really about. Not writing a perfect specification.
Not removing every nice idea. Not making the product cheap for the sake of being
cheap. A good MVP scope is a way to protect the first version from becoming too
big, too vague, and too expensive before it has learned anything.

## An MVP is not a smaller final product
A common mistake is to think of an MVP as the final product with fewer features.
That sounds reasonable, but it usually leads to bad decisions.
If you start from the final product, everything feels important. The admin panel
feels important. The billing system feels important. The analytics dashboard
feels important. The perfect UI states feel important. The second user role
feels important.
But an MVP should not start from the final product. It should start from the
first product risk.
For example:
- Can users complete the core workflow?
- Does this solve a painful enough problem?
- Is the product shape right: web app, mobile app, browser extension, internal
tool, API, or something else?
- Can the team operate the first version without building too much automation?
- Is there enough signal to justify the next version?
That changes the conversation. Instead of asking “what features should the
product have?”, you start asking “what do we need to build to learn the next
important thing?”
That is a much better question.
## Start with the product moment
Before I think about tech, I like to understand the product moment.
A product moment is the situation where the user actually needs the product. It
is not a feature list. It is not a database model. It is not a design system. It
is the real-world moment where someone is trying to do something and the current
way is annoying, slow, risky, manual, or unclear.
A few examples:
- A founder wants to review SEO and accessibility issues directly on a page
without jumping between tools.
- A small team wants to replace a spreadsheet workflow with a simple internal
dashboard.
- A coach wants to collect client progress from a mobile-first flow instead of
sending forms manually.
- A marketplace operator wants sellers to create listings and buyers to contact
them without building the whole marketplace platform yet.
When the product moment is clear, scope decisions become easier. If a feature
does not support that moment, it is probably not part of the first version.
This is also where a developer should ask uncomfortable questions. Not to slow
the project down, but to avoid building around an assumption that nobody has
checked.
Questions like:
- Who is the first user?
- What are they doing today instead?
- What part of that workflow is painful?
- What does success look like after one session?
- What has to happen for the product to feel useful?
- What can still be manual in version one?
If these questions are hard to answer, that is not a problem. It just means the
first step is not development yet. The first step is product clarification.
## Define one core workflow
The fastest way to make an MVP scope practical is to write down one core
workflow.
Not ten workflows. One.
For a SaaS product, it could be:
```text
User connects data → reviews generated results → exports a report
```
For a marketplace MVP:
```text
Seller creates listing → buyer discovers listing → buyer contacts seller
```
For a browser extension:
```text
User opens a page → extension analyzes the page → user sees actionable issues
```
For an internal tool:
```text
Team member uploads data → manager reviews status → client receives summary
```
This simple sequence is more useful than a long feature list because it shows
how the product is supposed to behave. It also exposes what is missing.
If the workflow cannot be described in one or two lines, the product may still
be too abstract. That does not mean it is a bad idea. It means it needs more
shaping before a developer can estimate it properly.
A good MVP scope usually has one primary workflow and maybe one or two support
flows. Authentication, settings, admin actions, and notifications can exist, but
they should support the main workflow instead of becoming the product.
## Separate must-have, later, and fake requirements
When a scope is unclear, every feature sounds like a must-have. I like to split
requirements into three groups.
| Type | Meaning | Example |
| --- | --- | --- |
| Must-have | The core workflow does not work without it | A user can create and submit the main form |
| Later | Useful, but not required to prove the first workflow | Advanced filters, saved views, team permissions |
| Fake requirement | Sounds professional, but does not reduce first-version risk | A complex admin panel before there are real users |
“Fake requirement” does not mean the idea is stupid. It means the timing is
wrong.

Some common fake requirements in early MVPs:
- advanced role management before there are different real user types
- custom analytics dashboards before there is meaningful usage
- full automation for operations that could be manual for the first customers
- mobile apps before the core workflow is validated on the web
- complex notification systems before the product has repeated usage
- a polished admin panel before the team knows what admins actually need
- a design system before the interface patterns have stabilized
The best MVPs are not the ones with the fewest features. They are the ones where
every feature has a job.
## Decide what can stay manual
This is one of the most important scoping decisions.
A lot of early products overbuild because they try to automate everything from
day one. But not every operation needs custom software immediately.
In a first version, it is often fine if:
- onboarding happens manually
- some emails are sent by a person
- data is reviewed before being published
- reports are generated with a small internal step
- customer support handles edge cases directly
- payments are managed through an existing tool first
Manual does not mean low quality. Manual can be a deliberate product strategy.
It lets you learn what the software should do before you hard-code the wrong
process.
The question is not “can this be automated?” Almost everything can be automated.
The better question is:
```text
Does automating this now help us prove the product, or does it just make the
first version bigger?
```
If the answer is the second one, keep it manual for now.
## Choose the technical shape after the workflow
A lot of teams start with technology too early.
They decide they need a mobile app, or a SaaS dashboard, or an AI product, or a
browser extension before the workflow is clear. Sometimes that is right. Often
it is just the most obvious shape, not the best one.
The technical shape should follow the product moment.
If the user needs to work inside a browser page, a browser extension might be a
better first product than a dashboard.
If the user needs to capture something quickly during the day, a mobile-first
flow might matter more than a complex web app.
If the product is mostly about managing internal operations, an internal tool
might be the right MVP before a customer-facing platform.
If the main value is data processing, the backend and API design may be more
important than the first UI.
If the idea is still being validated, a small web app may be enough before
committing to native mobile development.
This is where product-minded engineering matters. The job is not just to pick a
stack. The job is to pick the simplest technical shape that can prove the
workflow without blocking the future.
## Write the brief in plain language
Before hiring a developer, you do not need a huge technical specification. In
many cases, a short plain-language brief is more useful.
A good MVP brief should answer these questions:
```text
What are we building?
Who is the first user?
What problem are they trying to solve?
What is the core workflow?
What already exists today?
What should the first version definitely include?
What should it definitely not include yet?
What data does the product need to store?
Are there integrations?
Are there user roles?
Is design provided, or should the developer help shape the UX?
What is the expected timeline?
What would make the first version successful?
```
This does not have to be perfect. It just needs to be clear enough for a
developer to understand the product direction and ask better questions.
The most useful part is often “what should it definitely not include yet?”
That sentence prevents a lot of scope creep.
## Do not estimate a vague product
A vague product can always be estimated, but the estimate will not mean much.
If someone asks for “a platform where users can manage projects, invite teams,
track progress, get notifications, export reports, and use AI to improve their
workflow”, the range could be enormous.
That could be a focused four-week prototype. It could also be a six-month
product.
The difference is scope.
Before asking “how much will it cost?”, it is better to ask:
- What is the first workflow?
- What can be removed?
- What can be manual?
- What does the first user actually need?
- What does version one need to prove?
A good developer should not give you a confident fixed answer while the product
is still blurry. They should help make the product less blurry first.
## Example: scoping a browser extension MVP
Imagine the idea is a browser extension for reviewing websites.
The big version might include:
- page analysis
- SEO checks
- accessibility checks
- schema validation
- AI suggestions
- saved reports
- team workspaces
- history
- billing
- shareable links
- project dashboards
- integrations with other tools
That is too much for a first version.
A better MVP scope could be:
```text
A user opens a webpage, runs an analysis from a side panel, sees a prioritized
list of issues, and exports a simple report.
```
Now the scope is easier to reason about.
Must-have:
- extension loads on the active page
- side panel UI
- core checks
- issue list with severity
- basic export
Later:
- team accounts
- saved history
- billing
- advanced report customization
- integrations
Manual or skipped:
- onboarding emails
- public dashboard
- complex account system
- organization management
This first version is much more buildable. It may still be useful. And it gives
the team real feedback before investing in the bigger product.
## Example: scoping a custom web app MVP
Now imagine a team wants to replace an internal spreadsheet with a custom web
app.
The first instinct may be to build a full system:
- login
- dashboard
- CRUD for all entities
- roles and permissions
- audit logs
- notifications
- charts
- exports
- comments
- file uploads
- admin settings
Some of that may be needed. But the MVP question is different:
```text
What is the one internal workflow that is painful enough to replace first?
```
A focused first version could be:
```text
Team members submit weekly client updates, managers review them, and the system
produces a clean summary for each client.
```
That gives the first version a clear job. You can still build it properly, with
a good data model and room to grow, but you are not pretending to know the whole
future system on day one.
## A good MVP scope should protect the future
Small does not mean careless.
This is an important point. Some people hear “MVP” and think it means quick,
dirty, and disposable. Sometimes that is fine for a throwaway prototype. But for
custom software, the first version often becomes the foundation for the next
version.
That means the scope should be small, but the engineering should still be
responsible.
For me, that usually means:
- clear data models
- simple but stable architecture
- readable code
- basic validation
- sensible error states
- secure handling of user data
- deployment that can be repeated
- enough logging to debug real usage
- UI that is simple, but not confusing
You do not need every enterprise feature in version one. But you do need a codebase
that does not punish you for learning.
## What I expect before starting development
When someone reaches out with a custom MVP idea, I do not expect a finished
specification. That is usually unrealistic.
But I do want enough context to understand the shape of the problem.
The most helpful message looks something like this:
```text
I want to build a product for [type of user].
They currently solve the problem by [current workaround].
The painful part is [specific pain].
The first workflow should be [simple sequence].
I think the first version needs [must-haves].
I am unsure about [open questions].
I would like to launch or test it by [timeline].
```
That is already a strong start. From there, a developer can help turn the idea
into screens, data models, technical decisions, and a realistic build plan.
## The output of scoping is not a document. It is clarity.
You can write a long requirements document and still have a bad scope.
The real output of MVP scoping is clarity:
- clear user
- clear workflow
- clear must-haves
- clear non-goals
- clear technical shape
- clear first release
- clear next questions
When those things are clear, development becomes much healthier. There are fewer
surprises. Estimates are more realistic. Trade-offs are easier to discuss. The
first version has a better chance of becoming something useful instead of just
something complete.
That is the difference between building features and building a product.
## Before you hire a developer
Before you hire a developer for a custom MVP, try to reduce the idea to one
sentence:
```text
We are building [product shape] for [first user] so they can [core workflow]
without [current pain].
```
For example:
```text
We are building a browser extension for technical marketers so they can review
SEO and accessibility issues directly on a webpage without switching between
multiple tools.
```
Or:
```text
We are building a web app for small operations teams so they can collect,
review, and send client updates without managing everything in spreadsheets.
```
If that sentence is hard to write, the product probably needs more scoping. If
that sentence is clear, the first version becomes much easier to shape.
And that is usually where custom development should begin.
Not with a huge feature list.
Not with a perfect spec.
With a focused product moment, a buildable first workflow, and a clear reason
for every feature that makes it into version one.
If you are shaping a custom MVP, web app, browser extension, or backend-heavy
product, I can help turn the idea into a focused first version before it gets
too big.
[Send me the product context](/contact)
---
## Server Actions vs API Routes in Next.js: the rules I actually use
URL: https://www.pean.dev/blog/server-actions-vs-api-routes-in-nextjs-rules-i-use
Published: 2026-05-06
Description: A practical guide to choosing between Server Actions and API Routes in Next.js App Router. Learn when to use Server Actions, when to use Route Handlers, and how to structure mutations, forms, validation, auth, and external API calls.
Next.js gives you more than one way to run code on the server.
That is useful.
It is also confusing.
In older Next.js projects, the answer was usually simple: create an API Route, call it from the client, and handle the request on the server. In App Router projects, the decision is not always that obvious anymore.
You can use Server Components. You can use Server Actions. You can use Route Handlers. If you still have a Pages Router setup, you may also have API Routes.
The question is not:
**Which one is newer?**
The better question is:
**Which one fits the job?**
This article is the practical version of that decision. Not a full documentation rewrite. Not a framework debate. Just the rules I actually use when deciding between Server Actions and API Routes in real Next.js projects.

## First: API Routes and Route Handlers are not exactly the same thing
Before comparing anything, it is important to clarify the naming.
In the Pages Router, Next.js has **API Routes** inside `pages/api`.
In the App Router, the equivalent concept is usually **Route Handlers** inside the `app` directory using `route.ts` or `route.js`.
So when people say:
> Should I use Server Actions or API Routes in App Router?
They often really mean:
> Should I use Server Actions or Route Handlers?
That distinction matters.
If you are building a modern App Router project, you usually compare:
- Server Actions
- Route Handlers
- Server Components
If you are maintaining an older Pages Router project, you may still compare:
- API Routes
- client-side fetch calls
- server-side rendering functions
In this article, I will use “API Routes” in the common broad sense, but technically, for App Router, the server endpoint option is usually a **Route Handler**.
## The simple rule
Here is the shortest version:
**Use Server Actions for app-specific mutations.**
**Use Route Handlers or API Routes for HTTP endpoints.**
That one rule solves most decisions.
A Server Action is great when a user does something inside your app and you need to change data on the server.
Examples:
- submitting a form
- creating a post
- updating a profile
- deleting a saved item
- saving user preferences
- triggering a database mutation
- revalidating app data after a change
A Route Handler or API Route is better when you need a real HTTP endpoint.
Examples:
- receiving a webhook
- exposing data to another app
- handling requests from a mobile app
- building a public API
- proxying a third-party service
- supporting non-React clients
- returning custom JSON, files, feeds, or streaming responses
That is the main split.
One is about **app actions**.
The other is about **HTTP interfaces**.
## What Server Actions are good at
Server Actions are server functions that can be called from your Next.js application.
The most obvious use case is a form.
Instead of creating a client-side submit handler, calling `/api/something`, parsing JSON, and then updating the UI, you can send the form directly to a server function.
Example:
```tsx
// app/settings/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
const schema = z.object({
displayName: z.string().min(2).max(80),
})
export async function updateProfile(formData: FormData) {
const result = schema.safeParse({
displayName: formData.get('displayName'),
})
if (!result.success) {
return {
ok: false,
message: 'Please enter a valid display name.',
}
}
// Check auth here
// Update database here
revalidatePath('/settings')
return {
ok: true,
message: 'Profile updated.',
}
}
```
Then you can use it from a form:
```tsx
// app/settings/page.tsx
import { updateProfile } from './actions'
export default function SettingsPage() {
return (
)
}
```
That is the kind of code Server Actions are designed for.
The user performs an action. The server changes something. The page can revalidate. The workflow stays close to the component that needs it.

## What API Routes and Route Handlers are good at
API Routes and Route Handlers are better when the main thing you are designing is an HTTP contract.
For example, a Stripe webhook should not be a Server Action.
It is not called by your React component. It is called by Stripe. It needs a stable URL, a request body, headers, verification, status codes, and a response.
That belongs in a Route Handler.
Example:
```ts
// app/api/stripe/webhook/route.ts
import { NextResponse } from 'next/server'
export async function POST(request: Request) {
const body = await request.text()
const signature = request.headers.get('stripe-signature')
if (!signature) {
return NextResponse.json({ error: 'Missing signature' }, { status: 400 })
}
// Verify webhook signature here
// Handle event here
return NextResponse.json({ received: true })
}
```
This is not really an “app action”.
It is an endpoint.
That is where Route Handlers make more sense.

## My decision table
Here is the decision table I use most often.
| Situation | Use Server Action | Use Route Handler / API Route |
|---|---:|---:|
| Form inside your Next.js app | Yes | Sometimes |
| Create, update, or delete app data | Yes | Sometimes |
| Needs to be called by another service | No | Yes |
| Webhook | No | Yes |
| Public API | No | Yes |
| Mobile app or external client needs access | No | Yes |
| Needs custom HTTP status codes and headers | Sometimes | Yes |
| File upload with custom handling | Sometimes | Yes |
| Simple internal mutation | Yes | No |
| Data fetch for rendering UI | Usually no | Sometimes |
| Third-party callback URL | No | Yes |
| Shared backend endpoint | No | Yes |
The most important question is:
**Who is calling this code?**
If the caller is your own React app, a Server Action may be the cleanest option.
If the caller is anything else, use a Route Handler or API Route.
## Rule 1: Use Server Actions for forms
Forms are the clearest Server Actions use case.
A form already represents an action:
- create account
- update settings
- save item
- delete record
- send message
- join waitlist
- submit feedback
In the old pattern, you might write something like this:
```tsx
async function onSubmit(values) {
await fetch('/api/profile', {
method: 'POST',
body: JSON.stringify(values),
})
}
```
Then you create the API endpoint:
```ts
export default async function handler(req, res) {
// parse request
// validate data
// check auth
// update database
// return JSON
}
```
That works.
But for many app forms, it adds ceremony.
With Server Actions, the mutation can live closer to the UI flow.
That does not mean all form logic belongs inside the component. I usually keep actions in a separate `actions.ts` file when the logic is reused or non-trivial.
Example structure:
```txt
app/
settings/
page.tsx
actions.ts
schema.ts
```
That gives you a simple mental model:
- `page.tsx` renders the UI
- `schema.ts` validates the input
- `actions.ts` performs the mutation
Clean enough for small features.
Structured enough for real projects.
## Rule 2: Use Route Handlers for webhooks
Webhooks should almost always be Route Handlers.
A webhook is an external HTTP request. It usually needs:
- raw request body
- signature verification
- specific status codes
- provider-specific headers
- retry-safe behavior
- logging
- idempotency
That is endpoint territory.
Do not force webhooks into Server Actions.
Good examples for Route Handlers:
- Stripe webhook
- Lemon Squeezy webhook
- GitHub webhook
- Clerk webhook
- Supabase webhook
- Resend inbound email webhook
- custom integration callback
A webhook needs a URL that another system can call.
That is not what Server Actions are for.
## Rule 3: Use Server Actions for private app mutations
Most product apps have many small private mutations.
For example:
- save a place
- rename a collection
- archive a project
- update a task status
- mark a notification as read
- change a workspace setting
- invite a teammate from the dashboard
These actions are usually not part of a public API.
They are not used by a mobile app.
They are not called by an external service.
They only exist because the current web app needs them.
That is where Server Actions feel natural.
You can still keep the logic clean by separating the layers:
```txt
app/
dashboard/
actions.ts
features/
projects/
service.ts
schema.ts
permissions.ts
```
The Server Action should not become a giant file full of business logic.
I like this pattern:
```ts
'use server'
import { createProject } from '@/features/projects/service'
import { createProjectSchema } from '@/features/projects/schema'
export async function createProjectAction(formData: FormData) {
const parsed = createProjectSchema.safeParse({
name: formData.get('name'),
})
if (!parsed.success) {
return { ok: false, message: 'Invalid project name.' }
}
return createProject(parsed.data)
}
```
The action handles the app boundary.
The service handles the business operation.
That separation matters as the project grows.
## Rule 4: Use Route Handlers when you need an API contract
Sometimes you need an endpoint even if the current caller is your own frontend.
That happens when the endpoint has a meaningful HTTP contract.
Use a Route Handler when you care about:
- request method
- URL shape
- status code
- response format
- headers
- caching behavior
- streaming
- external reuse
- non-React clients
Example:
```ts
// app/api/reports/route.ts
import { NextResponse } from 'next/server'
export async function GET() {
const reports = await getReports()
return NextResponse.json({ reports })
}
```
This endpoint can be called from different places.
It has a clear URL.
It returns JSON.
That is an API.
A Server Action would make the code feel too tied to a single UI workflow.
## Rule 5: Do not use Server Actions as a general API layer
This is one of the easiest mistakes to make.
Server Actions are convenient, so people start using them for everything.
That can make the app feel clean at first.
But then problems appear.
For example:
- another client needs the same mutation
- an integration needs a public endpoint
- you need custom status codes
- you need request-level middleware behavior
- you want to document the API
- you want to test it as an HTTP endpoint
- you need stable URLs across clients
If your server code is really an API, make it an API.
Do not hide it behind Server Actions just because the first caller is a React component.
A good question to ask:
**Would this still make sense if I had a mobile app tomorrow?**
If yes, a Route Handler may be the safer long-term choice.
## Rule 6: Do not create API endpoints just to avoid Server Actions
The opposite mistake is also common.
Some teams keep creating API endpoints for every tiny mutation because that is how they wrote Next.js before App Router.
That can lead to unnecessary boilerplate:
- client submit handler
- fetch call
- JSON parsing
- API route
- duplicated validation
- manual loading states
- manual revalidation
For many internal app mutations, that is more code than you need.
If the action belongs to one app workflow and does not need to be exposed as an HTTP API, Server Actions are often simpler.
Less ceremony is a valid technical reason.
## Rule 7: Validation belongs on the server either way
This rule is not about Server Actions vs API Routes.
It applies to both.
Client-side validation is useful for user experience.
Server-side validation is required for correctness.
Never trust the browser.
Whether the data arrives through a Server Action or a Route Handler, validate it on the server before writing to the database.
A common pattern:
```ts
import { z } from 'zod'
export const createTaskSchema = z.object({
title: z.string().min(1).max(120),
})
```
Then reuse that schema in the boundary that receives the input.
For a Server Action:
```ts
const parsed = createTaskSchema.safeParse({
title: formData.get('title'),
})
```
For a Route Handler:
```ts
const body = await request.json()
const parsed = createTaskSchema.safeParse(body)
```
Same rule.
Different boundary.
## Rule 8: Auth also belongs on the server
Do not rely on the client to decide whether a user is allowed to perform an action.
A hidden button is not security.
A disabled form is not security.
A protected page is not always enough.
Every sensitive mutation should check authorization on the server.
For Server Actions, check auth inside the action or inside the service it calls.
For Route Handlers, check auth inside the handler or middleware, depending on the app.
Example:
```ts
export async function deleteProjectAction(formData: FormData) {
const user = await getCurrentUser()
if (!user) {
return { ok: false, message: 'You must be signed in.' }
}
const projectId = String(formData.get('projectId'))
const canDelete = await userCanDeleteProject(user.id, projectId)
if (!canDelete) {
return { ok: false, message: 'You do not have access to this project.' }
}
await deleteProject(projectId)
return { ok: true }
}
```
The important part is not the exact auth library.
The important part is that the permission check happens on the server boundary.
## Rule 9: Keep data fetching separate from mutations
Server Actions are mainly for mutations.
That means actions like:
- create
- update
- delete
- submit
- save
- archive
- invite
- reorder
For reading data, I usually start with Server Components and direct server-side data fetching.
Example:
```tsx
export default async function ProjectsPage() {
const projects = await getProjects()
return
}
```
You do not need a Server Action just to read data for initial rendering.
You also do not always need a Route Handler.
If the data is only used to render the page on the server, fetch it on the server.
Use Route Handlers for reads when you need an endpoint.
Use client-side fetching when the data changes after interaction or needs to load independently in the browser.
Use Server Actions mostly when the user is changing something.
## Rule 10: Think about revalidation before choosing
In App Router, data updates are not just about writing to the database.
You also need to think about what should update after the mutation.
Server Actions work nicely with revalidation.
For example:
```ts
import { revalidatePath } from 'next/cache'
export async function createPostAction(formData: FormData) {
// create post
revalidatePath('/posts')
return { ok: true }
}
```
That is a good fit when the mutation belongs to a page or section of the app.
Route Handlers can also trigger revalidation, but the workflow can feel more detached from the UI.
So I ask:
**Does this mutation mostly exist to update this app interface?**
If yes, Server Action.
**Does this mutation mostly exist as an HTTP endpoint?**
If yes, Route Handler.
## Rule 11: Be careful with reusable actions
Server Actions can be reused, but I try not to treat them like a random utility library.
This is fine:
```txt
app/
dashboard/
actions.ts
```
This can become messy:
```txt
lib/
actions.ts
```
A giant global `actions.ts` file usually becomes a junk drawer.
Better options:
```txt
features/
billing/
actions.ts
service.ts
schema.ts
features/
teams/
actions.ts
service.ts
schema.ts
```
Or, for route-specific actions:
```txt
app/
settings/
actions.ts
```
The goal is not to follow one perfect folder structure.
The goal is to keep actions near the product feature they belong to.
## Rule 12: Use Route Handlers for third-party API proxying
Sometimes the browser should not call a third-party API directly.
Maybe you need to hide a secret key.
Maybe you need to normalize the response.
Maybe the third-party API has CORS limitations.
Maybe you want to rate-limit or log requests.
In those cases, a Route Handler often makes more sense than a Server Action.
Example:
```ts
// app/api/search/route.ts
import { NextResponse } from 'next/server'
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const query = searchParams.get('q')
if (!query) {
return NextResponse.json({ error: 'Missing query' }, { status: 400 })
}
const results = await searchExternalService(query)
return NextResponse.json({ results })
}
```
This behaves like a normal API endpoint.
It can be called by search UI, background jobs, tests, or another client later.
That flexibility is useful.
## Rule 13: Use Server Actions when progressive enhancement matters
One underrated benefit of Server Actions is that they fit naturally with HTML forms.
That means you can often build flows that are less JavaScript-heavy.
The user submits a form.
The server handles it.
The app responds.
For many product forms, this is a simpler model than managing everything through client-side event handlers.
This does not mean you never need client-side code.
You still may need:
- optimistic UI
- pending states
- inline validation feedback
- dynamic inputs
- modals
- autocomplete
- drag and drop
But the mutation itself can still live on the server.
The best setup is often a combination:
- Client Component for interactivity
- Server Action for the mutation
- shared schema for validation
- server-side auth check
- revalidation after success
That gives you both good UX and a clean server boundary.
## Rule 14: Use Route Handlers for files, feeds, and custom responses
Server Actions are not the right tool for every server-side task.
If you are returning something that is not a normal app mutation result, use a Route Handler.
Good examples:
- `sitemap.xml`
- `robots.txt`
- RSS feed
- CSV export
- PDF generation endpoint
- file download
- image proxy
- Open Graph image route
- streaming response
- custom JSON API
These are response-oriented tasks.
They need an HTTP response.
Route Handlers are built for that.
## Rule 15: Do not ignore testing
Testing can influence the decision too.
Server Actions are easy to test as functions if the logic is separated well.
Route Handlers are easy to test as HTTP boundaries if the endpoint contract matters.
That is another reason I prefer separating business logic into services.
Instead of testing only this:
```ts
export async function createProjectAction(formData: FormData) {
// everything happens here
}
```
I prefer this:
```ts
export async function createProject(input: CreateProjectInput) {
// business logic here
}
```
Then the Server Action becomes a thin wrapper:
```ts
export async function createProjectAction(formData: FormData) {
// parse input
// check auth
// call createProject
}
```
The same service can also be used by a Route Handler later if needed.
That keeps your architecture flexible.
## A practical example: creating a project
Let’s say you are building a dashboard and users can create projects.
The project creation flow is only used inside your web app.
It does not need to be public.
It does not need a mobile client.
It is a normal form mutation.
I would use a Server Action.
```tsx
```
Then in the action:
```ts
'use server'
import { revalidatePath } from 'next/cache'
import { createProjectSchema } from './schema'
import { createProject } from '@/features/projects/service'
export async function createProjectAction(formData: FormData) {
const parsed = createProjectSchema.safeParse({
name: formData.get('name'),
})
if (!parsed.success) {
return { ok: false, message: 'Invalid project name.' }
}
await createProject(parsed.data)
revalidatePath('/dashboard/projects')
return { ok: true }
}
```
This is a good Server Action use case.
The action is private to the app.
The form is simple.
The mutation updates app data.
Revalidation is close to the workflow.
## A practical example: receiving a webhook
Now let’s say Stripe needs to tell your app that a subscription was updated.
That is not a Server Action.
Stripe does not submit your React form.
Stripe calls an endpoint.
I would use a Route Handler.
```ts
// app/api/billing/webhook/route.ts
import { NextResponse } from 'next/server'
export async function POST(request: Request) {
const body = await request.text()
const signature = request.headers.get('stripe-signature')
if (!signature) {
return NextResponse.json({ error: 'Missing signature' }, { status: 400 })
}
// verify signature
// update subscription state
return NextResponse.json({ ok: true })
}
```
This needs an HTTP boundary.
The URL matters.
The method matters.
The headers matter.
The status code matters.
Route Handler is the right tool.
## A practical example: saving a user preference
Now imagine a user toggles a setting in your app.
For example:
- dark mode preference
- compact layout preference
- email notification setting
- default workspace
If that setting is only used inside your web app, I would usually use a Server Action.
```ts
'use server'
export async function updateNotificationSettings(formData: FormData) {
const user = await getCurrentUser()
if (!user) {
return { ok: false, message: 'Unauthorized' }
}
const enabled = formData.get('enabled') === 'on'
await saveNotificationSettings(user.id, { enabled })
return { ok: true }
}
```
This is not a public API.
It is a private app mutation.
Server Action fits.
## A practical example: building a public endpoint
Now imagine users can fetch public project data from your app.
Example:
```txt
GET /api/public/projects/acme
```
That should be a Route Handler.
```ts
// app/api/public/projects/[slug]/route.ts
import { NextResponse } from 'next/server'
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params
const project = await getPublicProject(slug)
if (!project) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json({ project })
}
```
This endpoint has a public contract.
Someone may bookmark it, call it from another app, or integrate with it.
That is not a Server Action job.
## The mistake I try to avoid
The biggest mistake is choosing based on fashion.
Server Actions are not automatically better because they are newer.
API Routes are not automatically worse because they are older.
Route Handlers are not automatically necessary just because the code runs on the server.
The right decision depends on the boundary.
Ask these questions:
- Is this triggered by a form or app interaction?
- Is this only used inside this Next.js app?
- Does it need a stable URL?
- Will another client call it?
- Do I need custom status codes or headers?
- Is this a webhook?
- Is this a public API?
- Is this mostly a mutation or an HTTP endpoint?
Those questions are more useful than arguing about which feature is “the Next.js way”.
## My default architecture
In most App Router projects, my default setup looks like this:
```txt
app/
dashboard/
page.tsx
actions.ts
api/
webhooks/
stripe/
route.ts
features/
projects/
service.ts
schema.ts
permissions.ts
```
The responsibilities are clear:
- Server Components render server data
- Server Actions handle private app mutations
- Route Handlers handle HTTP endpoints
- services contain reusable business logic
- schemas validate input
- permission helpers keep authorization explicit
This structure keeps the app simple without locking everything into one pattern.
## So, should you use Server Actions or API Routes?
Use **Server Actions** when:
- the action belongs to your web app UI
- the main task is a mutation
- the caller is your own React app
- you are handling a form submission
- you want less client-side boilerplate
- you want to revalidate app data after a change
- the logic does not need to be a public endpoint
Use **Route Handlers or API Routes** when:
- you need a real HTTP endpoint
- another service needs to call your app
- you are handling a webhook
- you are building a public or shared API
- a mobile app or external client needs access
- you need custom headers, status codes, or response formats
- you are returning files, feeds, streams, or custom JSON
That is the practical difference.
Server Actions are for actions inside the app.
Route Handlers and API Routes are for endpoints.
## Final thoughts
The Server Actions vs API Routes debate becomes easier when you stop treating it like a winner-takes-all decision.
They solve different problems.
Server Actions are great for private mutations inside your Next.js app. They reduce boilerplate, work naturally with forms, and keep many product workflows simple.
Route Handlers and API Routes are better when you need an HTTP interface. They are the right choice for webhooks, public APIs, external clients, custom responses, and integration boundaries.
The rule I actually use is simple:
**If it is an app-specific mutation, start with a Server Action.**
**If it is an HTTP endpoint, use a Route Handler or API Route.**
That rule is not perfect, but it is a good default.
And good defaults are what keep Next.js projects from turning into architecture soup.
## FAQ
### Are Server Actions better than API Routes in Next.js?
Not always. Server Actions are better for private app mutations, especially forms and simple data changes inside a Next.js App Router project. API Routes or Route Handlers are better when you need an HTTP endpoint, webhook, public API, custom response, or external client support.
### Should I use Server Actions or Route Handlers in Next.js App Router?
Use Server Actions when the code is triggered by your app UI and mainly changes data. Use Route Handlers when you need a URL that can receive HTTP requests from browsers, third-party services, mobile apps, webhooks, or other external clients.
### Are API Routes still used in Next.js App Router?
In App Router, the equivalent of API Routes is usually Route Handlers inside the `app` directory. API Routes still exist in the Pages Router, but App Router projects usually use Route Handlers for endpoint-style server code.
### When should I use Server Actions in Next.js?
Use Server Actions for form submissions, private mutations, dashboard actions, user settings, create/update/delete workflows, and cases where the mutation belongs directly to your Next.js app interface.
### When should I use API Routes or Route Handlers?
Use API Routes or Route Handlers for webhooks, public APIs, third-party callbacks, custom JSON endpoints, file downloads, RSS feeds, mobile app endpoints, and any server code that needs a stable HTTP contract.
### Can Server Actions replace API Routes?
Server Actions can replace some internal API routes that were only created for app-specific mutations. They should not replace real API endpoints, webhooks, public APIs, or routes that need to be called by external clients.
### Do Server Actions run on the server?
Yes. Server Actions run on the server. That is why they are useful for database writes, secure mutations, validation, authorization checks, and revalidation. But you still need to validate input and check permissions on the server.
### Should I put business logic inside Server Actions?
Small logic can live in a Server Action, but for real projects it is usually better to keep business logic in separate service files. The Server Action should parse input, check auth, call the service, and revalidate data when needed.
### Are Server Actions good for SEO?
Server Actions themselves do not directly improve SEO. They can help product architecture by simplifying server-side mutations. SEO still depends on rendering, metadata, content quality, performance, internal linking, structured data, and crawlable pages.
### What is the best default for a new Next.js App Router project?
A good default is to use Server Components for server-rendered data, Server Actions for private app mutations, and Route Handlers for HTTP endpoints. This keeps the architecture simple and avoids unnecessary API boilerplate.
---
**Related guides:**
- [Next.js App Router Architecture in 2026](/blog/nextjs-app-router-architecture-in-2026)
- [Server vs Client Components in Next.js](/blog/nextjs-server-vs-client-components-article)
- [Next.js API Routes in 2026](/blog/nextjs-api-routes-in-2026-route-handlers-server-actions-when-to-use-each)
- [What Actually Makes a Frontend Feel Fast](/blog/what-actually-makes-a-frontend-feel-fast)
---
## How I think about building products as a developer, not just features
URL: https://www.pean.dev/blog/product-minded-developer-building-products-not-features
Published: 2026-05-04
Description: A practical breakdown of how I approach product-minded development: thinking about real users, systems, performance, failure states, and long-term quality instead of only shipping isolated features.
Most developers are trained to ship features.
A ticket says “add search,” so search gets added. A ticket says “add sync,” so
sync gets added. A ticket says “add a form,” so the form appears on the screen.
There is nothing wrong with that. Shipping features is part of the job.
But after working on real products, I started caring much more about a different
question:
> Does this actually work for the person using it?
Not just technically. Not just in the happy path. Not just in the demo.
Does it feel clear? Is it fast enough? What happens when the connection is bad?
What happens when the data grows? What happens when the user comes back next
week and expects the product to remember what they did?
That is the difference between building a feature and building a product.
A feature can be complete.
A product has to hold up in real life.

## The trap of feature-first development
Feature-first development feels productive because it creates visible progress.
A new page exists. A button works. A filter appears. A modal opens. A dashboard
gets another chart.
Everyone can point at something and say: done.
But a feature can be “done” and still make the product worse.
Search can exist but return noisy results. Sync can exist but feel unreliable. A
settings page can exist but make the product harder to understand. A dashboard
can have five charts and still not help anyone make a decision.
I have seen this pattern enough times to become careful with it.
The problem is not that features are bad. The problem is that features are often
planned as isolated pieces of UI instead of parts of a larger behavior.
A ticket might say:
> Add saved places.
But the real product question is closer to:
> How does someone save something important at the exact moment they need it,
> and how do we make them trust that it will still be there later?
That is a much better question.
It forces you to think about context, not only implementation.
## The product is the behavior around the feature
The more I build, the less I care about feature names.
“Save place” is a feature.
The product is everything around it:
- how quickly the user can do it
- what happens if the phone has no signal
- whether the user needs to type anything
- how the app confirms the place was saved
- how the place syncs later
- how easy it is to find again
- whether privacy is the default
- whether the flow still works from a small screen or Apple Watch
That is how I thought about [Pean](https://www.pean.me/), my private
place-saving app for iPhone and Apple Watch.
The basic feature sounds simple: save a point on a map.
But that description misses the actual product problem.
Pean is for moments when someone finds a place they may want to return to later:
a fishing spot, a mushroom area, a berry patch, a landmark, a quiet place in the
woods. Those moments are often outside. The user might be moving. The connection
might be weak. They might not want to take out the phone, open a form, choose a
group, type a note, and organize everything perfectly.
So the product cannot start with a complex form.
It has to start with capture.
Save first. Organize later.
That one decision changes the product much more than it changes the feature
list.
## I try to understand the usage moment first
Before I think too much about components, APIs, or database structure, I try to
understand the moment where the product is used.
That sounds obvious, but it is easy to skip.
A desktop dashboard, a mobile app, a browser extension, and a marketing page all
have different usage moments. They may use the same technologies, but they do
not need the same product decisions.
For every product surface, I try to ask:
- where is the user?
- are they focused or distracted?
- are they in a hurry?
- are they on desktop, mobile, or watch?
- do they trust the product already?
- is the action reversible?
- what happens if the connection is slow?
- what would become annoying after the tenth use?
These questions usually reveal more than a long feature discussion.
They also stop me from building the wrong interface.
For example, [Crowra](https://www.crowra.pean.dev/) could have been a normal SEO
dashboard. Login, add project, paste URL, run scan, open report.
That would be a valid product.
But it was not the workflow I wanted to solve.
The moment I cared about was this: the user is already looking at a page and
wants to inspect it without leaving the page.
That is why Crowra became a Chrome side-panel tool.
The side panel is not just a UI choice. It matches the moment. The page stays
open. The audit stays beside it. The user can scroll, inspect, fix, reload, and
scan again without switching context.
That is the kind of product decision I care about.
Not “what can we add?”
More often:
> Where should this live so the workflow feels natural?
## Real products fail in boring ways
A feature demo usually happens in perfect conditions.
The API responds. The network works. The data is clean. The user follows the
expected path. The browser is modern. The device is fast. Nothing weird happens.
Real products are not used like that.
People lose connection. They refresh at the wrong moment. They double-click.
They paste strange data. They leave and come back later. They use older devices.
They misunderstand labels. They expect the product to recover anyway.
This is why I try to think about failure states early.
Not because every product needs complicated architecture from day one.
Because failure is part of the user experience.
If a save action silently fails, the user does not think:
> The backend request returned an error.
They think:
> I cannot trust this product.
That is a much bigger problem.
So when I build a flow, I want to know:
- what happens while the action is loading?
- what happens if it fails?
- can the user retry safely?
- can the same action happen twice?
- is the local state still correct?
- does the UI explain what happened?
- do we lose data?
- do we need optimistic updates?
- do we need sync or queueing?
A lot of product quality lives in these boring details.
They rarely look impressive in screenshots.
But users feel them.
## Performance is part of the product
I do not think about performance as a final optimization step.
Performance is product work.
A slow interface feels uncertain. A delayed click feels broken. A page that
jumps while loading feels unfinished. A mobile screen that needs too much
JavaScript can make even a good product feel heavy.
This does not mean every project needs extreme optimization from the first
commit.
It means performance should influence the shape of the product before it becomes
hard to change.
I care about things like:
- what should render first
- what can wait
- what should be server-rendered
- what really needs client-side JavaScript
- whether an animation improves the experience or only adds weight
- whether images are sized correctly
- whether loading states reduce confusion
- whether the product still feels good on mobile
I wrote more about this in
[What actually makes a frontend feel fast in 2026](/blog/what-actually-makes-a-frontend-feel-fast).
For me, the important point is simple:
> Users do not experience the tech stack. They experience waiting.
That is why performance belongs in the same conversation as UX.
## I care about the system behind the screen
Screens are easy to see.
Systems are easier to ignore.
But many product problems come from the space between screens: unclear data
ownership, fragile state, inconsistent validation, weak error handling, messy
sync, or backend models that do not match the product.
A screen can look clean while the system behind it is already becoming painful.
That is why I like thinking full-stack.
Not because every developer has to do everything.
But because product behavior usually crosses boundaries.
A “save” button is not only a button. It involves UI feedback, local state,
validation, persistence, failure handling, retry behavior, accessibility, and
sometimes analytics or sync.
A “publish” action is not only an endpoint. It changes user expectations,
metadata, crawlability, previews, cache, and rollback strategy.
A “dashboard” is not only charts. It has to answer a question someone actually
has.
Good product engineering means understanding which of these layers matter for
the current problem and not pretending the rest does not exist.
It also means not making everything complicated.
Some features should stay simple.
But the simplicity should be intentional, not accidental.
## Clarity beats cleverness
I like simple products.
But simple does not mean empty.
Simple means the important thing is easy to understand.
This applies to the interface, but also to the code.
Clear naming matters. Clear data models matter. Clear component boundaries
matter. Clear empty states matter. Clear error messages matter. Clear metadata
matters.
I have learned to be suspicious of cleverness when it appears too early.
Clever abstractions. Clever animations. Clever navigation. Clever dashboards.
Clever settings. Clever architecture that takes longer to understand than the
problem itself.
Sometimes cleverness is useful.
But most of the time, products get better when they become easier to reason
about.
That is why I prefer:
- obvious flows
- boring but stable patterns
- clear user actions
- predictable state
- readable code
- fewer hidden assumptions
These choices do not always look exciting.
They make the product easier to improve later.
## UX does not end in Figma
A design can look great in Figma and still become weak in production.
The opposite is also true. A modest design can feel excellent if the product is
fast, clear, accessible, and reliable.
A lot of UX is engineering.
It is focus behavior. Loading states. Error recovery. Mobile layout. Keyboard
navigation. Form validation. Image loading. Metadata. Empty states. Response
time. Scroll behavior. State preservation.
These details decide whether the product feels polished or fragile.
That is why I like being close to product decisions.
A developer can see problems that are not always visible in static designs:
- this flow needs a better state model
- this page will be too heavy on mobile
- this form will create messy data
- this empty state needs a real action
- this feature needs a retry path
- this content needs better structure for search
- this interaction will be annoying after repeated use
That does not mean developers should block product ideas.
It means engineering should make the product sharper, not only “implemented.”
## Production-ready does not mean perfect
I use the phrase “production-ready” a lot, but I do not mean perfect.
Perfect products do not ship.
For me, production-ready means the product is honest enough for real usage.
It has the core behavior. It handles the most important states. It does not rely
only on the happy path. It is understandable enough to maintain. It is fast
enough to feel trustworthy. It does not hide obvious broken parts behind nice
UI.
Depending on the project, production-ready might include:
- clear user value
- predictable behavior
- useful loading states
- understandable errors
- basic accessibility
- stable data flow
- reasonable performance
- safe failure handling
- correct metadata
- maintainable implementation
- enough analytics or logging to learn from usage
That list changes by product.
A Chrome extension, a mobile app, a backend API, and a marketing site do not
need the same checklist.
But they all need the same mindset:
> Do not ship something that only works when everything goes right.
The ideal path is a demo.
The real path is the product.
## An example from Pean
A simple way to explain the difference is to take one product idea:
> Save important places.
Feature-first thinking might lead to:
- a map
- a save button
- a form
- a database record
- a list of saved places
That is a reasonable start.
But it is not enough to make the product feel right.
For Pean, the better questions were:
- what if the user is outside with poor signal?
- what if they only have a few seconds?
- what if they want the place private by default?
- what if they are using Apple Watch?
- what if they want to add details later?
- what if syncing happens after they reconnect?
- what gives them confidence that the place was saved?
Those questions pushed the product toward one-tap capture, offline support,
automatic sync, privacy by default, and simple organization after the place is
already saved.
The feature is still “save a place.”
But the product becomes something more useful:
> Save the spot before the moment is gone.
That is the difference I care about.

## What this changes for teams
When developers think this way, teams get more than implementation.
They get better questions earlier.
That can prevent a lot of rework.
A product-minded developer can notice when a feature solves the wrong problem,
when a flow is too heavy, when the data model does not match the interface, when
a page will be hard for search engines to understand, or when performance will
become a UX issue later.
This is not about turning developers into product managers.
It is about making engineering part of product quality.
The best work usually happens when product, design, and engineering are not
separate handoff stations.
They should still have clear responsibilities.
But the thinking has to overlap.
A developer does not need to own every product decision.
A good developer should understand the product well enough to protect it.
## What I try not to build
I try to avoid adding things only because they are easy to add.
More pages do not always make a site better. More settings do not always make a
product more flexible. More filters do not always improve discovery. More
animations do not always improve feel. More dashboards do not always create more
clarity.
Sometimes a product needs more.
Often it needs less.
Less friction. Less confusion. Less hidden state. Less repeated work. Less
unclear choice. Less UI that exists only because the data exists.
I also try to avoid features where success is undefined.
If nobody can explain what should get better after the feature ships, the
feature probably needs more thought.
It does not always need a complex metric.
But it needs a reason.
A good feature should reduce friction, increase trust, unlock a workflow,
improve speed, clarify a decision, support growth, or make the product easier to
use.
If it only makes the product bigger, I am not convinced.
## The kind of work I enjoy most
I enjoy work where product thinking and engineering quality meet.
That usually means:
- web apps with real workflows
- mobile experiences that need to feel fast and clear
- browser extensions with tight interaction design
- backend systems that support user-facing reliability
- SEO and content surfaces where structure matters
- product redesigns where positioning, UX, and implementation need to align
I like the middle space between idea and production.
Not only the first mockup.
Not only the final code.
The part where the product becomes real: constraints, flows, trade-offs, edge
cases, performance, content, metadata, systems, and the small decisions that
decide whether people can actually use it.
That is where I think good developers can create a lot of value.
## My product-building loop
If I reduce my approach to a simple loop, it looks like this:
1. Understand the real usage moment.
2. Define the behavior, not only the feature.
3. Map the important states: empty, loading, success, failure, retry.
4. Build the smallest useful version.
5. Keep the system behind the screen understandable.
6. Make the interface fast and clear.
7. Treat performance and accessibility as product quality.
8. Ship with enough structure to learn from usage.
9. Remove complexity that does not support the product.
10. Improve based on what the product teaches you.
The hard part is not understanding this list.
The hard part is staying disciplined when it is faster to just build the ticket.
## What building products has taught me
Building products has made me less impressed by long feature lists.
A product can have many features and still feel weak.
A smaller product can feel strong if it understands its moment well.
That lesson keeps repeating.
A private place-saving app does not need to become a general map. It needs to
save meaningful places quickly, privately, and reliably.
An SEO Chrome extension does not need to become a full platform first. It needs
to help review the page in front of the user.
A developer website does not need to list every technology. It needs to explain
what kind of value the developer brings and show proof clearly.
The product gets better when the purpose gets sharper.
That is how I want to build.
Not just more features.
Better products.
## FAQ
### What is a product-minded developer?
A product-minded developer is a developer who thinks beyond implementation and
cares about how the product works in real usage. That includes UX, performance,
failure states, data flow, accessibility, maintainability, and whether the
feature actually helps users.
### What is the difference between building features and building products?
Building features means implementing specific pieces of functionality. Building
products means designing complete behavior around real user needs, constraints,
edge cases, and long-term product quality.
### Why should developers think about product?
Developers make many decisions that directly affect product quality:
performance, state management, data modeling, error handling, accessibility,
technical trade-offs, and maintainability. Product thinking helps those
decisions support user value instead of only completing tasks.
### Does product-minded development slow teams down?
It can make the first conversation a little deeper, but it usually reduces
rework. Clear behavior, better failure handling, and stronger system boundaries
make products easier to ship, maintain, and improve.
### Is performance part of product quality?
Yes. Performance affects trust, clarity, and perceived quality. A slow interface
can make a useful feature feel broken, especially on mobile or in repeated
workflows.
### How do I become a more product-minded developer?
Start by asking better questions before building: who uses this, when do they
use it, what can fail, what should happen next, what does success look like, and
how will this decision affect future versions of the product?
---
Related reading:
- [What actually makes a frontend feel fast in 2026](/blog/what-actually-makes-a-frontend-feel-fast)
- [Server vs Client Components in Next.js: the rules I actually use](/blog/nextjs-server-vs-client-components-article)
- [How I designed one-tap offline place saving for Apple Watch and iPhone](/blog/how-i-designed-one-tap-offline-place-saving-for-apple-watch-and-iphone)
- [Why I built Crowra as a side-panel SEO and AI readiness inspector](/blog/why-i-built-crowra-side-panel-seo-ai-readiness-inspector)
- [Pean — private place saving for iPhone and Apple Watch](https://www.pean.me/)
---
## Why I built Crowra as a side-panel SEO and AI readiness inspector
URL: https://www.pean.dev/blog/why-i-built-crowra-side-panel-seo-ai-readiness-inspector
Published: 2026-04-25
Description: A practical product and engineering breakdown of Crowra: a Chrome side-panel extension for auditing SEO, technical health, schema, links, accessibility, and AI / GEO readiness without leaving the page you are reviewing.
Most SEO tools assume you are ready to leave the page.
Open a dashboard. Paste a URL. Wait for a scan. Open another tab for schema.
Open another tab for links. Open another tab for robots. Check the page again.
Copy findings into notes. Then try to remember which issue was actually worth
fixing first.
That workflow works when you are doing a deep audit.
It feels too heavy when you are reviewing the page in front of you.
That is the gap I wanted to solve with [Crowra](https://www.crowra.pean.dev/).
Not a full SEO platform. Not a rank tracker. Not a crawler SaaS. A focused
Chrome side-panel inspector for the moment when a page is almost ready and still
needs one serious pass across SEO, technical health, schema, links,
accessibility, and AI / GEO readiness.
The product idea is simple:
> Audit the active page without leaving the active page.
That one constraint shaped almost every technical and product decision.

## The problem I kept running into
When I build and ship pages, the review step is rarely one clean task.
It is a stack of small checks:
- does the page title exist?
- is the meta description useful?
- is the canonical correct?
- can crawlers access the page?
- is the schema valid enough?
- are links broken?
- does the page have enough content context?
- are there obvious accessibility misses?
- is the page understandable for AI search surfaces?
- can I export the result and share it with someone else?
None of these checks are hard alone.
The annoying part is the switching.
A page review quickly turns into a scattered tab stack: browser DevTools, schema
validators, link checkers, SERP preview tools, robots testers, manual notes, and
a spreadsheet if the review needs to travel.
That is fine for a large audit.
It is too much friction for everyday publishing.
I wanted Crowra to feel closer to a checklist that lives beside the page instead
of a separate destination.
## Why a Chrome side panel made sense
The side panel became the product shape because it matches the review moment.
A popup is too small. A full dashboard pulls you away from the page. A content
overlay risks getting in the way of the page itself. A side panel can stay open
while the user scrolls, checks sections, fixes something, reloads, and runs the
audit again.
That interaction model is important.
Crowra is not meant to be opened once a month for a giant report. It is meant to
be used during work:
1. open the page
2. open Crowra
3. run a scan
4. review prioritized signals
5. fix the page
6. re-scan
7. export the result if needed
The tool should stay close to the surface being inspected.
That is the same kind of product thinking I used when I wrote about
[one-tap offline place saving for Apple Watch and iPhone](/blog/how-i-designed-one-tap-offline-place-saving-for-apple-watch-and-iphone).
The surface matters because the moment matters.
For Pean, the moment was saving a private place before it disappears from memory.
For Crowra, the moment is reviewing a page before it goes live, after a content
update, during a migration, or before someone else asks why search engines and AI
answer surfaces are not understanding it properly.
Different product. Same rule:
> Design around the moment, not around the feature list.
## What Crowra checks
Crowra started as a page audit tool, but I did not want it to be only another
metadata checker.
The page review needed to cover the signals I actually look at before shipping a
page.
### SEO basics
The first layer is traditional on-page SEO:
- title
- meta description
- headings
- H1 structure
- internal anchor text
- robots meta
- Open Graph
- Twitter Cards
- SERP preview signals
These checks are not glamorous, but they catch real mistakes.
A page can have beautiful design and still ship with a weak title, no
description, a duplicated H1, or missing social preview data.
Crowra keeps these checks visible because basic metadata still matters.
### Technical health
The second layer is technical:
- HTTPS
- viewport
- charset
- canonical tags
- canonical mismatch
- absolute canonical URLs
- hreflang inventory
- mixed content signals
- resource breakdowns
- canonical chain following
This is the kind of work that often gets split between DevTools, source view,
and random browser extensions.
I wanted the technical section to answer one question quickly:
> Is this page technically safe enough to be indexed, shared, and crawled?
Not perfectly optimized. Not scored for every performance metric in the world.
Just safe enough to move forward or clear enough to know what needs fixing.
### Schema and entities
Structured data became its own section because schema issues are usually easier
to understand when they are shown as entities, not just raw JSON.
Crowra checks:
- JSON-LD
- Microdata
- RDFa
- parse errors
- entity types
- missing recommended fields
- common types like Article, BlogPosting, Product, Organization, WebSite, and
WebPage
A lot of schema tools tell you whether something is valid. That is useful. But
during a page review, I also want to understand what the page is claiming to be.
Is it an Article? A WebPage? A Product? An Organization? Is the schema helping
search engines understand the page, or is it just present?
That is the difference between validation and inspection.
### Links and same-site crawl
A single page audit is useful, but links often need a wider view.
That is why Crowra has two link modes:
1. page-level link extraction
2. same-site crawl from the Links tab
The page-level view is for quick inspection: internal links, external links,
nofollow links, and anchor patterns.
The crawl view is for launch and migration checks: broken links, redirects,
noindex pages, inbound links, duplicate titles, duplicate descriptions, and
exportable crawl results.
I did not want Crowra to become a giant crawler SaaS.
But I did want it to answer a practical launch question:
> If I ship this site today, are there obvious broken pages or crawl issues I
> should catch first?
That is where a lightweight same-site crawl makes sense.
### Accessibility signals
Crowra is not an accessibility compliance certifier.
That distinction matters.
But there are still useful checks that belong in a page review:
- image alt text
- empty alt text
- form label hints
- landmark checks
- heading order
- focus heuristics
- contrast-related signals
The goal is not to pretend one automated pass can replace a real accessibility
review. The goal is to catch obvious misses before they become production bugs.
### AI / GEO readiness
The newest part of the product is AI / GEO readiness.
GEO, in Crowra, means Generative Engine Optimization. I treat it as a review
surface for the signals that make a page easier to understand for AI answer
engines and discovery systems.
Crowra checks things like:
- `llms.txt`
- AI-bot access through `robots.txt`
- E-E-A-T signals
- author and date hints
- About and Contact links
- content density
- reading and completeness hints
- schema-derived entity hints
I am careful with the language here.
No tool can guarantee that an AI answer engine will cite a page. That would be a
false promise. But a tool can help review whether the page is crawlable,
structured, readable, entity-rich, and supported by basic trust signals.
That is what I want Crowra to do.
Not predict citations.
Surface readiness signals.
## The architecture behind the scan
The extension architecture is intentionally simple.
Crowra is built with React, TypeScript, Vite, Tailwind, Zustand, Lucide, Chrome
Manifest V3, the Chrome side panel API, and Chrome storage.
The scan flow has a few clear pieces:
1. the side panel asks for a scan
2. the active tab is identified
3. a packaged content script extracts a page snapshot
4. the background service worker handles browser-level work
5. the audit engine runs rules against the snapshot
6. the side panel renders the result
7. the result can be stored in local history or exported
The important object is the page snapshot.
In simplified form, I think about it like this:
```ts
type PageSnapshot = {
url: string;
origin: string;
title: string | null;
description: string | null;
headings: HeadingSnapshot[];
images: ImageSnapshot[];
links: LinkSnapshot[];
structuredData: StructuredDataBlock[];
hreflang: HreflangEntry[];
social: SocialTags;
robots: RobotsSignals;
content: ContentSummary;
accessibility: AccessibilityHints;
resources: ResourceSignals;
};
```
The exact production type has more detail, but the idea is stable:
> The content script observes the page. The audit engine interprets it.
I like that separation because it keeps the extension easier to reason about.
The extractor should not decide too much. It should collect evidence. The rules
should decide what that evidence means. The UI should show the result in a way a
human can act on.
That separation also keeps the product extensible. Adding a new rule should not
require rewriting the whole scan flow.
## Rules should be useful, not dramatic
One design decision I care about is avoiding overdramatic audit results.
It is easy to make a tool that scares users.
Everything can become a red warning. Every missing field can become a critical
failure. Every score can look like a final judgment.
That is not useful.
Crowra uses severity levels because not every issue means the same thing:
```ts
type AuditSeverity = 'critical' | 'high' | 'medium' | 'low' | 'info';
```
A broken canonical is not the same as a missing Twitter Card. A page blocked by
robots is not the same as a title that could be shorter. A missing schema field
is not the same as invalid JSON-LD.
So the scoring model has to be opinionated but not theatrical.
The score is a prioritization tool, not a truth machine.
That is why the UI shows both the score and the evidence behind it: the page
signals, issue groups, affected elements, and recommendations.
A good audit tool should not only say:
> Something is wrong.
It should help answer:
> What should I fix first, and why?
## Why local-first mattered
I did not want Crowra to require an account.
That was not only a privacy decision. It was also a product decision.
For this kind of workflow, an account wall feels wrong. A user is already in the
browser, looking at a page, trying to review something quickly. Asking them to
create an account before the first scan adds friction at the exact wrong moment.
So Crowra is local-first:
- no account required
- no backend required for the audit workflow
- no analytics SDK
- no remote code execution
- settings stored locally
- scan history stored locally
- custom extractor rules stored locally
Audit-related requests are made to the site the user chooses to inspect.
That model also fits Chrome Web Store review better because the product purpose
is narrow and explainable: the extension needs page access because the user is
asking it to inspect a page.
Broad access should always come with a narrow product purpose.
For Crowra, the purpose is not tracking browsing. It is user-started page review.
## The permission model had to match the product
A Chrome extension can easily become suspicious if the permissions feel broader
than the product.
Crowra needs access to the active page because the active page is the object
being audited. It needs scripting because the packaged content script extracts
page signals. It needs storage because settings, onboarding, custom extractors,
and local history live in Chrome storage. It needs the side panel because that is
the main UI. It needs tab information so the audit matches the page being
reviewed. It needs host permissions because users can audit any website they
choose.
That sounds like a lot until the product purpose is clear.
The key is that every permission maps back to one workflow:
> The user opens a page and asks Crowra to audit it.
If a permission does not support that workflow, it does not belong.
That is the same rule I use when building frontend architecture: keep the
boundary as small as the real capability requires. I wrote about that from a
Next.js perspective in
[Server vs Client Components in Next.js: the rules I actually use](/blog/nextjs-server-vs-client-components-article).
For Crowra, the browser permission boundary needed the same kind of discipline.
## Why I did not make it a normal SaaS first
It would have been easy to turn Crowra into a hosted crawler first.
Create an account. Add a project. Enter a domain. Run a crawl. Store reports in
a dashboard. Add team seats later.
That is a valid product.
It is not the product I wanted to start with.
The first version of Crowra is closer to a browser-native review surface. It
should feel like a tool you use while building, editing, and reviewing. The page
is already open. The context is already there. The extension should add a second
brain beside it.
Starting as a Chrome extension also keeps the product honest.
If the side panel is not useful, a bigger backend will not save it.
A narrow tool has to earn attention quickly.
That constraint is healthy.
## Reports are for handoff, not vanity
The Reports tab exists because audits often need to leave the browser.
A developer might need to send findings to a marketer. A marketer might need to
share a page review with a content team. A founder might want to keep a small
record of what changed before launch.
Crowra can export page audits as Markdown or JSON. Crawl results can be exported
as CSV or JSON.
Markdown matters because it fits human workflows:
- Notion
- Slack
- GitHub issues
- Linear tickets
- pull request comments
- internal docs
JSON matters because it fits machine workflows:
- debugging
- custom processing
- archived audit data
- future automation
The report is not meant to be a decorative PDF.
It is meant to travel.
That is the product rule:
> A page review is only useful if the finding reaches the person who can fix it.
## What I want Crowra to avoid
The easiest way to ruin a focused tool is to keep adding adjacent features until
the original workflow disappears.
For Crowra, I want to avoid:
- becoming a full rank tracker
- pretending to replace a human SEO review
- claiming AI citation guarantees
- running automatic background crawls without user intent
- requiring an account before the first useful scan
- hiding simple findings behind a dashboard
- making the score more important than the evidence
- turning every warning into a crisis
The product should stay close to the page.
It should help people ship cleaner pages.
That is enough.
## Where the product can grow
Crowra has a lot of natural next steps, but I want the direction to stay tied to
the same core workflow.
The areas that make sense are:
- stronger launch checklists
- better AI / GEO explanations
- more useful schema relationship views
- clearer crawl summaries
- better comparison between scans
- richer Markdown reports
- custom rule packs for teams
- more focused recommendations for content and technical fixes
The interesting part is not adding more tabs.
The interesting part is making the page review sharper.
A good product should reduce uncertainty, not increase the number of things to
look at.
## How I think about SEO for Crowra itself
Crowra is also a product that needs to be discovered.
That makes the content strategy unusually meta.
The obvious keywords are very competitive:
- SEO tool
- SEO audit tool
- website audit tool
I do not think Crowra should start there.
The better entry points are more specific:
- `SEO Chrome extension`
- `Chrome side panel SEO audit`
- `AI readiness SEO`
- `GEO SEO checklist`
- `schema markup audit`
- `technical SEO Chrome extension`
- `check broken links before launch`
- `website launch SEO checklist`
These keywords match real workflows.
Someone searching for a generic SEO tool might want anything. Someone searching
for a schema markup audit, a broken link launch check, or AI readiness SEO has a
more specific problem.
That is where Crowra can be useful faster.
The content plan should follow the product shape:
1. a website launch SEO checklist
2. an AI / GEO readiness checklist
3. a schema markup audit guide
4. a broken link launch checklist
5. an on-page SEO audit checklist
6. a comparison of SEO Chrome extensions
Each article should solve the manual workflow first and introduce Crowra only
where it genuinely removes friction.
That is how product-led content stays useful instead of becoming a disguised ad.
## The product in one flow
If I reduce Crowra to one flow, it looks like this:
1. The user opens a page.
2. The user opens Crowra in the side panel.
3. Crowra extracts a page snapshot from the active tab.
4. The audit engine runs SEO, technical, schema, links, accessibility, and AI /
GEO rules.
5. The UI groups issues by priority and category.
6. The user fixes the page or starts a same-site crawl.
7. The result can be exported as Markdown, JSON, CSV, or saved in local history.
That is the whole product.

The value is not that each check is impossible to do elsewhere.
The value is that the checks live together, beside the page, at the moment when
they are needed.
## What building Crowra taught me
Building Crowra reinforced a lesson I keep learning across products:
> Narrow workflows produce clearer products.
A broad SEO platform can do a thousand things and still feel hard to use. A
small side-panel inspector can be useful if it understands the exact moment it
exists for.
That does not mean the product should stay small forever.
It means the first version needs a center of gravity.
For Crowra, that center is page review.
Not analytics. Not rankings. Not dashboards. Not team reporting.
A practical audit of the page in front of you.
## FAQ
### What is Crowra?
Crowra is a Chrome side-panel extension for auditing the active page for SEO,
technical health, structured data, links, accessibility, and AI / GEO readiness.
### Is Crowra an SEO Chrome extension?
Yes. Crowra is an SEO Chrome extension, but it also covers technical checks,
schema markup, broken links, accessibility signals, reports, history, and AI /
GEO readiness signals.
### What does AI / GEO readiness mean in Crowra?
In Crowra, AI / GEO readiness means reviewing signals such as AI-bot access,
`llms.txt`, content density, E-E-A-T hints, schema-backed entities, and page
clarity. It does not guarantee inclusion in AI-generated answers.
### Does Crowra crawl a whole site automatically?
No. The main audit runs on the active page. Same-site crawling starts only when
the user triggers it from the Links tab.
### Does Crowra require an account?
No. Crowra is designed as a local-first Chrome extension. Settings, onboarding,
custom extractor rules, and scan history are stored locally in Chrome.
### Who is Crowra for?
Crowra is for developers, SEOs, marketers, publishers, technical writers, and
product teams who need a fast review before publishing, migrating, updating, or
cleaning up a page for search and AI discovery.
---
Related reading:
- [Server vs Client Components in Next.js: the rules I actually use](/blog/nextjs-server-vs-client-components-article)
- [What actually makes a frontend feel fast in 2026](/blog/what-actually-makes-a-frontend-feel-fast)
- [How I designed one-tap offline place saving for Apple Watch and iPhone](/blog/how-i-designed-one-tap-offline-place-saving-for-apple-watch-and-iphone)
- [Crowra — SEO and AI readiness inspector for Chrome](https://www.crowra.pean.dev/)
---
## How I designed a sync architecture for a real Expo app with NestJS and PostgreSQL
URL: https://www.pean.dev/blog/expo-nestjs-postgresql-sync-architecture
Published: 2026-04-24
Description: A practical breakdown of the sync architecture I use for a real Expo app: capturing GPS places on mobile, sending reliable writes through NestJS, storing them in PostgreSQL, and rendering them later in a Next.js web platform.
Most mobile app architecture diagrams are too clean.
They show the phone, the API, the database, and maybe a web dashboard. A few
arrows connect everything together. The system looks calm.
Real mobile products are not calm.
A user opens the app with weak signal. The GPS point arrives before the network
does. The app is closed halfway through a request. The same action is retried.
The web platform needs to show the latest data without turning every screen into
a loading state. And somewhere between all of that, the backend still has to
decide what is valid, what belongs to the user, and what should happen if the
same write arrives twice.
That is the part I care about most.
I have already written about the product side of [one-tap offline place saving
for Apple Watch and iPhone](/blog/how-i-designed-one-tap-offline-place-saving-for-apple-watch-and-iphone).
This article is the engineering side: how I think about a sync architecture for
a real Expo app with a NestJS API, PostgreSQL as the source of truth, Swift where
native iOS behavior matters, and a Next.js web platform on top.

## The product requirement that shaped the architecture
The product sounds simple:
> Save a private place now, organize it later.
But that one sentence creates a lot of technical pressure.
A place-saving app cannot behave like a normal web form. When someone saves a
GPS point, the important part is not a beautiful confirmation screen. The
important part is trust.
The user needs to feel that the place was captured even if:
- the network is unstable
- the app is backgrounded
- the GPS accuracy is still improving
- the Apple Watch sends a minimal payload first
- the user edits the place later from the phone
- the web platform renders the place from a different device
That means the architecture has to separate two jobs:
1. **Capture** — store the user's intent as quickly and safely as possible.
2. **Reconcile** — turn that intent into consistent server-side data.
Once I started thinking in those two layers, the stack made more sense.
Expo handles the mobile interface. Swift helps with native iOS and watch-related
edges where JavaScript is not the right layer. NestJS gives the API a clear
boundary. PostgreSQL becomes the durable source of truth. Next.js gives the user
a larger web surface for browsing, editing, and managing places.
The architecture is not about making every layer clever.
It is about giving every layer a clear job.
## The stack
The core stack looks like this:
- **Expo / React Native** for the iPhone app
- **Swift** for native iOS pieces where the JS layer should not own everything
- **NestJS** for the API and application boundary
- **PostgreSQL** for durable relational data
- **Next.js** for the web platform
- **Object storage** for media, if photos or generated assets are involved
That is a boring stack in the best possible way.
The mobile app should be great at interaction. The API should be great at
validation and ownership. The database should be great at consistency. The web
platform should be great at rendering and management.
A sync architecture gets painful when these responsibilities blur.
If the mobile app tries to become the source of truth, every device conflict
becomes harder. If the backend only accepts perfect final objects, the capture
flow becomes fragile. If the web platform owns too much mutation logic, the
system slowly gets two APIs: the official backend and the accidental one hidden
inside frontend code.
I try to avoid that.
## The shape of a synced place
For a GPS place, the first server-side model does not need to be complicated.
The important thing is to preserve the event that happened:
```ts
type CreatePlaceRequest = {
clientId: string;
name?: string;
note?: string;
latitude: number;
longitude: number;
accuracy?: number;
capturedAt: string;
source: 'iphone' | 'apple_watch' | 'web';
};
```
The small but important field here is `clientId`.
The backend has its own database ID, but the client also needs a stable ID before
the server responds. Without that, retries become awkward. Optimistic UI becomes
awkward. Queueing becomes awkward. And if the request succeeds but the response
never reaches the client, the app has no reliable way to know whether the place
already exists.
So I treat `clientId` as part of the sync contract.
The client generates it once. The server stores it once. If the same write
arrives again, the API can return the existing place instead of creating a
duplicate.
That is the beginning of idempotency.
## Why idempotency matters more than people think
Mobile writes are messy.
A request can fail in at least three different ways:
- it never reached the server
- it reached the server but failed during validation or persistence
- it succeeded, but the client never received the response
From the user's point of view, all three can look the same.
That is why retrying the same action must be safe.
For place creation, the rule is simple:
> The same user and the same client-generated ID should create one place, not
> many.
In PostgreSQL, that usually means a unique constraint:
```sql
create unique index places_owner_client_id_unique
on places (owner_id, client_id);
```
Then the NestJS service can treat duplicate delivery as a normal condition, not
an exceptional one.
```ts
async createPlace(userId: string, input: CreatePlaceDto) {
const existing = await this.placeRepository.findByClientId(
userId,
input.clientId,
);
if (existing) {
return existing;
}
return this.placeRepository.create({
ownerId: userId,
clientId: input.clientId,
latitude: input.latitude,
longitude: input.longitude,
accuracy: input.accuracy,
capturedAt: new Date(input.capturedAt),
source: input.source,
name: input.name ?? null,
note: input.note ?? null,
});
}
```
This is not fancy architecture. It is the kind of boring rule that prevents
annoying product bugs.
The app can retry confidently. The backend can defend itself. The user does not
get five identical places because they saved something in the forest with bad
signal.
## The outbox pattern on mobile
For a real mobile app, I do not want every screen to think directly in HTTP
requests.
The UI should say:
> A place was created.
Then a sync layer should decide:
- can this be sent now?
- should it be queued?
- has this exact write already been sent?
- should it be retried?
- should the user see a pending state?
That is the mental model behind an outbox.
The local app keeps a small list of unsynced writes:
```ts
type PendingSyncAction = {
id: string;
type: 'create_place' | 'update_place' | 'delete_place';
payload: unknown;
createdAt: string;
attemptCount: number;
lastAttemptAt?: string;
};
```
The UI does not need to wait for the network before it can show the new place.
But it should still be honest. A place can be visible locally while still having
a small pending state until the backend confirms it.
That distinction matters.
Optimistic UI is good when it makes the product feel fast. It becomes dangerous
when it lies about durability.
I wrote about this from a frontend perspective in
[what actually makes a frontend feel fast in 2026](/blog/what-actually-makes-a-frontend-feel-fast).
For sync-heavy mobile apps, the same principle applies: speed is not only about
rendering quickly. It is about giving the user clear feedback at the right level
of certainty.
"Saved locally" and "synced to the server" are not the same state.
The UI should not pretend they are.

## NestJS as the sync boundary
I like NestJS for this kind of backend because it encourages a clean separation
between transport, validation, services, and persistence.
For sync endpoints, that separation is useful.
A controller should not decide ownership rules. It should not know how to resolve
duplicates. It should not know every detail of PostgreSQL. Its job is to receive
the request, attach the authenticated user, and pass a validated command into the
application layer.
```ts
@Controller('places')
export class PlacesController {
constructor(private readonly placesService: PlacesService) {}
@Post()
createPlace(
@CurrentUser() user: AuthenticatedUser,
@Body() body: CreatePlaceDto,
) {
return this.placesService.createPlace(user.id, body);
}
}
```
The service owns the product rule:
```ts
@Injectable()
export class PlacesService {
async createPlace(userId: string, input: CreatePlaceDto) {
this.validateCoordinates(input.latitude, input.longitude);
const existing = await this.placesRepository.findByClientId(
userId,
input.clientId,
);
if (existing) {
return existing;
}
return this.placesRepository.createForOwner(userId, input);
}
private validateCoordinates(latitude: number, longitude: number) {
if (latitude < -90 || latitude > 90) {
throw new BadRequestException('Invalid latitude');
}
if (longitude < -180 || longitude > 180) {
throw new BadRequestException('Invalid longitude');
}
}
}
```
The repository owns persistence:
```ts
@Injectable()
export class PlacesRepository {
async findByClientId(ownerId: string, clientId: string) {
// ORM or query builder implementation
}
async createForOwner(ownerId: string, input: CreatePlaceDto) {
// Insert into PostgreSQL
}
}
```
This looks basic, but basic is good here.
Sync code tends to grow. First it is only place creation. Then it is updates.
Then it is media. Then sharing. Then groups. Then subscription limits. Then
conflict handling. If everything starts inside a controller, the API becomes
difficult to reason about very quickly.
A clean NestJS boundary gives the system somewhere to put rules.
## PostgreSQL as the source of truth
For this kind of product, PostgreSQL should not be treated like a passive JSON
dump.
It should protect the core invariants:
- a place belongs to one owner
- a client-generated ID is unique per owner
- coordinates are valid
- deleted records are handled intentionally
- shared access is represented explicitly
- timestamps are stored consistently
- subscription limits can be checked against durable state
A simplified table might look like this:
```sql
create table places (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references users(id),
client_id text not null,
name text,
note text,
latitude double precision not null,
longitude double precision not null,
accuracy double precision,
source text not null,
captured_at timestamptz not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
deleted_at timestamptz,
constraint places_latitude_check
check (latitude >= -90 and latitude <= 90),
constraint places_longitude_check
check (longitude >= -180 and longitude <= 180)
);
create unique index places_owner_client_id_unique
on places (owner_id, client_id);
create index places_owner_updated_at_idx
on places (owner_id, updated_at desc);
create index places_owner_created_at_idx
on places (owner_id, created_at desc);
```
I like this shape because it keeps the data boring.
Coordinates are coordinates. Ownership is ownership. Sync identity is explicit.
Soft deletion is visible. The backend can build more complex product behavior on
top without losing the simple truth underneath.
If geospatial querying becomes important later, the model can evolve. But the
first job is not to impress the database. The first job is to keep user data
safe, queryable, and consistent.
## Why the web platform should not bypass the sync model
The Next.js web platform is not just a marketing site. For a mobile-first
product, the web app often becomes the place where users manage data more
comfortably.
That creates a temptation:
> The web app is already close to the backend, so maybe it can use different
> mutation rules.
I try not to do that.
If the iPhone app, Apple Watch flow, and web platform all create or update
places, they should go through the same domain rules. The web UI can be richer,
but it should not invent a second version of the product logic.
This is where my approach to Next.js overlaps with the rules I use for
[Server vs Client Components in Next.js](/blog/nextjs-server-vs-client-components-article).
The server can assemble the page, fetch the user's places, protect access, and
render a useful first view. Small client components can own the actual
interaction: editing a name, moving a marker, opening a media modal, changing a
category, or triggering a save.
The important part is that the mutation still goes through the same API contract.
The web platform should feel more comfortable than mobile, not less consistent
than mobile.
## Handling updates without creating a conflict machine
Create is the easiest sync action.
Update is harder because two devices can edit the same record. For many apps,
the right first version is not a complex CRDT or real-time collaboration system.
It is a smaller set of rules that match the product.
For a private place-saving app, most updates are not collaborative. A user might
change the name, note, category, photo, or exact marker position. Those edits are
usually personal and sequential.
So I would start with simple server-owned conflict rules:
- the server stores `updatedAt`
- the client sends the last known server version
- the backend rejects or marks suspicious stale updates
- the UI asks the user to refresh or reapply the change when needed
A minimal request might look like this:
```ts
type UpdatePlaceRequest = {
name?: string;
note?: string;
latitude?: number;
longitude?: number;
expectedUpdatedAt: string;
};
```
Then the service can check whether the client edited an old version:
```ts
if (place.updatedAt.toISOString() !== input.expectedUpdatedAt) {
throw new ConflictException('Place was changed on another device');
}
```
This is intentionally simple.
Most products do not need perfect automatic merging on day one. They need a
clear conflict story that does not silently overwrite important data.
Start with correctness. Add smarter merging only where the product actually
needs it.
## Media should sync separately from place creation
Photos change the architecture.
A GPS point is small. A photo is not. A place can be captured immediately, but a
photo upload might take longer, fail more often, or need a separate storage
pipeline.
That is why I prefer treating media as a separate sync concern.
The flow can look like this:
1. Create the place with coordinates and minimal metadata.
2. Ask the API for an upload target.
3. Upload the file to object storage.
4. Confirm the media object with the backend.
5. Attach it to the place.
That keeps the core capture flow reliable.
The user should not lose a saved place just because a photo upload failed. Media
can retry later. The place itself should already exist.
This is the same product idea again:
> Capture first. Enrich later.
I wrote about that product split in the post about
[one-tap offline place saving](/blog/how-i-designed-one-tap-offline-place-saving-for-apple-watch-and-iphone),
but the engineering version is just as important. The system should not make
optional context block the core data.
## Privacy changes the backend shape
Pean is not a public map product. The default assumption is private places.
That changes the backend model.
A place is not just a row with coordinates. It is a private object with access
rules. The API should always answer:
- who owns this place?
- who can read it?
- who can edit it?
- was this place shared directly or through a group?
- should this appear in another user's map?
- should media follow the same visibility rules?
That is why I would rather keep sharing explicit than magical.
A simplified sharing model might have separate tables for direct sharing and
group-based access instead of hiding everything inside a JSON column. The exact
schema can change, but the rule should stay stable:
> Location privacy is a core product rule, not a UI preference.
This is also why a product like this is different from a generic maps app. I
wrote more about that in
[why Google Maps is not enough for saving personal places](/blog/save-personal-places-not-google-maps).
The backend has to respect the same idea. Private by default should be visible
in the data model, the API, and the UI.
## A sync endpoint is not just CRUD
A common mistake is treating sync as normal CRUD with worse network conditions.
It is more than that.
A sync endpoint needs to think about:
- idempotency
- retries
- partial failure
- local IDs
- stale data
- deleted records
- server timestamps
- ownership
- rate limits
- subscription limits
- schema changes over time
For example, a regular CRUD endpoint might say:
```txt
POST /places
PATCH /places/:id
DELETE /places/:id
```
That is fine as a transport shape, but the internal service still needs sync
semantics.
For a mobile app, "create place" is not just "insert row." It is:
- validate the user's plan and limits
- check ownership
- accept a client-generated identity
- deduplicate retries
- preserve the capture timestamp
- return a server-confirmed version
- make the result visible to other clients
That is why I think of sync as an application layer, not a route naming style.
CRUD describes the HTTP surface.
Sync describes the behavior.
## What I would monitor from day one
Sync bugs can be hard to see from the frontend because the UI often recovers
silently.
That is useful for the user, but dangerous for the developer.
I would want to track:
- how many pending actions exist on the client
- how often sync retries happen
- how many writes are deduplicated by `clientId`
- how many requests fail validation
- how many conflicts happen on updates
- how often media upload succeeds after the place is already created
- how long it takes from local capture to server confirmation
These metrics tell you where the system feels fragile.
A high retry count might mean the API is fine but mobile connectivity is bad. A
high deduplication count might mean the retry layer is working. A high conflict
count might mean the web and mobile editing model needs better UX.
Without those signals, sync architecture becomes guesswork.
## The architecture in one flow
If I reduce the system to one happy path, it looks like this:
1. The user saves a place on iPhone or Apple Watch.
2. The mobile app creates a local place with a `clientId`.
3. The place appears immediately with a pending sync state.
4. The sync layer sends the write to the NestJS API.
5. The API validates ownership, coordinates, limits, and idempotency.
6. PostgreSQL stores the durable server version.
7. The mobile app receives the confirmed place.
8. The Next.js web platform renders the same place from the server.
9. Later edits use the same domain rules instead of a separate web-only path.
The flow is simple because each layer stays honest.
The phone captures. The API decides. PostgreSQL remembers. The web platform
renders and manages.
## What I would avoid next time
The biggest sync mistake is trying to make the first version too magical.
I would avoid:
- building a complex conflict resolution system before real conflicts exist
- blocking place creation on media upload
- making the web platform bypass the API rules
- treating local pending state as the same thing as server confirmation
- allowing mobile retries without idempotency
- hiding ownership and privacy rules inside UI-only logic
- building the backend as generic CRUD and hoping sync works later
The architecture should be strong, but not over-engineered.
A good first version has boring guarantees:
- the user does not lose the place
- duplicate requests do not create duplicate places
- private data stays private
- the server remains the source of truth
- the UI explains pending and synced states clearly
- the web platform uses the same rules as mobile
That is already a lot.
## Final thought
A reliable sync architecture is not about making every device perfectly
real-time.
It is about preserving user intent.
When someone saves a place, the system should understand what happened and carry
that action safely from the device to the backend to the web platform. The user
should not have to understand the network, retries, background execution,
database constraints, or API boundaries.
They should tap save and trust the product.
For me, that is the real architecture goal.
Not just moving data from Expo to NestJS to PostgreSQL.
Making the product feel dependable at the exact moment it matters.
---
## Server vs Client Components in Next.js: the rules I actually use
URL: https://www.pean.dev/blog/nextjs-server-vs-client-components-article
Published: 2026-04-19
Description: After building with the App Router for a while, I stopped thinking in terms of 'server good, client bad'. These are the practical rules I use to decide where each component should live.
When I first moved to the Next.js App Router, I treated Server Components like a
new rule I had to obey.
Keep everything on the server. Add `'use client'` only when absolutely
necessary. Minimize JavaScript. Ship less. Hydrate less.
That part is correct.
But after building real screens with filters, forms, search, modals, navigation,
and optimistic updates, I realized the mental model was incomplete. The hard
part is not knowing that Server Components are the default. The hard part is
knowing **where the boundary should be**.
That boundary decides whether a page feels fast or clumsy, whether your data
loading stays simple or turns into glue code, and whether your components remain
composable six months later.
So these are the rules I actually use.

## The rule that changed how I build
I no longer ask:
> Should this page use Server Components or Client Components?
I ask:
> What is the smallest interactive island I can get away with?
That one question is usually enough.
A lot of App Router mistakes come from making the boundary too high in the tree.
You add `'use client'` to a page or a large layout because one button needs a
click handler. Then half the route becomes client-side by accident. Data loading
gets pushed into effects. Large libraries end up in the browser bundle. And the
page that should have streamed nicely becomes a giant hydrated block.
My default is simple:
- **Fetch and assemble on the server**
- **Add interactivity in small client leaves**
- **Keep the boundary low**
That is the biggest win I have gotten from the App Router.
## Rule 1: Start on the server unless the browser is required
I start every new component as a Server Component.
Not because it is trendy, but because it keeps the first version honest. If the
component does not need state, effects, event handlers, refs, or browser APIs,
there is usually no reason to pay the client cost for it.
This is what I keep on the server by default:
- data fetching
- access checks
- token or secret-dependent logic
- page shells
- layout composition
- content rendering
- expensive formatting or mapping logic
- large dependencies the user does not need in the browser
This approach gives you a cleaner tree almost for free. It also prevents the
common mistake where a presentational block becomes client-side only because it
was placed next to an interactive widget.
A product card, article body, sidebar section, dashboard table wrapper, pricing
page, or read-only settings summary usually starts on the server for me. Then I
attach small client parts only where interaction begins.
## Rule 2: Use `'use client'` for capability, not convenience
There are only a few real reasons I move a component to the client:
- it uses `useState`, `useReducer`, `useEffect`, or `useRef`
- it needs event handlers like `onClick` or `onChange`
- it depends on browser APIs such as `window`, `localStorage`, or media queries
- it wraps a third-party interactive library
- it needs immediate in-browser feedback before a round trip completes
That sounds obvious, but the important detail is this: I do **not** use
`'use client'` just because passing props feels annoying.
That is usually a smell.
If I find myself moving a whole section to the client to avoid thinking about
composition, I stop and split it instead.
A good example is a searchable page.
The page itself can stay on the server. The initial data can be fetched on the
server. The heading, metadata, filters shell, results shell, and empty state can
all stay on the server. Only the interactive search input, filter toggles, or a
small client controller may need to run in the browser.
That keeps the route fast on first load and still lets the interaction feel
native.
## Rule 3: Keep the client boundary as low as possible
This is the rule I break the least.
If only one child needs interactivity, only that child gets `'use client'`.
Not the parent. Not the whole section. Definitely not the page unless there is a
very good reason.
Bad:
```tsx
'use client'
export default function ProductPage() {
return (
)
}
```
Better:
```tsx
export default async function ProductPage() {
const product = await getProduct()
return (
)
}
```
```tsx
'use client'
export function AddToCartButton({ productId }: { productId: string }) {
return
}
```
This pattern solves multiple problems at once:
- less JavaScript reaches the browser
- more of the route can render and stream earlier
- data loading stays server-first
- the client component becomes easier to reuse and test
When I review App Router code, the question I ask most often is:
> Does this parent really need to be client-side, or are we just being lazy?
That one catches a lot.
## Rule 4: Prefer server data flow over client fetch waterfalls
One of the easiest ways to make a Next.js app feel worse is to fetch important
page data in `useEffect`.
You render a shell. Then the browser loads JavaScript. Then hydration finishes.
Then the effect runs. Then the fetch starts. Then the page updates.
That is a lot of waiting for data that the server could have loaded earlier.
Whenever the data is needed to render the route, I fetch it on the server first.
Then I pass the result down into client components as props.
That keeps the first paint meaningful and removes a surprising amount of loading
state noise.
I still fetch on the client when the data is truly client-driven:
- live search suggestions while typing
- hover or focus-driven previews
- UI state that changes too frequently to bounce through the server each time
- background refresh after the initial render
But for the main route content, server-first wins most of the time.
## Rule 5: Use client components for interaction, not ownership of the whole screen
A useful distinction:
- **Server Components own the screen structure**
- **Client Components own interaction moments**
That mental split makes large pages easier to reason about.
For example, on a dashboard page I want the server to own:
- the route shell
- the fetched records
- the summary numbers
- the default sort and filters from the URL
- permissions and feature gating
Then I let client components own:
- dropdown open state
- local input state
- inline edit mode
- modal visibility
- drag and drop
- instant optimistic feedback
The mistake is letting the interaction layer become the page owner. Once that
happens, everything tends to get pulled toward browser-only patterns even when it
should not.
## Rule 6: Suspense is part of architecture, not just a loading spinner
The App Router becomes much more interesting once you stop thinking about
Suspense as a visual detail.
A good Suspense boundary is an architectural boundary. It lets the server send a
useful shell early while slower parts continue rendering. That changes how the
page feels even when total backend work stays the same.
So I try to place boundaries around meaningful chunks:
- the analytics panel separate from the main summary
- related items separate from product details
- comments separate from the article body
- slow charts separate from quick textual stats
This is where server/client decisions connect directly to UX. A thin client
island inside a well-placed Suspense boundary is usually much better than a huge
interactive tree that blocks the whole route.
If part of the page can arrive later without harming understanding, I isolate it.
That is usually worth more than micro-optimizing a few lines of component code.
## Rule 7: Use optimistic UI deliberately, not everywhere
Optimistic UI is one of those ideas that sounds universally good until you add it
to every form and toggle.
I only use it when the user benefits from immediate feedback and the rollback
story is manageable.
Good candidates:
- liking or saving something
- adding an item to a lightweight list
- toggling a preference
- reordering small collections
Bad candidates:
- flows with complicated server validation
- destructive operations with many downstream effects
- anything where reconciliation would be confusing to the user
The existence of Server Functions makes this easier than older client-only form
patterns, but the product decision still matters. Fast feedback is good. False
certainty is not.
## Rule 8: Watch out for hidden client creep
The biggest App Router regressions I see are not dramatic architectural failures.
They are small convenience choices that quietly move too much code to the client.
Usually it starts like this:
- a utility imports a browser-only package
- a shared component gains one small click handler
- a top-level wrapper gets `'use client'`
- a provider is mounted too high
- data that could come from the server gets re-fetched in the browser
None of those look huge on their own. Together they turn a crisp server-first
route into a blurry hybrid with all the costs of both models.
When something feels off in an App Router codebase, I inspect the boundaries
before I inspect the algorithm.

## The practical exceptions
There are absolutely pages where I go client-heavy on purpose.
Sometimes the screen behaves more like an application surface than a document:
- complex visual editors
- drag-heavy kanban boards
- highly interactive maps
- advanced offline-first flows
- real-time collaborative surfaces
In those cases, fighting for a server-heavy tree can make the code worse, not
better.
So the point is not to be ideological.
The point is to be intentional.
If a route is basically an interactive app once it loads, I accept that and design
for it. But I want that to be a conscious choice based on the experience, not an
accident caused by placing `'use client'` too high.
## The rules in one list
If I had to reduce the whole thing to a checklist, it would be this:
1. Start on the server.
2. Move to the client only when the browser is genuinely required.
3. Keep the `'use client'` boundary as low as possible.
4. Fetch route-defining data on the server.
5. Let the server own structure and the client own interaction.
6. Use Suspense to separate meaningful chunks.
7. Add optimistic UI only where rollback is simple.
8. Audit for hidden client creep.
That is the framework I keep coming back to.
Not because it is academically pure, but because it leads to codebases that stay
faster, simpler, and easier to change.
## Final thought
The most useful shift for me was realizing that Server and Client Components are
not two competing styles.
They are a composition tool.
The server is excellent at assembling the page, fetching data, protecting
sensitive logic, and sending useful HTML early. The client is excellent at
interactions, local state, and immediate feedback.
Good App Router architecture is mostly about respecting that split.
Once I stopped asking "which side should this page live on?" and started asking
"where does interaction actually begin?" the decisions got much easier.
And in practice, that is the rule that has saved me the most time.
For the larger structure around that boundary — data access, Server Actions,
Route Handlers, authorization, and caching — read
[Next.js App Router Architecture in 2026](/blog/nextjs-app-router-architecture-in-2026).
---
## How I designed one-tap offline place saving for Apple Watch and iPhone
URL: https://www.pean.dev/blog/how-i-designed-one-tap-offline-place-saving-for-apple-watch-and-iphone
Published: 2026-04-10
Description: Pean was never meant to be just another map app. I wanted a faster way to save private places exactly when they matter — from your wrist, without signal, and without friction. Here's the UX and product thinking behind it.
When people think about map apps, they usually think about search, directions,
and navigation.
That is not the problem I wanted to solve.
The real problem was much smaller and much more specific:
**what is the fastest, most reliable way to save a place the moment it
matters?**
Not a public business. Not a destination with a review page. Not something you
can just search again later.
A real personal place:
- a fishing spot
- a mushroom patch
- a berry location
- a hidden trail marker
- a useful landmark
- a place you discover once and do not want to lose
That question is what eventually shaped Pean.
And the more I thought about it, the clearer one thing became:
**if saving a place takes too much attention, the product fails right at the
moment it matters most.**
That is why Apple Watch became central to the product so early.
Not as a “nice extra feature.” Not as a marketing bullet. As a core part of the
whole workflow.

## The problem starts in the wrong conditions
The best moment to save a place is usually not when you are sitting comfortably
with full signal, both hands free, and time to organize everything properly.
It is usually the opposite.
You are moving. Your hands are busy. The weather is not ideal. The signal is
weak. You want the exact point now, not a vague memory later.
That matters more than it sounds.
A lot of apps technically let you save a location. But in real outdoor
situations, “technically possible” is not the same as “actually usable.”
That is where many place-saving flows break down.
They assume you are ready to:
- unlock your phone
- open the app
- wait for the screen to settle
- tap through a flow
- maybe fill in details immediately
- hope the connection holds
For many types of places, that is already too much friction.
If you are trying to save a private fishing spot, a hidden viewpoint, or a
mushroom place you just found, the ideal action is much simpler:
**capture first, organize later.**
That became one of the main product rules behind Pean.
## Why Apple Watch mattered so much
Once I looked at the problem honestly, Apple Watch stopped looking like a side
feature.
It started looking like the fastest interface for the job.
If the goal is to save a place on a map in one tap, the wrist is often more
natural than the phone.
You do not need a long interaction. You do not need a heavy screen. You do not
need full editing tools. You need confidence that the place is captured.
That is the real role of Apple Watch in Pean.
It is not there to replicate the entire iPhone app. It is there to do one thing
extremely well:
**save the GPS point immediately when the place appears in front of you.**
That sounds obvious in hindsight, but it changes the shape of the product
completely.
Instead of asking, “How much functionality can I fit on the watch?” the better
question became:
**What is the minimum interaction that still feels trustworthy?**
That question is much more useful.
Because watch products become awkward very quickly when they try to do too much.
Tiny screens punish complexity. Slow flows feel even slower. Every extra
decision adds friction.
For Pean, the watch experience had to stay brutally simple.
Open. Tap. Save. Trust that it is there.
That is the product.
## Save first, enrich later
This is probably the most important design principle in the whole system.
A place is most fragile at the moment of discovery.
That is when it is easiest to lose.
The exact coordinates matter right then. The rest can come later.
That means the workflow should happen in two layers.
### Layer 1: capture
At the moment of discovery, the app should save:
- the GPS point
- the timestamp
- enough minimal state to trust that the place exists
That is the urgent job.
### Layer 2: context
Later, when you are back on your phone, you can add:
- a photo
- a note
- a category
- more meaning around the saved place
That is the calmer job.
This split matters because most place-saving products blur these two moments
together.
They make the user do “capture” and “organization” inside one flow.
But those are not the same task.
In Pean, I wanted the product to respect the real sequence:
1. discover something worth remembering
2. save the exact place immediately
3. organize it properly later
Once you accept that sequence, a lot of design decisions become easier.
## Why offline was not optional
If a place-saving app only works when connectivity is perfect, it is hard to
trust in exactly the moments when it matters most.
That is especially true for outdoor place saving.
Fishing spots, berry places, mushroom patches, landmarks, quiet return points,
and hidden trails often exist in areas where signal is unreliable. Even when the
network exists, you do not want your confidence to depend on whether it stays
stable for the next few seconds.
That is why offline support could not be treated like an enhancement. It had to
be part of the core product idea.
For me, offline place saving is not just a technical checkbox. It is a trust
feature.
If a user taps save and thinks:
**“Did it really save, or do I need to try again later?”**
the flow is already broken.
So the rule became simple:
- saving must work even without signal
- the place should be queued locally
- sync should happen later without drama
- the user should not have to think about the handoff
This changes the emotional quality of the product.
A place-saving app becomes much more useful when it behaves like a notebook in
your pocket, not like a fragile network form.

## What the watch should not do
One of the easiest mistakes in product design is adding more just because the
platform allows it.
I think Apple Watch products get better when they are shaped by restraint.
For Pean, that meant the watch should **not** try to be the main place
management interface.
It should not become the place where you browse everything, edit everything,
sort everything, and manage a deep content structure.
That is what the iPhone is for.
The watch is strongest at the capture moment. The iPhone is stronger for context
and management. The web is stronger for browsing and reviewing your saved map
over time.
That division makes the overall system clearer.
### Apple Watch is for:
- immediate GPS capture
- speed
- confidence
- low-friction saving outdoors
### iPhone is for:
- photo, note, and category
- reviewing saved places
- editing details
- turning a raw saved point into something meaningful
### Web map is for:
- browsing your saved places on a larger screen
- filtering and exploring your map memory
- seeing your history more clearly over time
A lot of product clarity comes from giving each surface a focused role.
Not every device needs to do everything.
## Architecture starts with failure, not success
One thing I find useful in product work is this:
do not design the architecture around the ideal case. Design it around the
moment when things go wrong.
For a place-saving app, the wrong moments are obvious:
- the signal drops
- the sync is delayed
- the user is moving quickly
- the watch interaction has to stay short
- the save needs to feel confirmed immediately
That is why the flow has to be built around resilience.
The capture payload should stay small. The save action should be clear. The
local queue should be dependable. The sync model should not make the user
babysit the process.
Even without going into implementation details, this product shape naturally
pushes you toward a more robust architecture.
The moment you say:
**“The save must work even in bad conditions”**
you stop designing a normal happy-path app.
You start designing for confidence.
And that changes everything:
- UX states
- confirmation feedback
- local storage decisions
- sync behavior
- error recovery
- what counts as “saved enough”
That is one of the reasons I like working on products like this. A narrow use
case often forces much better thinking than a broad one.
## Privacy changes the whole framing too
Another reason Apple Watch and one-tap capture fit Pean so well is that the
product is not about public map discovery.
It is about personal places.
That sounds subtle, but it changes the entire framing.
A saved fishing spot is not the same type of object as a cafe pin. A mushroom
patch is not the same as a public destination. A hidden viewpoint is not
something you necessarily want to broadcast.
That is why Pean makes more sense to me as a **private place-saving app** rather
than a general map app.
Privacy is not just a legal layer here. It is part of the value.
The more personal the place is, the more important it becomes that the product
respects ownership, selectivity, and control.
That also reinforces the watch flow.
If the job is:
- save my place
- keep it mine
- let me organize it later
- share only when I choose
then the product can stay focused.
It does not need to compete with every navigation feature in the world. It only
needs to solve one workflow exceptionally well.
## Why not just use Google Maps or Apple Maps?
This is the obvious question, and it is a fair one.
General map apps are very good at:
- finding places
- navigating to places
- working with known destinations
- handling public place workflows
But saving a personal place is a different job.
The problem is not “How do I get somewhere?” The problem is:
**How do I quickly save something I just discovered, keep it private, and trust
that I can return to it later?**
That is a narrower workflow. But it is a real one.
And once you optimize for that job, the product starts to look different:
- faster capture
- less friction
- private by default
- better support for personal spots
- stronger offline behavior
- clearer separation between saving and organizing
That is why Pean is not really trying to replace map apps as a whole.
It is trying to do one specific thing better.
## What this taught me about product design
The biggest lesson here is not about Apple Watch specifically.
It is about where product clarity comes from.
A product gets clearer when you stop asking, “What features should we add?” and
start asking, “What exact moment are we trying to support?”
For Pean, the moment was always the same:
you find a place worth keeping, and you need to save it before it disappears
from memory.
Everything else came from that.
- Apple Watch mattered because it reduced friction
- offline mattered because trust mattered
- iPhone mattered because context mattered
- privacy mattered because the places were personal
- the web mattered because long-term map memory mattered
That is the kind of product logic I believe in most.
Not feature accumulation. Not broad positioning first. A sharp job, supported
well.
## Where this goes next
I still think this idea can be pushed much further.
There is a lot more to explore around:
- how offline sync changes user trust
- how categories make saved places more useful over time
- how selective sharing should work for close friends and small groups
- what a personal map becomes after months or years of saving places
But the foundation stays the same.
Pean only makes sense if saving a place feels instant, dependable, and private.
That is why Apple Watch became a core part of the product.
Not because it was flashy. Because it matched the moment.
## FAQ
### Why use Apple Watch to save places on a map?
Because Apple Watch can reduce friction at the moment of discovery. If the goal
is to save a GPS point quickly, tapping your wrist can be faster and more
natural than opening a full phone workflow.
### Can a place-saving app work offline?
Yes. A strong place-saving app should support offline saving or offline-first
behavior, especially for outdoor use cases where signal can be weak or missing.
### What is the best way to save private places?
The best approach is to save the exact location immediately, keep it private by
default, and add context like notes, photos, and categories later.
### Why is offline place saving important?
Because many valuable places are discovered in imperfect real-world conditions.
If saving only works with a stable connection, users cannot trust the product in
the moments that matter most.
### Is a dedicated place-saving app better than a general map app?
It depends on the job. General map apps are excellent for navigation and public
destinations. A dedicated place-saving app is better when the goal is to save
personal spots quickly, keep them organized, and control who sees them.
---
Related reading:
- [How Pean started: a private map app for saving places, fishing spots, and personal discoveries](/blog/how-the-idea-for-pean-started)
- [How to save places on a map](https://www.pean.me/en/blog/how-to-save-places-on-a-map)
- [Best Google Maps alternatives for saving places](https://www.pean.me/en/blog/google-maps-alternatives)
---
## Why Google Maps is not enough for saving personal places
URL: https://www.pean.dev/blog/save-personal-places-not-google-maps
Published: 2026-04-07
Description: I tried using Google Maps to save personal locations — fishing spots, hidden viewpoints, random discoveries. It didn't work the way I needed. Here's why and what I built instead.
Saving locations should be simple. It’s not.
At some point I realized I had dozens of places I wanted to keep: fishing spots,
viewpoints, random places I discovered while traveling. Not restaurants. Not
businesses. Just **points that mattered to me**.
So naturally, I tried using Google Maps.
It didn’t work.
## What I expected
I thought I’d be able to:
- Save a spot instantly (like a bookmark)
- Organize places in a clean way
- Keep them private
- Share a few of them with specific people
Sounds basic.
## What actually happens in Google Maps
Google Maps is built around **places**, not **personal points**.
That difference matters more than it seems.
### 1. It’s optimized for businesses, not your own spots
When you save something in Google Maps, you're usually saving:
- a restaurant
- a cafe
- a public place
But what if it’s:
- a fishing location in the middle of nowhere
- a mushroom spot in the forest
- a random roadside view
You end up dropping a pin, but it feels like a hack — not a first-class feature.

## Lists don’t scale
Google Maps gives you “Saved” lists.
At first it feels fine. Then:
- Everything becomes a long flat list
- No real structure
- No fast way to navigate your own spots
- No context (why did I save this?)
After ~20–30 places it starts breaking down.
## Privacy is basically all or nothing
This is the biggest problem.
You can either:
- keep everything private
- or share a full list
But what if you want:
- share **just one** fishing spot
- with **one person**
- without exposing everything else?
You can’t.
The usual workaround:
- copy coordinates
- send them manually
- lose all context
## Sharing is not built for real life
Real use cases look like this:
- “I’ll send you that exact spot where I caught fish last week”
- “Don’t share this with others”
- “This place only works in spring”
Google Maps sharing doesn’t support that kind of nuance.
It’s generic.
## What I actually needed
After using it for a while, I realized I wanted something very different:
- Save a location in **one tap**
- Work with **any point**, not just known places
- Be **private by default**
- Share **selectively**
- Keep context (notes, meaning, memory)
Basically — something built for **personal discovery**, not navigation.
## What I built instead
That’s how I ended up building :contentReference[oaicite:0]{index=0}.
Not a maps app. Not a navigation tool.
Just a simple way to:
- save places that matter
- keep them private
- share only what you want

One small but important detail: everything is **yours by default**.
No accidental sharing. No exposure.
## The shift that changed everything
The key realization was this:
> Maps apps are built for finding places. I needed something for **keeping
> them**.
That’s a completely different problem.
And once you see it that way, a lot of UX decisions suddenly make sense.
## Final thoughts
Google Maps is incredibly good at what it does.
But saving personal locations is not its core use case.
If you only save restaurants — it works.
If you care about your own places — it starts falling apart.
That gap is bigger than it looks.
And it’s probably why I ended up building my own tool.
---
## How Pean started: a private map app for saving places, fishing spots, and personal discoveries
URL: https://www.pean.dev/blog/how-the-idea-for-pean-started
Published: 2026-04-05
Description: Pean is a private map app for saving places, organizing fishing spots and personal discoveries, and sharing locations with friends on your terms. This is the story behind the product.
**Pean is a private map app for saving places that matter.** It helps people save places on a map, keep private spots organized, share locations with friends when they want to, and return to personal discoveries later.
It is especially useful for people who want a **personal map app** for outdoor scenarios: fishing spots, mushroom places, berry locations, hidden trails, herbs, landmarks, and other saved places that are meaningful mainly to them.
This article is still the story of how the idea started, but it also answers a more practical question: **what is Pean, who is it for, and why does it exist when products like Google Maps already exist?**
If you want to see the product itself, start here:
- [Pean homepage](https://www.pean.me/en)
- [Pean web map](https://www.pean.me/map)
- [More Product Lab posts on pean.dev](/blog)

## What is Pean?
Pean is a **private map app** built for saving personal places, organizing them by category, and coming back to them later.
The simplest way to describe it is this:
- save places on a map in one tap
- keep private places private by default
- organize saved places with notes, categories, and media
- share locations with friends or small groups on your terms
- keep working even when mobile signal is unreliable
That makes Pean different from a general map or navigation app. It is not about finding every place in the world. It is about building a **personal map of places that matter to you**.
## Who is Pean for?
Pean is useful for people who repeatedly discover places they want to keep:
- anglers who want to save fishing spots
- hikers who want to keep trail markers and return points
- travelers who want private travel locations
- mushroom and berry pickers who want to organize discoveries on a map
- anyone who wants to save personal spots without mixing them into public maps
In short, it is for people who need an **app to save places on a map** without turning those places into public bookmarks.
## The problem that started the product
Pean did not start with the idea of building yet another map app. It started with a more practical question: **how do you avoid losing a place that matters to you?**
Not a business listing you can search again later. Not a public point of interest. A real personal spot:
- a fishing place
- a mushroom patch
- a berry location
- a quiet turn on a trail
- a useful landmark
- a place you know you want to revisit
The problem is that these moments usually happen in imperfect real-world conditions:
- your hands are busy
- the weather is not ideal
- there is no signal
- you do not want to open a complex app
- you need the exact point, not a vague memory
That is why the core product question became much clearer:
**How do you save places fast, keep them private, and still trust that they will be there when you need them later?**
## Why Pean instead of Google Maps?
This is one of the most important SEO and product questions, so it is worth answering directly.
[Google Maps](https://maps.google.com/) is excellent for search, public places, route planning, and navigation. But it is not primarily built as a **private places app** for personal spots, fishing spots, or small-group location sharing.
Pean is different because it focuses on a narrower but very real job:
- save places on a map that matter to you personally
- keep those places private by default
- organize saved places by category and context
- share locations with friends selectively
- return to your own spots instead of searching public listings again
So the correct framing is not “Pean replaces Google Maps.”
The better framing is:
**Google Maps helps you find places in the public world. Pean helps you keep places in your personal world.**
## From WildSpot to Pean
Inside the codebase and earlier product thinking, the name **WildSpot** appears. That name fit the earliest shape of the product because the first use cases were strongly tied to outdoor discovery: places you find in the wild and want to remember.
Over time, the concept became broader.
The early scenarios still mattered:
- save fishing spots
- save mushroom places
- save berry locations
- save herbs, landmarks, and return points
But the deeper use case was not limited to one niche. The real product was becoming a **personal map app for meaningful places**. That broader direction is why the public brand became **Pean**.
## Why the idea only worked if saving was fast
The product would not make sense if saving a place felt slow.
If the flow requires too many taps, too much attention, or a stable connection, the place is already easy to lose. That is why Pean had to be designed around speed first:
1. discover a place
2. save the exact location immediately
3. add context later if needed
This is also where the “one tap” logic became central. Pean is not just an app to organize saved places. It is an app built around the exact moment when a place needs to be captured before it disappears from memory.
## Why Apple Watch became important
One of the strongest product ideas behind Pean is that Apple Watch is not treated as a side feature. It is treated as one of the fastest ways to save places on a map.
That matters because in real outdoor situations, reaching for a phone can be friction. Reaching for your wrist is often faster.
According to the current public positioning on [pean.me](https://www.pean.me/en), the watch flow is intentionally simple: save the GPS point quickly, queue it locally if needed, and sync later.
That choice says a lot about the product. Pean was shaped around the real capture moment, not around a generic feature list.

## Offline and privacy were not secondary features
Two things had to be part of the core product from the beginning: **offline-first behavior** and **privacy by default**.
That is because the most valuable saved places are often the least connected:
- forest paths
- quiet travel locations
- fishing spots
- seasonal mushroom areas
- places outside strong mobile coverage
If a product fails there, it becomes hard to trust.
Privacy matters for a similar reason. Many places are valuable precisely because they are personal. Some are useful. Some are seasonal. Some are shared only with close friends. Some should stay yours.
That is why Pean makes more sense as:
- a private map app
- a tool for organizing saved places
- a way to share locations with friends selectively
instead of a public social map.
## Real use cases for Pean
If someone searches for an **app to save places on a map**, they usually want a concrete scenario, not an abstract category.
These are some of the clearest use cases for Pean today:
- **Save fishing spots** and keep them organized by season, note, or category.
- **Keep private travel locations** that are meaningful to you but not meant for public lists.
- **Share locations with friends** in a closed group instead of posting them publicly.
- **Organize saved places** like landmarks, herbs, berry locations, and return points.
- **Build a personal map app workflow** around discovering, saving, and revisiting places.
That mix of private saving, categorization, and selective sharing is what gives the product its own space.
## What Pean already offers today
The story is useful, but it matters just as much to explain what the product actually gives users now.
Today, the Pean direction already includes:
- Apple Watch capture
- iPhone spot management with photo, note, and category
- a web map for browsing saved places
- categories such as fish, mushrooms, berries, hunting, herbs, landmarks, and other
- private spots and selective sharing
- offline-first saving with sync later
You can see the current product framing here:
- [Pean homepage](https://www.pean.me/en)
- [Web map](https://www.pean.me/map)
- [About the maker on pean.dev](/)
## The deeper product idea
The longer Pean exists, the clearer its category becomes.
It is not just a notes app with coordinates.
It is not just another map.
It is not only an outdoor utility.
It is a **private memory map**: a product for saving places, organizing discoveries, and coming back to them later.
That is why the story matters. The product did not start from a trend. It started from a repeated behavior that existing tools handled poorly.

## FAQ
### What is Pean?
Pean is a private map app for saving meaningful places, organizing personal spots, and sharing locations with friends on your terms.
### Can I save private places on a map with Pean?
Yes. Pean is built around private places by default, so saved spots can stay personal until you decide to share them.
### Can I share locations with friends privately?
Yes. Pean supports selective sharing and group access, so you can share places with trusted people instead of posting them publicly.
### Is Pean an alternative to Google Maps for personal spots?
Yes, in the sense that it covers a different job. Google Maps is for search and navigation. Pean is for saving personal spots, organizing discoveries, and returning to meaningful places.
### Can I use Pean to organize fishing spots?
Yes. Fishing spots are one of the clearest use cases, together with mushroom places, berry spots, herbs, landmarks, and other personal discoveries.
## What comes next
This is the first article in the **Product Lab** series on pean.dev. The next posts can go deeper into:
- the earliest MVP shape
- why categories mattered from the beginning
- how offline sync changes trust
- how group map sharing fits the product
- how Apple Watch influenced architecture and UX priorities
If you want to follow both the product and the build process, start with [the blog index](/blog) and [Pean itself](https://www.pean.me/en).
---
## What actually makes a frontend feel fast in 2026
URL: https://www.pean.dev/blog/what-actually-makes-a-frontend-feel-fast
Published: 2026-04-04
Description: Fast-feeling frontends are built with streaming, optimistic UI, layout stability, careful media loading, and metrics that reflect interaction quality instead of just raw network speed.
Most frontend performance discussions still start with network speed. That matters, but it is not what users feel first.
A frontend feels fast when it acknowledges intent immediately, keeps the layout stable, reveals useful content early, and avoids making the user wait for the whole page to become "done" before anything responds.
That is why modern performance work is less about shaving one more millisecond off a request and more about controlling *when* the user gets feedback, *how much* of the UI becomes usable early, and *whether* the browser has to keep redoing expensive work.

## 1. Speed starts with acknowledgement
If a click lands and nothing reacts, the interface already feels slow even if the request finishes quickly. The first job of a modern frontend is to acknowledge intent inside the same interaction frame.
That acknowledgement can be:
- a pressed state on a button
- a route transition that starts immediately
- a skeleton for a content area
- an optimistic update in a list
- a progress hint that confirms work has started
This is why `INP` matters so much more than teams used to think. Users do not experience a page as a static document anymore. They experience it as a stream of interactions. A fast site with bad interaction latency still feels broken.
In React, this often means separating urgent work from non-urgent rendering. For example, keep the tap or click responsive first, then let heavier UI reconcile in a transition:
```tsx
import { startTransition, useState } from 'react';
function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const handleChange = async (value: string) => {
setQuery(value);
const nextResults = await fetchResults(value);
startTransition(() => {
setResults(nextResults);
});
};
return handleChange(e.target.value)} />;
}
```
The important part is not React-specific. The important part is architectural: urgent feedback should not wait for expensive rendering.
## 2. Streaming beats waiting for "the whole page"
Older apps treated a page as complete only when all data had arrived. Modern apps should stop doing that.
A better model is:
1. send the shell fast
2. reveal primary content as soon as possible
3. stream secondary content later
4. hydrate only where interactivity is needed
This is where Server Components, streaming SSR, and suspense boundaries help. They are not magic performance buttons, but they let you structure work so that users see useful UI earlier.
The win is not only on the network. Streaming also reduces the psychological cost of waiting because the page starts progressing immediately.
```tsx
export default function DashboardPage() {
return (
<>
}>
}>
>
);
}
```
The shell, navigation, and structure can appear immediately. Revenue and activity do not need to block each other. That alone changes how fast the page feels.

## 3. Optimistic UI is often more important than raw request time
If a mutation takes 500ms but the UI updates instantly and then confirms, the action often feels fast enough. If the same mutation takes 250ms but the UI stays frozen and uncertain, it feels worse.
That is why modern products lean on optimistic patterns for:
- likes and reactions
- checkbox toggles
- list reordering
- chat/message sending
- inline editing
The rule is simple: use optimistic updates when the success rate is high and the rollback story is clear.
```tsx
const [optimisticItems, addOptimisticItem] = useOptimistic(items, (state, draft) => [
draft,
...state,
]);
async function submit(text: string) {
const optimistic = { id: crypto.randomUUID(), text, pending: true };
addOptimisticItem(optimistic);
try {
await createComment(text);
} catch {
// rollback or refetch
}
}
```
This is one of the biggest differences between an app that is technically fast and an app that *feels* fast. The network is no longer the only timeline users perceive.
## 4. Layout stability is performance
Late content that shifts the screen destroys the feeling of speed. The browser may have loaded the pixels quickly, but if text jumps, buttons move, or images resize after render, users interpret the interface as unstable and therefore slow.
This is exactly why `CLS` still matters. Modern frontends need to reserve space early and render media with known dimensions.
Good defaults:
- always provide image width and height or an aspect ratio
- reserve space for ads, embeds, and charts
- avoid swapping fonts in ways that reflow large text blocks
- keep skeletons close to the final layout, not generic grey boxes
```css
.cardMedia {
aspect-ratio: 16 / 9;
background: #17191f;
overflow: hidden;
}
.cardMedia img {
width: 100%;
height: 100%;
object-fit: cover;
}
```
This looks simple, but it prevents a surprising amount of jank. A stable interface reads as a fast interface.

## 5. Media strategy matters more than teams admit
Modern pages are often bottlenecked by media behavior, not JavaScript alone.
A frontend feels fast when:
- above-the-fold media is prioritized intentionally
- below-the-fold media is lazy without hurting scroll smoothness
- decoding happens predictably
- image sizes match the real rendered size
- video thumbnails are cheap and stable
The bad version is common: a page ships "optimized images" but still downloads assets far larger than needed, triggers decode work at the wrong moment, and causes the browser to fight both painting and layout at once.
For article pages in particular, media should support reading rhythm, not interrupt it.
## 6. Prefetch helps, but only when it is selective
Prefetch can make navigation feel instant, but aggressive prefetching is not free.
It costs:
- bandwidth
- memory
- cache pressure
- CPU work to parse and evaluate future code
Modern apps should prefetch based on probability, not hope. Good candidates:
- links in or near the viewport
- likely next-step routes
- hover-triggered intent
- recently visited sections that are likely to be revisited
Bad candidates:
- every link in a large feed
- deep routes users rarely open
- data-heavy pages with short cache lifetimes
The goal is not "prefetch more". The goal is "make the next likely action feel instant without making the current page heavier."
## 7. The browser main thread is still the real bottleneck
Many teams now understand bundle size. Fewer teams think clearly about main-thread pressure after the bundle arrives.
A frontend feels slow when the browser is busy with:
- large synchronous renders
- too many layout reads and writes
- expensive effects on mount
- oversized hydration boundaries
- animation work that competes with interaction
This is why partial hydration, island architecture, Server Components, and better scheduling matter. They reduce how much JavaScript has to compete for the main thread during the moments users are actually touching the page.
At the component level, the rule is still boring and useful:
- avoid rendering what is not visible
- avoid recomputing what has not changed
- defer non-urgent work
- keep interaction paths short
## 8. Measure what users can feel
If I had to pick the metrics that map most directly to user perception on modern frontends, I would watch:
- `INP` for interaction responsiveness
- `LCP` for primary content visibility
- `CLS` for layout stability
- route transition latency for app navigation
- mutation confirmation time for interactive flows
The critical detail is that lab scores alone are not enough. A frontend can score well and still feel awkward if transitions are blocked, loading states are vague, or layout shifts happen in the exact moments users are trying to act.
Real user monitoring is the only way to see that.
## 9. My rule of thumb
A frontend feels fast when the user can answer three questions immediately:
1. Did the interface register my action?
2. Can I already do the next thing?
3. Is this screen stable enough to trust?
Streaming, optimistic UI, transitions, image strategy, and scheduling are all tools for improving those three answers.
That is the modern shift. Performance is no longer only about delivering bytes faster. It is about structuring feedback, rendering, and stability so the browser always has something confident and useful to show next.
---
## CSS animations without Framer Motion: what I learned
URL: https://www.pean.dev/blog/css-animations-without-framer-motion
Published: 2026-04-03
Description: I removed Framer Motion from my personal site and replaced everything with native CSS. Here's how I did it, what was hard, and what I got in return.
Framer Motion is great. I used it for a year on my personal site and it worked
perfectly — smooth animations, declarative API, stagger effects with almost no
code. But at some point I opened Lighthouse and saw that the library alone was
adding **~40KB** to the bundle. For a personal site with a few page transitions
and entrance animations, that felt like too much.
So I decided to remove it entirely and rewrite everything with native CSS.

## What I was replacing
The animations on the site were:
- **Page transition** — `AnimatePresence` + `motion.main` with `opacity` and `y`
on enter/exit
- **Section entrance** — `motion.section` with `initial/animate/exit` and a
configurable `delay` prop
- **Text animation** — custom `AnimateText` component that split text into words
and animated each one
- **Stagger lists** — `motion.ul` with `staggerChildren` in `variants`
- **Scroll-linked parallax** — `useScroll` + `useTransform` on the greeting
block
All of that had to go.
## Page transitions
The hardest thing to replicate natively is page _exit_ animations. Framer Motion
gives you `AnimatePresence` which keeps the old page mounted until its exit
animation finishes. CSS has no equivalent.
I made a pragmatic call: keep enter animations, drop exit animations. On a
personal site nobody notices the exit. Here's what the enter looks like:
```css
@keyframes pageEnter {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.main {
animation: pageEnter 0.4s ease both;
}
```
To re-trigger the animation on route change I pass `key={pathname}` to the
wrapper component. React remounts the element, the animation fires again.
## Stagger without a parent component
Framer Motion's `staggerChildren` works by having a parent `motion` component
that delays each child. In pure CSS you can do the same with `:nth-child`:
```css
.item:nth-child(1) {
animation-delay: 0.1s;
}
.item:nth-child(2) {
animation-delay: 0.2s;
}
.item:nth-child(3) {
animation-delay: 0.3s;
}
```
But that only works if you know how many items there are. When the list is
dynamic I pass an `index` prop down and set `animationDelay` inline:
```tsx
```
Not as elegant, but it works for any length list.

## Text animation with CSS custom properties
The `AnimateText` component split text into `` elements per word and
animated each with a stagger. I kept the same idea but moved the delay into a
CSS custom property:
```tsx
words.map((word, i) => (
{word}
));
```
```css
.word {
display: inline-block;
animation: textSlideUp var(--duration, 0.5s) ease both;
animation-delay: var(--delay, 0s);
}
```
CSS custom properties flow through the cascade so you can set them inline and
read them in any nested selector. Very clean.
## Scroll-linked parallax
This one I had to do in JavaScript anyway since CSS scroll timelines don't have
broad enough browser support yet. I replaced `useScroll` + `useTransform` with a
plain scroll listener:
```ts
useEffect(() => {
const handleScroll = () => {
const progress =
window.scrollY / (document.body.scrollHeight - window.innerHeight);
el.style.transform = `translateY(${progress * 200}px)`;
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);
```
The key detail: `{ passive: true }`. Without it the browser can't optimize
scrolling because it has to wait for your handler to potentially call
`preventDefault()`. With it, scroll is butter smooth.
## Results
The bundle went from ~280KB to ~215KB for the main page. Not revolutionary, but
the site feels noticeably snappier on slow connections. More importantly — there
are fewer moving parts. When something breaks I know exactly where to look.
Would I recommend this approach for a product with complex animations? No.
Framer Motion earns its weight when you have shared layout animations, drag
interactions, or physics-based spring effects. But for a portfolio site with
simple entrances and transitions, native CSS is more than enough.
---