The Frontend Doesn't Get to Define the Boundary

The Next.js app students use and the Laravel dashboard staff use both read the same question bank in StudySmart AI, and they were built eight months apart. Neither one had to be reshaped when the other showed up, because the boundary between what a student can see and what staff can see was drawn in the FastAPI core before either interface had a single screen.

The problem

StudySmart AI's question bank has exactly one owner - the FastAPI core - and two very different readers. A student needs a filtered, randomized subset of questions to take a quiz, plus their own attempt history layered on top. Staff need the opposite: the full bank, unfiltered, with authorship (was this question written by a person or generated by the model), edit history, and moderation flags visible, and the ability to mutate any of it directly. Same rows in the same collection, read for entirely different purposes.

The student-facing app existed first. The admin dashboard - a separate Laravel codebase - arrived roughly eight months later, once staff needed to curate the bank by hand instead of trusting whatever the assistant had generated. That gap is exactly where this kind of design either pays off or turns into a rewrite.

What "backend-first" actually meant here

It did not mean building the API before writing any frontend code - that is just build order. It meant deciding, while the student app was still the only client, that a second consumer with different permissions was coming, and shaping the API around two named roles instead of one implicit one. The student endpoints and the admin endpoints were written as separate response contracts from the start, even though only one of them had a UI to call it for most of the project's life.

class StudentQuestionView(BaseModel):
    id: str
    prompt: str
    options: list[str]
    topic: str
    # No authorship, no moderation flags, no edit history -
    # nothing a student's UI has any use for, or should see.

class AdminQuestionView(BaseModel):
    id: str
    prompt: str
    options: list[str]
    topic: str
    source: Literal["human", "generated"]
    flagged: bool
    last_edited_by: str | None
    last_edited_at: datetime | None

Two models over one table. The student view is not the admin view with fields hidden at render time - it is a different contract, generated from a different query, so there is no code path in which a moderation flag or an editor's identity ever serializes into a response a student's browser receives.

Why the frontend can't be the one to draw this line

The tempting alternative is one endpoint, one response shape, and an is_admin flag the frontend uses to decide what to render. That puts the actual security boundary - who gets to see authorship and moderation state - inside client-side conditionals, which is exactly the boundary that matters least where a browser can enforce it and most where a server can. Anyone who can call the API directly, without ever loading the admin UI, gets the full object back regardless of what the dashboard chooses to render.

The role check has to happen at the boundary the request actually crosses, not in the component that happens to be looking at the response:

async def require_role(role: Role, user: User = Depends(current_user)) -> User:
    if user.role != role:
        raise HTTPException(status_code=403, detail="Insufficient role")
    return user

@router.get("/admin/questions/{id}", response_model=AdminQuestionView)
async def get_question_admin(id: str, _: User = Depends(partial(require_role, Role.STAFF))):
    ...

@router.get("/questions/{id}", response_model=StudentQuestionView)
async def get_question(id: str, _: User = Depends(current_user)):
    ...

Two routes, two response models, one dependency deciding who is allowed to reach which. Nothing about which fields a client is trusted to see is decided past this point.

The tradeoff: work with nothing to show for it

This costs real time up front, and it is time that produces no visible progress. Writing AdminQuestionView, its query, and its role dependency months before a single admin screen exists to call it looks like scope creep on a project that, at that point, has exactly one client. It is easy to justify skipping - nobody is asking for it yet, and the student app would ship identically without it.

The cost of skipping it shows up later and lands on someone else's schedule. Bolting an admin role onto an endpoint the student app already depends on means every change to the response shape is now a compatibility question against a client that already shipped - add a field the student frontend has to explicitly ignore, or branch the one endpoint on role and hope every future change remembers which fields belong to which branch. Drawing the boundary before the second client existed meant the Laravel dashboard, when it arrived, called endpoints that were already shaped for it. Nothing on the student side moved.

Where this doesn't apply

This is not an argument for designing every hypothetical consumer in advance. StudySmart AI has a third surface - the AI assistant chat - that reads quiz and session data too, and it reuses the student contracts rather than getting its own, because what the assistant needs to reference in conversation is exactly what a student is allowed to see. A second role boundary was worth drawing early because it was already known and already different. A boundary that doesn't exist yet, for a client that isn't committed, is speculation - and speculation is exactly what this decision is not. The admin dashboard wasn't a guess; it was scoped and scheduled before the student app's first release. The boundary got drawn early because the second reader was already real, not because a second reader might someday show up.

StudySmart AI is a client engagement; the code referenced here describes the architecture rather than reproducing production source.

See the full project journey

Was this useful?

Corrections, counter-arguments and benchmark disagreements all welcome.

Protected by reCAPTCHA. Google's privacy policy and terms apply.