Docs / Getting Started / Project structure
Project structure
Keel is a standard App Router project, organized by domain rather than by file type. Here's what lives where and, since it comes up a lot, which routes are static and which render per request.
Top-level layout
app/, every route: the marketing pages, the auth flow, the authenticated/appshell, and the/adminpanel.lib/, the non-UI core.lib/db/schema/is the Drizzle schema split by domain (team, billing, credits, feature-flags).lib/dal/is server-only data access, one gate per resource type.lib/billing/is the Stripe/LemonSqueezy/Polar abstraction.lib/permissions.tsholds the RBAC helpers.components/, plain shadcn/ui primitives undercomponents/ui/, plus marketing, content, and settings components. These are portable React, no App Router dependency.content/, the MDX for blog, docs, and legal pages (this file included).memory/, context for the next AI session that touches the repo. Read it before an architectural change, see Logging decisions.
Folder conventions inside app/
- Route groups organize
app/by area: the marketing pages sit at the root (app/page.tsx,app/pricing/page.tsx), the dashboard underapp/app/, the admin panel underapp/admin/, auth underapp/auth/, and all mutations underapp/api/. _components/, a folder prefixed with an underscore is a private folder, Next.js never routes to it. Keel uses one next to almost every page for that page's local client components.app/app/settings/team/and its_components/are the reference shape: a thin serverpage.tsxrenders a"use client"orchestrator from_components/.- Dynamic segments,
[teamId],[id],[token], and the catch-all[...slug](used byapp/docs/[...slug]/page.tsx) capture the URL part as a param.
Static vs. dynamic rendering
Which routes are prerendered at build and which run per request follows directly from what each page reads, no config needed.
Static (prerendered at build)
The marketing pages are fully static. app/page.tsx and app/pricing/page.tsx are plain components that read no cookies, headers, or searchParams, so Next prerenders them to HTML once at build.
The blog and docs are static too, generated per slug. app/blog/[slug]/page.tsx and app/docs/[...slug]/page.tsx each export generateStaticParams() (returning every post slug and every doc slug), so every content page is baked at build time from the MDX in content/.
Dynamic (rendered per request)
Everything under app/app/ and app/admin/ is dynamic, because it renders the signed-in user. The gate is getSession() in lib/dal/teams.ts, which calls auth.api.getSession({ headers: await headers() }). Reading headers() opts a route out of static rendering, so any page down that path (the dashboard at app/app/page.tsx, the settings pages, the admin user list) is rendered fresh per request. This is correct: a page showing one user's teams and KPIs can't be a shared prebuilt file.
Why headers() forces dynamic
headers() is a request-time API, its value only exists once a real request arrives. Any component that awaits it (directly or through getSession()) can't be prerendered, so Next renders the whole route on demand.
The API routes under app/api/**/route.ts are always request-time only. A route handler runs when it's called, it has no build-time output, so webhooks, checkout, and every mutation are dynamic by nature.