DEV Community

Cover image for I Built a SaaS with AdonisJS 7, and I Loved It
Maxime Sahroui
Maxime Sahroui

Posted on

I Built a SaaS with AdonisJS 7, and I Loved It

This is Part 1 of a 6-part series where I open up the real engineering decisions behind my latest SaaS. No "10x" claims, no hot takes. Just the choices, why I made them, and what I'd tell a past version of myself.

The one-sentence version

I needed to ship a small, focused SaaS on my own, keep the whole thing type-safe end to end, host data in the EU, and not spend my weekends gluing a backend API to a separate frontend app. So I reached for AdonisJS 7 + Inertia + Vue 3.

If you've never seen Adonis in the wild, or you've only ever reached for Next.js / Nuxt / a decoupled SPA, this article is the gentle tour I wish I'd had.

Let's start with the question that actually matters.

Why not just build a decoupled SPA?

This is the default these days: a backend exposing a JSON API, and a separate frontend SPA (or a Next.js app) consuming it. It's a great architecture for a team, or for a product with multiple clients (web, mobile, third parties).

But look at my actual constraints as a solo maker:

  • One web client. No mobile app, no public API consumers on day one.
  • I'm the whole team. Every boundary I introduce is a boundary I have to maintain.
  • I want to move fast without lying to myself about types. A hand-written API contract between two repos is a place where types quietly drift apart.

A decoupled SPA asks me to pay the "integration tax" up front (CORS, auth token plumbing, a shared types package, two deploy pipelines) for flexibility I don't need yet. That's the trap: paying for optionality you may never exercise.

So the real question became: is there a way to get a modern Vue frontend without standing up a separate API and re-deriving my types by hand?

That's exactly the problem Inertia solves.

