Full-stack Go/Gin + React 19 monorepo with email/password auth (Authula), multi-tenant schema-per-tenant isolation on Neon Postgres, and single-binary deploy.
Built for developers who want a clean, production-ready starting point with multi-tenancy out of the box.
| Layer | Technology |
|---|---|
| Backend | Go + Gin |
| Auth | Authula (email/password + Bearer tokens) |
| Database | Neon Postgres (schema-per-tenant) |
| ORM | Bun |
| Frontend | React 19 + TypeScript (React Compiler) |
| Build | Vite 8 |
| CSS | Tailwind CSS v4 |
| Components | shadcn/ui |
| State | Zustand |
.
├── Makefile # dev, build, install, clean commands
├── CLAUDE.md # AI assistant project instructions
├── server/ # Go/Gin backend (API + auth + embedded frontend)
│ ├── CLAUDE.md # Go-specific guidelines
│ ├── cmd/server/
│ │ ├── main.go # Entry point — wires DB, config, migrations, router
│ │ └── static/ # Embedded frontend (populated by make build)
│ ├── internal/
│ │ ├── config/config.go # Postgres connection + Authula config
│ │ ├── handler/
│ │ │ ├── health.go # GET /api/health, /api/message
│ │ │ ├── user.go # GET /api/me (protected)
│ │ │ ├── org.go # Organization CRUD + member management
│ │ │ ├── tenant.go # Tenant-scoped endpoints
│ │ │ ├── response.go # JSON response envelope helpers
│ │ │ └── static.go # SPA routing for embedded frontend
│ │ ├── middleware/
│ │ │ ├── cors.go # CORS middleware
│ │ │ ├── auth.go # Bearer token auth middleware
│ │ │ └── tenant.go # Tenant context + search_path isolation
│ │ ├── model/
│ │ │ ├── organization.go # Organization model
│ │ │ └── organization_member.go # Membership + roles
│ │ ├── migrate/
│ │ │ ├── public.go # Public schema migrations (users, orgs)
│ │ │ └── tenant.go # Per-tenant schema migrations
│ │ └── router/router.go # Mounts middleware, auth, API, org, tenant routes
│ ├── go.mod / go.sum
│ └── .env.example
└── web/ # React 19 + TypeScript frontend
├── CLAUDE.md # Frontend-specific guidelines
├── package.json
├── vite.config.ts # Vite + Tailwind + API proxy to :8081
├── components.json # shadcn/ui configuration
└── src/
├── App.tsx # React Router + AuthLoader
├── main.tsx # App entry point
├── index.css # Tailwind CSS v4 styles
├── store/
│ ├── auth.ts # Auth state: signIn, signUp, signOut, user
│ └── org.ts # Org state: organizations, currentOrg, createOrg
├── components/
│ ├── ProtectedRoute.tsx # Redirects to /login if unauthenticated
│ ├── OrgGuard.tsx # Redirects to /orgs if no org selected
│ ├── OrgSwitcher.tsx # Org selection dropdown
│ ├── CreateOrgDialog.tsx # Dialog for creating new organizations
│ └── ui/ # shadcn/ui components (button, card, dialog, etc.)
├── pages/
│ ├── LoginPage.tsx # Email/password login
│ ├── SignupPage.tsx # Account registration
│ ├── HomePage.tsx # Main dashboard (org-scoped)
│ ├── OrgsPage.tsx # Organization list + creation
│ └── OrgSettingsPage.tsx # Organization settings (owner/admin)
├── lib/
│ ├── api.ts # Typed fetch wrapper + tenantApi with X-Org-Slug
│ ├── types.ts # Shared TypeScript types
│ ├── query.ts # React Query configuration
│ ├── i18n.ts # Internationalization setup
│ └── utils.ts # cn() utility
└── locales/
└── en.json # English translations
- Go 1.22+ (install)
- Node.js 20+ (install)
- Make (pre-installed on macOS/Linux)
- Neon Postgres database (sign up)
# Clone
git clone https://github.com/calebeaires/gin-react-monorepo.git
cd gin-react-monorepo
# Install dependencies
make install
# Configure environment
cp server/.env.example server/.env
# Edit server/.env — set AUTHULA_SECRET and DATABASE_URL
# Run both servers
make devServer runs on http://localhost:8081, web on http://localhost:5173.
Open http://localhost:5173 — create an account at /signup, then create an organization to get started.
In development, two processes run independently:
- Go server on
:8081— serves/api/*and/auth/* - Vite dev server on
:5173— serves the React app with hot reload - Vite proxies
/apiand/authrequests to:8081(configured inweb/vite.config.ts) - Everything is same-origin, so Bearer tokens work without CORS issues
In production (make build):
- Vite builds the React app to static files
- Static files are copied into
server/cmd/server/static/ - Go embeds them via
//go:embed static/* - The single binary serves API + frontend on one port
handler/static.gohandles SPA routing (unknown paths ->index.html)
No Nginx, no separate static file server. One binary, one process, one port.
The app uses schema-per-tenant isolation on Neon Postgres:
- Public schema holds shared data: Authula tables (users, sessions),
organizations,organization_members - Tenant schemas (
tenant_<slug>) hold per-org business data, isolated viaSET LOCAL search_path - Users are global — one account can belong to many organizations
- Each request to
/api/t/*requires anX-Org-Slugheader
Request → CORS → Auth Middleware → Tenant Middleware → Handler → Response
(Bearer token) (X-Org-Slug) (tenant_tx)
- CORS middleware allows
AuthorizationandX-Org-Slugheaders - Auth middleware extracts Bearer token, queries Authula's
sessionstable directly, setsuser_idin context - Tenant middleware reads
X-Org-Slug, verifies membership, begins transaction withSET LOCAL search_path TO tenant_<slug>, public - Handler uses
tenant_txfrom context for all DB queries - Tenant middleware commits or rolls back the transaction
- Create:
POST /api/orgscreates the org record +tenant_<slug>schema in one transaction - Delete:
DELETE /api/orgs/:slugdrops the schema + deletes the org record (owner only) - Members: Owners and admins can add/remove members and change roles
| Role | Permissions |
|---|---|
owner |
Full control — manage members, change roles, delete org |
admin |
Manage members, update org name |
member |
Access tenant-scoped data |
Sign Up/In → Authula returns token → Stored in Zustand + localStorage
│
Authorization: Bearer <token> on every API call
│
Auth middleware → sessions table lookup → user_id in context
- Web calls
POST /auth/sign-uporPOST /auth/sign-in - Authula validates credentials, creates a session, returns
{ user, session: { token } } - Frontend stores
session.tokenin Zustand +localStorage(session_tokenkey) - All API calls include
Authorization: Bearer <token>header (auto-injected byapi()wrapper) - Auth middleware queries Authula's
sessionstable directly via shared DB — no HTTP calls AuthLoaderfetchesGET /api/meon page load to restore user + orgsProtectedRouteredirects to/loginif no token;OrgGuardredirects to/orgsif no org selected
API response envelope: All /api/* endpoints return { data, error, message }. The api() wrapper auto-unwraps data. Auth endpoints (/auth/*) use Authula's own format — handled by authFetch() separately.
| Method | Path | Description |
|---|---|---|
GET |
/api/health |
Health check |
GET |
/api/message |
Test message |
| Method | Path | Description |
|---|---|---|
POST |
/auth/sign-up |
Create account |
POST |
/auth/sign-in |
Login |
POST |
/auth/sign-out |
Logout |
| Method | Path | Description |
|---|---|---|
GET |
/api/me |
Current user + organizations |
POST |
/api/orgs |
Create organization |
GET |
/api/orgs |
List user's organizations |
GET |
/api/orgs/:slug |
Org details + members |
PATCH |
/api/orgs/:slug |
Update org name (owner/admin) |
DELETE |
/api/orgs/:slug |
Delete org + schema (owner) |
POST |
/api/orgs/:slug/members |
Add member (owner/admin) |
DELETE |
/api/orgs/:slug/members/:userId |
Remove member (owner/admin) |
PATCH |
/api/orgs/:slug/members/:userId |
Change member role (owner) |
| Method | Path | Description |
|---|---|---|
GET |
/api/t/status |
Tenant context info |
Set in server/.env (copied from .env.example):
| Variable | Required | Default |
|---|---|---|
AUTHULA_SECRET |
Yes | — |
DATABASE_URL |
Yes | — |
AUTHULA_BASE_URL |
No | http://localhost:8081 |
PORT |
No | 8081 |
make dev # Run server + web in parallel
make dev-server # Server only (port 8081)
make dev-web # Web only (port 5173)
make install # Install all dependencies (Go + Node)
make build # Build single binary with embedded frontend → dist/server
make clean # Remove build artifactsWhy Gin? Minimal, fast, and the most popular Go web framework. No magic — just handlers and middleware.
Why internal/? Go convention — code inside internal/ can't be imported by external projects. Keeps the API surface clean.
Why Authula? Pluggable auth that lives in your codebase. No SaaS dependency, no vendor lock-in. Swap email/password for OAuth or TOTP by adding a plugin.
Why Neon Postgres? Serverless Postgres with branching for dev/staging. Schema-per-tenant isolation gives each organization its own namespace without managing separate databases.
Why schema-per-tenant? Strongest isolation without the operational overhead of database-per-tenant. Each org's data lives in tenant_<slug> and is accessed via SET LOCAL search_path within a transaction — no cross-tenant data leakage.
Why Bearer tokens over cookies? Simpler for multi-tenant APIs where the frontend needs to send both auth and org context headers. Works consistently across same-origin and cross-origin setups.
Why Vite proxy? The web app proxies /api and /auth to the server in development. This eliminates CORS issues because everything is same-origin.
Why shadcn/ui? Components are copied into your project, not installed as a dependency. You own the code. Customize anything without fighting a library.
Why single binary? Go embeds the built React app. Deploy one file, run one process. No reverse proxy, no static file server, no container orchestration needed for simple deployments.
- New API endpoint: Create handler in
server/internal/handler/, register inrouter.go - New tenant-scoped endpoint: Add to
/api/t/group inrouter.go(gets auth + tenant middleware automatically) - New tenant table: Add
CREATE TABLEtomigrate/tenant.go, usetenant_txfrom Gin context in handlers - New page: Create in
web/src/pages/, add route inApp.tsx - New UI component:
cd web && npx shadcn@latest add <component> - Protected route: Wrap with
<ProtectedRoute>inApp.tsx - Org-required route: Wrap with
<OrgGuard>inside<ProtectedRoute>
Here's a complete walkthrough of adding a tenant-scoped feature — from database to UI.
Step 1 — Model (server/internal/model/project.go):
type Project struct {
bun.BaseModel `bun:"table:projects"`
ID uuid.UUID `json:"id" bun:"id,pk,type:uuid,default:gen_random_uuid()"`
Name string `json:"name" bun:"name,notnull"`
CreatedAt time.Time `json:"created_at" bun:"created_at,nullzero,notnull,default:current_timestamp"`
}Step 2 — Migration (add to server/internal/migrate/tenant.go):
// Inside CreateTenantSchema(), after creating the schema:
_, err = db.NewCreateTable().
ModelTableExpr(fmt.Sprintf("%s.projects", pq.QuoteIdentifier(schemaName))).
Model((*model.Project)(nil)).
IfNotExists().
Exec(ctx)Step 3 — Handler (server/internal/handler/project.go):
func ListProjects() gin.HandlerFunc {
return func(c *gin.Context) {
tx := c.MustGet("tenant_tx").(bun.Tx)
var projects []model.Project
if err := tx.NewSelect().Model(&projects).Scan(c.Request.Context()); err != nil {
ErrorResponse(c, http.StatusInternalServerError, "Failed to list projects")
return
}
SuccessResponse(c, http.StatusOK, projects)
}
}Step 4 — Route (add to tenant group in server/internal/router/router.go):
tenant.GET("/projects", handler.ListProjects())Step 5 — Frontend (web/src/pages/ProjectsPage.tsx):
export default function ProjectsPage() {
const { data: projects } = useQuery({
queryKey: ['projects'],
queryFn: () => tenantApi<Project[]>('/t/projects'),
})
return <div>{projects?.map(p => <div key={p.id}>{p.name}</div>)}</div>
}Step 6 — Route (add to web/src/App.tsx inside <OrgGuard>):
<Route path="/projects" element={<ProjectsPage />} />That's it — your feature is tenant-isolated, authenticated, and wired end-to-end.
| Guide | Description |
|---|---|
| Deployment | Build, deploy (VPS, Docker, Fly.io), production checklist |
| Database | Schema layout, tenant isolation, adding tables |
| Troubleshooting | Common issues, debugging tips, manual API testing |
| Contributing | Development workflow, code style, PR guidelines |
See CONTRIBUTING.md for development workflow, code style, and PR guidelines.