Design decisions
The kit takes two positions: it is AI-native (the rules an agent needs ship with the project) and it uses Next.js as the full stack (the api layer is the backend; there is no second service). Everything below follows from those two. New decisions get an entry here, with the reason, so the next person does not re-derive it.
Server functions: our own transport, not native Server Actions
The api layer looks like server actions — 'use server' files wrapped in
createAction, exported through resolveActions — but at build time the kit
swaps the mechanics (src/lib/server-action/, wired by withServerFn in
next.config.ts):
- A Turbopack loader turns
'use server'intoimport 'server-only', so the functions are never registered as native Next Server Functions. - The domain
index.tsis replaced by a typed facade of function ids. Server Components and RSC resolve thereact-serverexport condition and call the action module directly through a build-time manifest, with no HTTP hop. Client-side code gets a client runtime that POSTs to one internal route handler,POST /api/internal/server-fn/[...slug], one request per method. - The wire codec is plain JSON plus tags for
undefined,Date,bigintand non-finite numbers, so the same codec runs in the browser and the handler and@comwit/stateproxies serialize as they are.
Why not native Server Actions:
- Parallelism. Next runs native action calls from one client in series. Here
each method is an independent POST, so
Promise.all([a.findAll(), a.getStats()])really runs in parallel. - Explicit errors. Only
ActionErrormessages cross to the browser; any other throw is logged and masked by the handler. A lint rule (api-action-error) makes the boundary visible in code. - No closure capture, stable ids. Actions are plain modules, never closures created in a render; ids come from the file path, not an opaque hash, which makes the manifest reviewable.
- One place for transport rules. Same-origin checks, body limits and
Cache-Control: no-storelive in one handler instead of per action. - Server callers stay direct. The service layout awaits
getMe()in RSC without going through HTTP, so hydration does not add a round trip.
What it asks of you: keep 'use server' as the source convention, call the
api only through index.ts, and do not add API-transport-only snapshot() or
deproxy() calls — the codec serializes enumerable values already.
Cache Components with a service worker that respects the same boundary
cacheComponents: true and partialPrefetching separate the static shell
from request-time data. staleTimes.static is a year and staleTimes.dynamic
is zero; the service worker (src/app/sw.ts) caches static assets, static
RSC payloads and complete documents, and never anything marked private or
no-store. One deployment id (NEXT_DEPLOYMENT_ID or the Git SHA) namespaces
every cache; AppVersionGuard polls version.json and reloads stale clients.
Metadata never reads request data, so the shell stays static and social
crawlers get the tags in <head>.
Auth resolves on the server, once, with a null boundary
Each service's route group renders <Suspense fallback={null}> around an
async layout that awaits getMe(); a small client adapter calls
useUser.hydrate(...) before any consumer reads the user. No skeleton, no
anonymous flash, no useEffect bootstrap, and the adapter subscribes to
nothing so a sign-out can never replay the server seed. A lint rule
(auth-hydration-boundary) keeps the adapter clean.
The api layer shapes data for the screen
Domains in the api layer are page-fit, not table-fit. Relative times
(30분 전), formatted dates and display counts are produced in the api with
the shared kst helpers, so components never format, filter, aggregate or
slice. The reason is pagination: a component only has the current page, so
client-side numbers are wrong by construction.
Repository is the only door to data
Every read goes through src/server/repository/, which starts as functions
over normalized in-memory rows. IDs are UUID v7 (time-ordered, safe as a
primary key), deletion is a soft delete, and schema.dbml is written before
rows so relationships are designed rather than discovered. Connecting a
database changes only the function bodies.
Admin lives on a stable internal path
The admin service's route files stay under /admin; the public entry is a
project-specific prefix that src/proxy.ts rewrites, and direct /admin/*
requests return 404. Rewriting in the request proxy rather than
next.config.ts keeps RSC navigation and prefetch from resolving an app
catch-all first. There is no admin sign-up; the first account is created only
on an explicit request through the server-only admin API.
Rules are code
Every rule above that can be checked is an Oxlint plugin in eslint-rules/,
run by pnpm run validate. The .ai.md files state what the structure
cannot; the lint rules reject what the .ai.md files forbid. Agents and
people get the same feedback, before review.