ajo_agent
Get started
Back to blog
productopen sourceMay 8, 20262 min read

Why we open-sourced our AI SaaS template

Most AI side-projects die in week three when the founder realizes they need to wire up Stripe, sessions, and a dozen other unsexy things. So we built the template we wished we had.

KW

Kacper Włodarczyk

Maintainer

The first AI app I shipped took eleven days to build. The next eleven days were spent on auth, billing, and the magical dance between session cookies and a websocket I couldn't stop disconnecting.

That ratio — eleven days of product to eleven days of plumbing — is the average AI side-project's cause of death. By week three, you're knee-deep in webhook signatures and you've forgotten why you were excited.

So we built a template.

TL;DR: a cookiecutter project that gives you a FastAPI + Next.js app with auth, billing, RAG, AI agents, organizations, and observability already wired. MIT, runs on your infra, zero markup.

What's actually in it

The hard parts come included:

  • Auth — JWT access + refresh tokens, password hashing, optional OAuth, magic links.
  • Billing — Stripe checkout, portal, subscriptions, credits, usage metering.
  • Teams — organizations, invitations, role-based access control.
  • AI agents — pick your poison (PydanticAI, LangChain, LangGraph, CrewAI, DeepAgents).
  • RAG — Milvus / Qdrant / Chroma / pgvector, with sync sources for Drive and S3.
  • Observability — structured logging, request IDs, Logfire / LangSmith depending on agent.

What's not included is opinion-shopping. We picked the boring choices. You can swap them out, but the defaults work.

The dial we kept turning

The hardest single decision was where to draw the abstraction line. Too thin and you're shipping a starter without value; too thick and the template traps you in our taste.

We landed on the repository → service → route layering, with thin domains as flat modules and thick ones (billing, RAG, channels) as sub-packages. Routes never know about repos. Services raise domain exceptions, never return error codes. It's not novel — but it's consistent.

# Routes never call repos directly.
@router.get("/{user_id}", response_model=UserRead)
async def get_user(user_id: UUID, service: UserSvc, user: CurrentUser) -> Any:
    return await service.get_by_id(user_id)

What happens next

The template ships every two weeks. Each release: a small, real upgrade — not a marketing burst. We dogfood it on every project we run, which keeps the surface area honest.

If you're building an AI SaaS, fork it. If you find a wart, send a PR. If you ship something cool on top of it, we'll boost it.

end of postBack to blog