Build Your First Feature
Add your first real feature without redesigning the app. Follow the standalone template's existing path from React Query to Postgres, and keep trusted ownership on the server.
Auth and billing are already wired. The deploy path is already there.
Now you need the part that makes the application yours.
That first custom feature is an easy place to throw away the value of a starter. A form imports a database helper because it is convenient. Another component talks to a provider directly. A new endpoint invents its own way to identify the user. None of those decisions looks large on its own.
Soon the starter has two or three ways to do the same kind of work.
Your first feature does not automatically need a second architecture.
We will use one small example: a product brief with a title and a problem to solve. A signed-in user should be able to create a brief, refresh the page, and still see it. Another user should not see it.
The Product Brief is illustrative. It does not ship with CoreMVP. The path we will use does.
Your feature already has a home
The standalone template already has one path for browser-driven application state:
Figure 1. One feature follows the template's existing path from browser interaction to trusted server work and persisted data.
You do not need to memorize that stack before you start. The important part is that each boundary already exists.
More importantly, the template is already using it.
The signed-in account status on the dashboard starts in useUserStatus(). That hook calls the local /api/user/status route. The route calls a server-only service. The service resolves the current Supabase user before reading persisted state for that user.
So when we add a Product Brief, we are not choosing a new architecture. We are reusing a path that is already carrying authenticated application state.
That changes the first question from:
What architecture should this feature use?
to:
Where does this feature fit in the architecture that already works?
For the brief, the answer is straightforward. The form stays in the UI. React Query owns the browser request and cached result. The local Route Handler accepts the request. The service makes trusted decisions. The repository owns the database operation.
Yes, that is more structure than putting everything in one component or one route.
But in this template, most of that structure is already in place. The directories, Supabase server client, service boundary, repository pattern, and same-origin API path already exist. Skipping them does not remove a system. It creates a second way to do the same job.
That is the tradeoff worth preserving: a few small boundaries now, instead of a second application shape you have to remember later.
Keep trusted work on the server
A Product Brief belongs to a user.
The easiest mistake is to let the browser tell the server which user that is.
For example, the form could send:
title
problem
ownerIdThat is convenient. It is also the wrong authority.
The browser can tell us what the user typed. It cannot be trusted to establish who owns the resulting row.
The server already has a better source: the authenticated Supabase session.
So the browser sends the brief input. The server validates that input, resolves the signed-in user, and attaches that identity to the write.
The important part of the service is small:
import "server-only";
import { BriefRepository } from "@/lib/db/repositories/brief.repository";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import { requireUserAuth } from "@/lib/supabase/session";
export async function createBrief(input: { title: string; problem: string }) {
const supabase = await createSupabaseServerClient();
const user = await requireUserAuth(supabase);
return BriefRepository.create({
...input,
ownerId: user.id,
});
}The line that matters is ownerId: user.id.
Ownership comes from the server-side session, not from a form field.
Validation follows the same rule. The browser can help the user catch mistakes early, but the server still has to reject input that does not meet the feature's contract before bad state is saved.
This is what built with security in mind should mean in practice. Not a vague claim that the application is secure. A concrete decision about where trusted identity and validation come from.
The read path should preserve that decision. When the app lists briefs, the server resolves the user again and asks the repository for rows belonging to that server-derived user ID.
The exact schema, migration, validation library, and repository implementation belong in the Docs. The reusable judgment is simpler:
The browser describes the action. The server establishes the trusted context.
Figure 2. The browser sends what the user typed. The server attaches who the signed-in user actually is before the repository writes the row.
Saved should mean saved
A feature can look finished before it actually works.
The POST request returns. The new brief appears immediately. A green toast says "Saved."
Then you refresh the page and it disappears.
That failure is useful because it exposes the difference between a successful write request and a feature the rest of the application can rely on.
For a persisted user-owned feature, the finish line should be boring:
- Create a brief.
- Refresh the page.
- The brief is still there.
- Sign in as another user.
- That user does not see it.
The refresh removes the convenient result sitting in browser memory. The application has to read the brief back through its normal path.
The second account tests something different. Authentication tells the server who made the request. Your data access still has to respect which rows belong to that user.
That is why, for this first feature, I would rather refresh the normal briefs query after a successful mutation than treat the mutation response as the final truth. React Query can support either pattern. Here, re-reading from the server buys us something useful: the same path the user depends on after a reload is exercised immediately after the write.
The tradeoff is a little more work and another request. The benefit is that the first feature proves the read path at the same time as the write path.
A signed-out request should fail. Invalid input should fail. A different user should not recover the row.
None of those behaviors makes for an exciting demo.
They make for a feature you can trust after the demo is over.
Figure 3. Refresh proves persistence. A second account proves that the row remains tied to the server-derived owner.
Do not redesign the app yet
The Product Brief does not introduce a new runtime problem.
It uses the same user identity, the same application, the same Postgres database, and the same browser-to-server path the template already uses.
So it stays in the one-app architecture.
That is not a rule that a separate backend is always wrong. It is a refusal to pay for a new boundary before this feature gives us a reason to own one.
The same applies to abstractions.
BriefRepository and createBrief() are boring names. Good. They describe the product thing we are actually building.
A generic feature framework would ask us to invent a reusable contract before we have enough features to know what is genuinely shared. That can wait.
The first custom feature should make the product more specific, not make the architecture more generic.
Build the next one
For another persisted, user-owned feature, ask five questions before you add new structure:
- What does the user do?
- What input must the server validate?
- What trusted decision must stay off the client?
- What should still be there after a refresh?
- What must another user never be able to read?
Those questions are more useful than starting with file names.
Maybe the next object is a saved report, a workspace setting, or a content draft. The noun changes. For this class of feature, the path can often stay the same.
That is the reusable lesson from the first one:
Reuse the path that already works until a real requirement gives you a reason not to.
Then spend the complexity budget on the behavior that actually makes your product different.
For the exact implementation patterns, continue with the Authentication, Database, and Components documentation.
I'm Antoine. I build CoreMVP and write about the problems I run into, the decisions I make, and what I learn along the way.