Architecture Modes
Double Architecture: Web + API
A streamlined Turborepo monorepo with two applications: a public-facing web app and a Go API. Admin features live inside the web app as role-protected routes instead of a separate application.
Overview
Double architecture strips away the dedicated admin panel and gives you a two-app monorepo. The Go API handles all backend logic (authentication, CRUD, file uploads, jobs), and a single frontend serves both regular users and administrators. Admin functionality is implemented through role-protected routes within the web app — users with the ADMIN role see additional navigation items and have access to management pages.
# Double with TanStack Router (recommended)grit new myapp --double --vite# Double with Next.jsgrit new myapp --double --next
Best suited for
- - Projects where admins and users share the same interface with extra privileges
- - Blogs, portfolios, and content sites where the author manages content inline
- - Simpler SaaS applications where admin features are minimal (user management, settings)
- - MERN/MEAN stack developers transitioning to Go who want a familiar two-app structure
- - Teams that want fewer apps to deploy and maintain
Key differences from Triple
- - No apps/admin/ directory — the admin panel does not exist as a separate application
- - No admin resource definitions — no
defineResource(), no DataTable/FormBuilder auto-generation - - Admin features in web app — role-protected routes (e.g.,
/admin/users) guarded byRequireRole("ADMIN") - - Fewer generated files —
grit generatecreates Go files + shared types + web hooks, but no admin page or resource definition - - Simpler deployment — 2 apps instead of 3, fewer Docker images, less infrastructure
Complete Folder Structure
This is the full tree generated by grit new myapp --double --vite. Compare it with the triple structure — the apps/admin/ directory is absent, and the web app gains role-protected admin routes.
myapp/├── .env # Shared environment variables├── .env.example # Template for other developers├── .gitignore├── .prettierrc # Code formatting├── .prettierignore├── docker-compose.yml # Dev: PostgreSQL, Redis, MinIO, Mailhog├── docker-compose.prod.yml # Production deployment├── grit.json # Project manifest (architecture: "double")├── turbo.json # Turborepo pipeline config├── pnpm-workspace.yaml # pnpm workspace definition├── package.json # Root scripts (dev, build, lint)├── .claude/│ └── skills/grit/│ ├── SKILL.md # AI assistant guide (tailored to double)│ └── reference.md # Detailed API conventions├── packages/│ └── shared/ # Shared between API and web│ ├── package.json│ ├── schemas/ # Zod validation schemas│ │ ├── index.ts # // grit:schemas marker│ │ └── user.ts # User schema (register, login, update)│ ├── types/ # TypeScript interfaces│ │ ├── index.ts # // grit:types marker│ │ └── user.ts # User type│ └── constants/ # Shared constants│ └── index.ts # API_ROUTES, ROLES // grit:api-routes├── apps/│ ├── api/ # Go backend (identical to triple)│ │ ├── Dockerfile # Multi-stage build (golang → alpine)│ │ ├── go.mod # Go module: myapp/apps/api│ │ ├── go.sum│ │ ├── cmd/│ │ │ ├── server/main.go # Entry point: config, db, services, router│ │ │ ├── migrate/main.go # Migration runner│ │ │ └── seed/main.go # Database seeder│ │ └── internal/│ │ ├── config/config.go # Loads .env, all env vars│ │ ├── database/db.go # GORM connection + AutoMigrate│ │ ├── models/ # GORM models│ │ │ ├── user.go # User model // grit:models marker│ │ │ └── upload.go # Upload model│ │ ├── handlers/ # HTTP handlers (thin — call services)│ │ │ ├── auth.go # Register, login, refresh, me│ │ │ ├── user.go # User CRUD│ │ │ ├── upload.go # File upload (presigned URLs)│ │ │ └── ai.go # AI completions + streaming│ │ ├── services/ # Business logic│ │ │ ├── auth_service.go # JWT, bcrypt, token generation│ │ │ └── user_service.go # User queries│ │ ├── middleware/ # Gin middleware│ │ │ ├── auth.go # JWT verification│ │ │ ├── cors.go # CORS configuration│ │ │ ├── logger.go # Structured logging│ │ │ ├── cache.go # Redis response caching│ │ │ ├── maintenance.go # grit down/up support│ │ │ └── rate_limit.go # Sentinel rate limiting│ │ ├── routes/│ │ │ └── routes.go # Route registration // grit:handlers, grit:routes:*│ │ ├── mail/ # Email service (Resend)│ │ │ ├── mailer.go # Send function│ │ │ └── templates/ # HTML email templates│ │ ├── storage/ # S3-compatible file storage│ │ ├── jobs/ # Background jobs (asynq)│ │ ├── cron/ # Scheduled tasks│ │ ├── cache/ # Redis cache service│ │ ├── ai/ # AI service (Vercel AI Gateway)│ │ └── auth/ # TOTP 2FA service│ │ └── totp.go # Setup, verify, backup codes, trusted devices│ └── web/ # Frontend (public + admin routes)│ ├── Dockerfile # Next.js standalone or Vite static build│ ├── package.json # Dependencies + scripts│ ├── vite.config.ts # (or next.config.js for --next)│ ├── tailwind.config.ts│ └── src/ # TanStack Router (or app/ for Next.js)│ ├── routes/│ │ ├── __root.tsx # Root layout│ │ ├── index.tsx # Landing page│ │ ├── (auth)/ # Auth pages (login, register)│ │ ├── (app)/ # Protected user pages│ │ └── (admin)/ # Role-protected admin pages│ │ ├── _layout.tsx # Admin layout (checks ADMIN role)│ │ ├── users/ # User management│ │ └── settings/ # System settings│ ├── hooks/ # React Query hooks│ ├── components/ # Shared components│ └── lib/ # Utilities, API client└── packages/└── grit-ui/ # 100 shadcn-compatible components├── registry.json└── registry/ # Per-component JSON + TSX
Directory-by-Directory Breakdown
Root Files
The root configuration is nearly identical to triple. The key difference is in grit.json which stores "architecture": "double", telling the CLI to skip admin-specific file generation.
| File | Purpose |
|---|---|
| .env | Shared environment variables: DB connection, JWT secret, Redis URL, S3 credentials, Resend API key |
| grit.json | Project manifest. Architecture is set to double — the CLI uses this to determine which files to generate |
| turbo.json | Turborepo pipelines: dev, build, lint, test. Simpler dependency graph with only 2 apps |
| docker-compose.yml | Development infrastructure: PostgreSQL 16, Redis 7, MinIO, Mailhog |
| docker-compose.prod.yml | Production deployment with 2 app containers (API + web) instead of 3 |
packages/shared/
Identical to the triple architecture. Contains Zod schemas, TypeScript types, and shared constants. The only difference is that there is one consumer (web) instead of two (web + admin). The marker comments and injection system work the same way.
| Directory | Contents |
|---|---|
| schemas/ | Zod validation schemas per resource. Re-exported from index.ts via the // grit:schemas marker. |
| types/ | TypeScript interfaces per resource. Re-exported via // grit:types marker. |
| constants/ | API route constants and role enums. Injected via // grit:api-routes marker. |
apps/api/ (Go Backend)
The Go API is identical to the triple architecture. It uses the same handler-service-model pattern, the same middleware stack, the same batteries (cache, storage, email, jobs, cron, AI). The only difference is that the CORS middleware is configured to allow a single frontend origin instead of two.
| Directory | Role |
|---|---|
| cmd/server/ | Entry point: loads config, connects to DB, starts Gin on :8080 |
| cmd/migrate/ | Standalone migration runner via GORM AutoMigrate |
| cmd/seed/ | Database seeder with admin user and sample data |
| internal/config/ | Typed config struct loaded from .env |
| internal/database/ | GORM connection: PostgreSQL (production) or SQLite (dev/test) |
| internal/models/ | GORM model definitions with struct tags. // grit:models marker for injection. |
| internal/handlers/ | Thin Gin handlers: parse input, call service, return JSON |
| internal/services/ | Business logic: CRUD with pagination, filtering, error handling |
| internal/middleware/ | JWT auth, CORS, logger, cache, rate limiter, maintenance mode |
| internal/routes/ | Centralized route registration with marker comments |
| internal/mail/ | Resend email service with HTML templates |
| internal/storage/ | S3-compatible file storage with presigned URLs |
| internal/jobs/ | Background job processing with asynq + Redis |
| internal/cron/ | Scheduled tasks with cron expressions |
| internal/cache/ | Redis cache service with TTL |
| internal/ai/ | Claude + OpenAI with streaming |
| internal/auth/ | TOTP 2FA: setup, verify, backup codes, trusted devices |
apps/web/ (Public + Admin Frontend)
In double architecture, the web app serves double duty. Public pages are accessible to everyone, while admin routes are protected by role checks. The navigation dynamically shows admin links (e.g., "Users", "Settings") only when the logged-in user has the ADMIN role.
| Path | Description |
|---|---|
| src/routes/__root.tsx | Root layout: providers, fonts, global styles (TanStack Router). For Next.js: app/layout.tsx |
| src/routes/index.tsx | Landing page visible to all visitors |
| src/routes/(auth)/ | Auth route group: login, register, forgot-password |
| src/routes/(app)/ | Protected user pages: dashboard, profile, resource pages |
| src/routes/(admin)/ | Role-protected admin routes. The layout component checks the user role and redirects non-admins. Contains user management, system settings, and other admin-only pages. |
| src/hooks/ | React Query hooks for data fetching (generated by grit generate) |
| src/lib/ | Utilities: API client (Axios/fetch wrapper), auth helpers, formatters |
How role-protected admin routes work
// The admin layout checks the user's role before rendering childrenimport { useAuth } from '@/hooks/use-auth'import { Navigate, Outlet } from '@tanstack/react-router'export default function AdminLayout() {const { user } = useAuth()// Redirect non-admin usersif (user?.role !== 'ADMIN') {return <Navigate to="/" />}return (<div className="flex">{/* Admin sidebar with management links */}<AdminSidebar /><main className="flex-1 p-6"><Outlet /></main></div>)}
The backend also enforces role checks via the RequireRole("ADMIN") middleware on admin API routes, so the frontend check is for UX only — not a security boundary.
Data Flow
The double architecture has a simpler data flow with a single frontend talking to the API. Admin and user requests follow the same path — the only difference is the role attached to the JWT token.
┌─────────────────────────────────────────────────────────┐│ BROWSER ││ ││ ┌────────────────────────────────────────────────────┐ ││ │ Web App (:3000) │ ││ │ ┌──────────────┐ ┌──────────────────────────┐ │ ││ │ │ Public Pages │ │ Admin Pages (role-gated) │ │ ││ │ │ /, /login, │ │ /admin/users, │ │ ││ │ │ /dashboard │ │ /admin/settings │ │ ││ │ └──────┬───────┘ └────────────┬─────────────┘ │ ││ └─────────┼────────────────────────┼─────────────────┘ │└────────────┼────────────────────────┼───────────────────┘│ │REST + JWT REST + JWT (ADMIN role)│ │▼ ▼┌─────────────────────────────────────────────────────────┐│ GO API (:8080) ││ ││ Request → Gin Router ││ → Middleware Stack ││ ├── CORS (allow web:3000) ││ ├── Logger ││ ├── Rate Limiter (Sentinel) ││ ├── Auth (JWT verification) ││ ├── RequireRole("ADMIN") ← admin routes only ││ └── Cache (Redis) ││ → Handler → Service → GORM → PostgreSQL ││ ││ Background: asynq workers, cron, GORM Studio │└─────────────────────────────────────────────────────────┘│ │ │ │▼ ▼ ▼ ▼PostgreSQL Redis MinIO Resend(data) (cache (files) (email)+ jobs)
Step-by-step: Admin managing users
- Admin navigates to
/admin/usersin the web app - The admin layout component verifies the user's role is ADMIN (frontend guard)
- React Query hook calls
GET /api/v3/admin/userswith the JWT token - Gin router matches the admin route group and applies
RequireRole("ADMIN")middleware (backend guard) - Handler calls
userService.GetAll()and returns the paginated user list - The web app renders the user table — built with your own components (not the auto-generated DataTable from triple)
How Code Generation Works
In double architecture, grit generate creates fewer files compared to triple because there is no admin panel to scaffold into. The Go backend files and shared types are identical — the difference is the absence of admin resource definitions and admin pages.
grit generate resource Product --fields "name:string, price:float, description:text, category_id:belongs_to, is_active:bool"
Files Created
Go Backend (apps/api/)
- -
internal/models/product.go— GORM model with struct tags, belongs_to relationship - -
internal/services/product_service.go— CRUD operations with pagination - -
internal/handlers/product.go— Gin handlers: Create, GetAll, GetByID, Update, Delete
Shared Package (packages/shared/)
- -
schemas/product.ts— Zod schemas:CreateProductSchema,UpdateProductSchema - -
types/product.ts— TypeScript interface:Product
Web App (apps/web/)
- -
hooks/use-products.ts— React Query hooks:useProducts(),useProduct(id),useCreateProduct(),useUpdateProduct(),useDeleteProduct()
NOT generated (compared to triple)
- -
resources/products.ts— No admin resource definition - -
app/(dashboard)/products/page.tsx— No auto-generated admin page with DataTable and FormBuilder
To manage products as an admin in double architecture, you build the management UI yourself inside the src/routes/(admin)/ route group, using the generated React Query hooks and shared types.
Marker Injections
Double uses 5 marker injections instead of 6 (no admin resource marker).
| File | Marker | What Gets Injected |
|---|---|---|
| routes/routes.go | // grit:handlers | Handler initialization |
| routes/routes.go | // grit:routes:* | Route group registration |
| schemas/index.ts | // grit:schemas | Schema re-export |
| types/index.ts | // grit:types | Type re-export |
| constants/index.ts | // grit:api-routes | API route constant |
Ports & URLs
With one fewer frontend, the port layout is simpler. No port 3001 is occupied.
| Service | Port | URL | Notes |
|---|---|---|---|
| Go API | 8080 | http://localhost:8080 | REST endpoints + GORM Studio at /studio |
| Web App | 3000 | http://localhost:3000 | Next.js or Vite (:5173) — serves both public and admin UI |
| PostgreSQL | 5432 | localhost:5432 | Docker container |
| Redis | 6379 | localhost:6379 | Cache + job queue |
| MinIO API | 9000 | http://localhost:9000 | S3-compatible API |
| MinIO Console | 9001 | http://localhost:9001 | Web UI for managing buckets |
| Mailhog SMTP | 1025 | localhost:1025 | Catches outgoing email |
| Mailhog UI | 8025 | http://localhost:8025 | View caught emails in browser |
Deployment Options
Deploying double is simpler than triple — two containers instead of three, one frontend domain instead of two.
Docker Compose (Self-hosted)
The production Docker Compose file builds the Go API and the web app as optimized images. With only 2 app containers, resource usage is lower and the configuration is simpler.
# Build and deploydocker compose -f docker-compose.prod.yml up -d --build
grit deploy
Works the same as triple — grit deploy reads the grit.json manifest to determine which apps to build and deploy. With double architecture, it skips the admin container entirely.
Hybrid: Vercel + VPS
Deploy the web app (Next.js) to Vercel and the Go API to a VPS. Simpler than triple because you only have one Vercel project to configure instead of two. SetNEXT_PUBLIC_API_URL in Vercel to point to your API server.
Static Deploy (Vite only)
If you chose --vite, the web app builds to static files that can be served from any CDN (Cloudflare Pages, Netlify, S3 + CloudFront). The Go API is the only server-side component. This is the most cost-effective deployment option.
When to Choose Double
Double is the right choice when you want the power of a monorepo with shared types but don't need a separate admin application.
Choose Double when...
- - Admins and users share the same UI with role-based visibility
- - Your admin features are simple (user list, settings page)
- - You are building a blog, portfolio, or personal SaaS
- - You want fewer apps to deploy and maintain
- - You prefer building admin UI manually with your own components
- - You are familiar with MERN/MEAN stack structure (API + SPA)
Consider Triple instead when...
- - You need DataTable + FormBuilder auto-generated admin pages
- - Non-technical admins need a polished, dedicated dashboard
- - You want
grit generateto create admin pages automatically - - Your admin operations are complex (multi-step forms, widgets, analytics)
- - Separate teams work on the public site vs admin panel
Upgrading from Double to Triple
If your project outgrows double architecture, you can upgrade to triple later. Rungrit upgrade --triple to scaffold the admin app into your existing project. Your existing Go API, shared types, and web app remain untouched — the command only adds the apps/admin/ directory and updates grit.json.
Example Project
The same Job Portal application built with double architecture. The web app includes role-protected admin routes for managing job listings, companies, and applications. Compare it with the triple example to see the structural differences.
Job Portal — Double + TanStack Router
Full source code with README, .env template, step-by-step guide, and production Docker Compose.