Keel

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 /app shell, and the /admin panel.
  • 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.ts holds the RBAC helpers.
  • components/, plain shadcn/ui primitives under components/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 under app/app/, the admin panel under app/admin/, auth under app/auth/, and all mutations under app/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 server page.tsx renders a "use client" orchestrator from _components/.
  • Dynamic segments, [teamId], [id], [token], and the catch-all [...slug] (used by app/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.

Edit this page on GitHub →Last updated Jul 22, 2026