| name | new-route-scaffold |
| description | Full scaffold workflow for building a new route inside app/(a)/ with correct SSR-first architecture, database-derived types, Redux hydration, shell components, and view sub-pages. Use when creating any new authenticated route, adding a feature with a sidebar/main layout, or extending an existing route with new view modes. Triggers on: new route, new page, scaffold, app/(a)/, SSR shell, route structure, Redux hydration for route, database type mismatch. |
New Route Scaffold — app/(a)/
This skill captures the exact workflow used to build app/(a)/notes. Follow it for every new authenticated route. Many decisions require information only Arman has — ask explicitly before assuming anything.
STOP: Questions to Ask Before Writing a Single Line
Never assume the following — always ask Arman:
- Layout shape — How many panels? Sidebar width? Resizable? What goes in the header area?
- View modes — Does this route have sub-views (like edit/preview/split)? What are they named?
- Group-by / filter dimensions — How is the sidebar list organized? What FK relationships drive grouping?
- Data fetch scope — What fields does the list view need? What triggers a "full" fetch?
- Redux slice — Does one already exist? Should we create one or extend an existing slice?
- Existing types — Do the feature types already exist? Are they derived from the DB or hand-written (likely stale)?
- Navigation entry — Does
constants/navigation-links.tsx already have an entry? What favicon color/letter?
- Mobile behavior — Any mobile-specific overrides beyond standard rules?
If anything is unclear, stop and ask. Do not invent layout details, field names, or group-by modes.
Phase 1: Audit & Fix Types
Before touching any route file, verify the feature type file matches types/database.types.ts.
Step 1 — Find the DB Row shape
grep -A 30 '"your_table": {' types/database.types.ts
Step 2 — Compare against the feature type file
Located at features/[feature]/types.ts. Hand-written types are almost always missing fields. The canonical pattern:
import type { Database } from "@/types/database.types";
export type NoteRow = Database["public"]["Tables"]["notes"]["Row"];
export type NoteInsert = Database["public"]["Tables"]["notes"]["Insert"];
export type NoteUpdate = Database["public"]["Tables"]["notes"]["Update"];
export type NoteFolderRow = Database["public"]["Tables"]["note_folders"]["Row"];
export type NoteVersionRow = Database["public"]["Tables"]["note_versions"]["Row"];
{
: ;
: ;
: ;
: | ;
: | ;
: | ;
: | ;
: | ;
: | ;
: [] | ;
: <, > | ;
: <, > | ;
: | ;
: ;
: ;
: ;
: | ;
: | ;
: | ;
: | ;
: | ;
: | ;
}
_NoteCompatCheck = {
[K keyof ]: [K] [K]
? [K] [K] ? :
: ;
};
= <,
| | | | |
| | |
| | |
| |
>;
= | | | | ;
= | | | | | ;
Step 3 — Update Redux notes.types.ts factory functions
The createBlankNoteRecordFromPartial and createAutogeneratedNoteRecord functions hardcode default values for every field. After adding fields to the interface, update both factories to include the new fields — otherwise TypeScript will error. Key rule: new nullable fields default to null, not "" or [].
Phase 2: Server Data Layer
Create lib/[feature]/data.ts. This is the only place server-side DB queries live for this route.
import "server-only";
import { cache } from "react";
import { createClient } from "@/utils/supabase/server";
import { notFound } from "next/navigation";
import type { Note, NoteListItem } from "@/features/notes/types";
export const getNoteListSeed = cache(async (): Promise<NoteListItem[]> => {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return [];
const { data, error } = await supabase
.from("notes")
.select("id, user_id, label, folder_name, folder_id, tags, updated_at, position, organization_id, project_id, task_id, is_public, version")
.eq(, user.)
.(, )
.(, { : })
.();
(error) error;
(data ?? []) [];
});
getNote = ( (: ): <> => {
supabase = ();
{ data, error } = supabase
.().().(, id).();
(error || !data) ();
data ;
});
preloadNote = (: ): { (id); };
Critical rules:
import "server-only" is mandatory — it prevents accidental client import
- Every function must be wrapped in
cache() — layout, generateMetadata, and page all call the same function; cache() collapses them to one DB hit
notFound() inside a cached function triggers not-found.tsx automatically
- The preload pattern starts a fetch before an await chain, eliminating waterfalls
Phase 3: Route Files
File map
app/(a)/[feature]/
├── layout.tsx ← static metadata only
├── loading.tsx ← dimension-exact skeleton of the full shell
├── error.tsx ← <ErrorBoundaryView context="[Feature]" /> one-liner
├── page.tsx ← fetches list seed, hydrates Redux, renders shell
└── [id]/
├── layout.tsx ← parallel fetch, generateMetadata, BOTH hydrators, shell
├── loading.tsx ← skeleton of the content area only (not the full shell)
├── error.tsx ← <ErrorBoundaryView context="[Feature] Detail" /> one-liner
├── not-found.tsx ← not-found UI
├── page.tsx ← redirect to default view
├── edit/page.tsx
├── split/page.tsx
├── rich/page.tsx
├── md/page.tsx
├── preview/page.tsx
└── diff/page.tsx
error.tsx (route root and [id]/)
Every error boundary is a one-liner — never build error UI inline.
"use client";
import { ErrorBoundaryView } from "@/components/errors/ErrorBoundaryView";
export default function MyFeatureError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
return <ErrorBoundaryView error={error} reset={reset} context="My Feature" />;
}
Optional props: context (string label for console logs), homePath (override home button path, default "/").
ErrorBoundaryView shows all users a polished error UI with retry/back/home actions. Admins get a collapsible debug panel with full error details, request context, user context, stack trace, raw JSON dump, and a "Copy for AI" button that strips minified chunk URLs and formats a clean Markdown summary for pasting into any AI chat.
page.tsx — placeholder for routes under development
When a route's real UI isn't built yet, use ComingSoonPage from components/coming-soon/CominSoonTemplate.tsx. Always customize all four props — the defaults are generic marketing copy that will confuse users and hurt SEO.
import ComingSoonPage from "@/components/coming-soon/CominSoonTemplate";
export default function MyFeaturePage() {
return (
<ComingSoonPage
heroTitleLine1="Your Notes,"
heroTitleLine2="organised by AI"
description="A smart note-taking workspace with AI tagging, linking, and search. Coming soon."
statusBadgeText="Notes is under active development"
/>
);
}
Props to always set: heroTitleLine1, heroTitleLine2, description (feature-specific, SEO-friendly), statusBadgeText (present-tense, names the feature).
layout.tsx (route root)
Static metadata only. No data fetching, no children manipulation.
import { createRouteMetadata } from "@/utils/route-metadata";
export const metadata = createRouteMetadata("/notes", {
title: "Notes",
description: "Create, organize, and manage your notes and documents",
});
export default function NotesLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
loading.tsx (route root)
Must mirror the exact dimensions of the shell. Every element has explicit h- and w-. No content-derived sizing. See route-architecture.md for the full skeleton example from the notes route.
Key rules:
- Outer container:
h-full flex overflow-hidden (AppShell .shell-main is already full viewport — do not subtract header height)
- Route chrome:
<PageHeader> — never an in-body faux header bar
- Sidebar:
w-[280px] shrink-0 — must match the real aside exactly
- No spinners —
<Skeleton> components only
[id]/loading.tsx mirrors the content area only (layout persists across navigation)
page.tsx (route root — index, no item selected)
import { Suspense } from "react";
import { getNoteListSeed } from "@/lib/notes/data";
import { NoteListHydrator } from "@/features/notes/route/NoteListHydrator";
import { NotesShell } from "@/features/notes/components/shell/NotesShell";
import NotesLoading from "./loading";
export default async function NotesPage() {
const seeds = await getNoteListSeed();
return (
<>
<NoteListHydrator seeds={seeds} />
<Suspense fallback={<NotesLoading />}>
<NotesShell seeds={seeds}>
<NotesEmptyState />
</NotesShell>
</Suspense>
</>
);
}
function NotesEmptyState() {
(
);
}
[id]/layout.tsx — the most important file
This is where both hydrators live and parallel fetching happens.
import { Suspense } from "react";
import { getNote, getNoteListSeed, preloadNote } from "@/lib/notes/data";
import { createDynamicRouteMetadata } from "@/utils/route-metadata";
import { NoteListHydrator } from "@/features/notes/route/NoteListHydrator";
import { NoteHydrator } from "@/features/notes/route/NoteHydrator";
import { NotesShell } from "@/features/notes/components/shell/NotesShell";
import NotesLoading from "../loading";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const note = await getNote(id);
return createDynamicRouteMetadata("/notes", {
title: note.label,
description: note.content ? note.content.slice(0, ) : ,
});
}
() {
{ id } = params;
(id);
[seeds, note] = .([(), (id)]);
(
);
}
[id]/page.tsx — redirect to default view
import { redirect } from "next/navigation";
export default async function NoteIndexPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
redirect(`/notes/${id}/edit`);
}
View sub-pages ([id]/edit/page.tsx, etc.)
Sub-pages return { title } only — the layout handles all favicon/OG. No data fetching.
import { NoteViewShell } from "@/features/notes/components/shell/NoteViewShell";
import { NoteEditorPlaceholder } from "@/features/notes/components/shell/NoteEditorPlaceholder";
export function generateMetadata() { return { title: "Edit" }; }
export default async function NoteEditPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return (
<NoteViewShell
noteId={id}
mode="edit"
leftPanel={<NoteEditorPlaceholder noteId={id} mode="edit" />}
/>
);
}
Split view passes both panels:
export default async function NoteSplitPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return (
<NoteViewShell
noteId={id}
mode="split"
leftPanel={<NoteEditorPlaceholder noteId={id} mode="edit" />}
rightPanel={<NoteEditorPlaceholder noteId={id} mode="preview" />}
/>
);
}
Phase 4: Redux Hydrators
Both live in features/[feature]/route/. They render null and dispatch synchronously during the first render pass — not in useEffect.
"use client";
import { useRef } from "react";
import { useAppDispatch } from "@/lib/redux/hooks";
import { upsertNoteFromServer } from "../redux/slice";
import type { NoteListItem } from "../types";
export function NoteListHydrator({ seeds }: { seeds: NoteListItem[] }) {
const dispatch = useAppDispatch();
const hydrated = useRef(false);
if (!hydrated.current) {
for (const seed of seeds) {
dispatch(upsertNoteFromServer({ note: seed, fetchStatus: "list" }));
}
hydrated.current = true;
}
return null;
}
"use client";
import { useRef } from "react";
import { useAppDispatch } from "@/lib/redux/hooks";
import { upsertNoteFromServer } from "../redux/slice";
import type { Note } from "../types";
export function NoteHydrator({ note }: { note: Note }) {
const dispatch = useAppDispatch();
const hydrated = useRef(false);
if (!hydrated.current) {
dispatch(upsertNoteFromServer({ note, fetchStatus: "full" }));
hydrated.current = true;
}
return null;
}
Why useRef and NOT useEffect:
useEffect fires after paint — children reading from the store see empty state for one frame and flash. The useRef guard dispatches during the render pass, before any child reads.
Phase 5: Shell Components
All shell components are Server Components. Client Component islands are pushed as deep as possible — only the interactive parts get "use client".
Component tree:
[Feature]Shell (Server) — h-full flex overflow-hidden (+ PageHeader for route chrome)
├── [Feature]Sidebar (Server — w-[NNpx] shrink-0 frame) ← ask Arman for width
│ └── [Feature]SidebarClient (Client — Redux reads, interactions)
└── [Feature]MainArea (Server — flex-1 min-w-0 frame)
├── [Feature]TabBar (Client — open tabs from Redux)
└── {children} per page → [Feature]ViewShell (Server)
├── leftPanel (Client editor island)
└── rightPanel (Client preview island — split mode only)
Key layout rules:
- Outermost:
h-full overflow-hidden — never h-screen, h-page, or calc(100dvh - header) on (core) routes
- Route chrome:
<PageHeader> — see features/shell/components/header/variants/USAGE.md
- Sidebar:
w-[280px] shrink-0 — ask Arman for the exact width
- Main area:
flex-1 min-w-0 overflow-hidden
- Scroll regions:
overflow-y-auto only on the inner list container
- No inline styles unless Arman has explicitly approved (e.g., glass mode testing)
See route-architecture.md for the full NotesShell and NoteViewShell implementations.
Phase 6: Metadata Rules
From app/(a)/_read_first_route_rules/metadata-and-seo.md:
| Location | What to use | Notes |
|---|
layout.tsx (root) | createRouteMetadata("/path", { title, description }) | Provides favicon + OG for entire route |
[id]/layout.tsx | createDynamicRouteMetadata("/path", { title, description }) inside generateMetadata | Provides favicon + OG for item detail |
Sub-page page.tsx | export function generateMetadata() { return { title: "View Name" }; } | Title only — layout already covers favicon/OG |
| New route | Add favicon entry in constants/navigation-links.tsx | Ask Arman for color and letter |
Phase 7: Navigation Entry
Add to constants/navigation-links.tsx if not already present. Ask Arman for all values — icon, href, section, color (Tailwind name + hex), and favicon letter. Never guess these.
Skeleton Rules (Non-Negotiable)
Every loading.tsx must satisfy:
- Identical outer container — same
h-, w-, flex classes as the real shell
- Exact fixed dimensions — every skeleton block has explicit
h- and w- (no content-derived sizing)
- Mirror structure — sidebar skeleton has toolbar row + list items; editor skeleton has title + body lines
- No spinners for page content —
<Skeleton> only (from @/components/ui/skeleton)
[id]/loading.tsx covers only the content area, not the full shell (the layout persists)
Checklist Before Asking Arman to Review
Related Skills — Read These First
These skills contain the rules this workflow is built on. When in doubt, they win:
ssr-zero-layout-shift — .claude/skills/ssr-zero-layout-shift/SKILL.md — server/client component boundaries, Suspense rules, hydration pattern, skeleton design, fixed-dimension containers, CLS prevention (absorbed nextjs-ssr-architecture + nextjs-app-router-expert)
- Shell header + page height —
features/shell/components/header/variants/USAGE.md — <PageHeader>, h-full, when .h-page applies
redux-selector-rules — .claude/skills/redux-selector-rules/SKILL.md — selector patterns, curried selector caching, avoiding re-render loops
- Route rules —
app/(a)/_read_first_route_rules/RULES.md — mandatory, read before every session on this route group
Additional Resources