Inertia in one paragraph (for people who've never used it)

Inertia is the glue between a classic server-side framework and a modern client-side framework. Your server controllers don't return JSON for a separate app to fetch. They return pages. You write something like inertia.render('waitlists/index', { waitlists }), and Inertia ships the right Vue component plus its props to the browser. The first load is server-rendered HTML; every navigation after that is a lightweight XHR that swaps props and re-renders the Vue component. No full page reload, no separate REST layer to design.

The mental model: you keep your monolith's routing and controllers, but your views are Vue components instead of server-rendered templates. No API to invent, no client-side router to wire up, no token dance.

// app/controllers/waitlists_controller.ts (illustrative)
export default class WaitlistsController {
  async index({ inertia, auth }: HttpContext) {
    const waitlists = await Waitlist.query()
      .where('ownerId', auth.user!.id)
      .orderBy('createdAt', 'desc')

    // No JSON endpoint. We hand Vue its props directly.
    return inertia.render('waitlists/index', {
      waitlists: WaitlistTransformer.collection(waitlists),
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

If you're coming from Laravel, this will feel like home, since Inertia grew up in that world. If you're coming from Next.js, think of it as "server components, but the boundary is the controller and the component, not a network fetch you have to manage."

So why AdonisJS 7 specifically?

AdonisJS 7 shipped in early 2026, and its headline feature is end-to-end type safety. I'll get to that, because it's the piece that makes the Inertia-on-a-monolith story click for someone who cares about types. But if I'm honest, the thing that won me over first wasn't a v7 feature at all. It was the attitude.

1. Opinionated on purpose, and it feels like a fresh wind

Most of the JavaScript ecosystem hands you a box of parts and wishes you luck. Pick a router. Pick a validator. Pick an ORM, a mailer, a folder structure, a way to wire up auth. Every one of those choices is an evening you don't spend building your actual product.

Adonis just made the decisions for me. There's a place for controllers, a place for services, a place for validators, and a first-party tool for almost everything you'd otherwise go shopping for: validation, auth, social login, sessions, an ORM, a mailer. Do I know every one of those tools inside out? No. Does that matter? Also no. The documentation is genuinely crystal clear about the way to do each thing, so I read the page, I use the tool, and I move on. No bikeshedding, no comparison spreadsheets, no "well, it depends." Just: here's how we build, now go build.

That sounds like a small thing. It is not. As a solo maker, the decisions I don't have to make are worth more to me than most of the features I get. And there's a 2026 bonus: an opinionated, convention-driven codebase is exactly what AI coding assistants understand best. Less structure to explain, fewer house rules to teach. The conventions do the talking.

2. Type-safe Inertia props via Transformers

In v7, the data you hand to a page isn't just "some object." You describe its shape with a Transformer, and Adonis generates .d.ts files at build time so your Vue components get typed props without you re-declaring them on the frontend. The serialization layer and the consumption layer agree by construction, not by discipline.

// app/transformers/waitlist_transformer.ts (illustrative)
export default class WaitlistTransformer {
  static transform(waitlist: Waitlist) {
    return {
      id: waitlist.id,
      name: waitlist.name,
      slug: waitlist.slug,
      subscriberCount: waitlist.subscriberCount,
      createdAt: waitlist.createdAt.toISO(),
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

That object's shape flows straight into the Vue page's props. Rename a field here, and TypeScript lights up in the .vue file. That feedback loop is the whole reason a one-person team can refactor fearlessly.

3. A type-safe URL builder (no more stringly-typed routes)

v7 replaces the old untyped router.makeUrl with a generated, type-checked URL helper. For a solo dev this is quietly huge: rename a route, and every dead link surfaces at compile time instead of in a bug report from a user.

One honest caveat before we move on: v7 requires Node.js 24+. It leans into native platform APIs (it even dropped dotenv in favor of Node's built-in env parsing). If you're pinned to an older Node, factor that into your decision.

The project map

Here's how the codebase is actually laid out. If you've used Adonis before, none of this will surprise you, and that predictability is the point.

app/
  controllers/      # Thin, CRUDdy. One verb in, one render/redirect out.
  services/         # Where the real logic lives (retention, audit, signup).
  transformers/     # The typed contract between server data and Vue props.
  validators/       # Vine schemas, request validation at the edge.
  models/           # Lucid ORM models.
inertia/
  pages/            # Vue 3 components, one per "page" the server renders.
  layouts/
config/
  inertia.ts        # SSR + Inertia adapter config.
docs/
  adr/              # Architecture Decision Records. (ADR-0001 = this stack.)
Enter fullscreen mode Exit fullscreen mode

The rhythm I follow per feature:

  1. Validate the request with a Vine schema.
  2. Delegate to a service, so controllers stay thin.
  3. Transform the result into a typed shape.
  4. Render the Vue page with those props.

Controllers stay boring. Services hold the interesting decisions. Transformers keep the frontend honest. That separation is what lets me come back to a feature three months later and still understand it.

Selective SSR: marketing pages vs. the app

One detail worth calling out, because it's a common Inertia question. I server-render the marketing pages (landing, pricing, blog) for SEO and fast first paint, while the authenticated dashboard behaves like a snappy SPA after the first load. Inertia lets me make that choice per-route rather than committing the entire app to one rendering mode. SEO where it earns its keep; app-like responsiveness everywhere else.

Was it the right call?

For this project (a focused, single-client, EU-first SaaS built by one person), yes, emphatically. I got a modern Vue 3 frontend, kept one repository and one deploy, and the type system catches my mistakes before my users do.

The honest summary is almost boring: I picked an opinionated framework so I could stop deciding and start shipping, and a rendering bridge so I could have a modern frontend without a second codebase to babysit. For a one-person product, boring is exactly what you want.

What's next in this series

In Part 2, I'll go deep on why I reached for the Shadow DOM to drop an embedded signup form onto someone else's landing page without my CSS and theirs declaring war on each other, and the alternatives I rejected (iframe, web components, postMessage bridges).

If you're building something with Adonis 7 + Inertia, I'd love to hear what tripped you up, so drop a comment below.


The running case study in this series is followtheduck.app, the EU-hosted waitlist tool I build with this exact stack. If keeping your signups in the EU instead of a US spreadsheet sounds like your kind of thing, come take a look.

Top comments (0)