Mengseang.
← Blog/flutter-nestjs-mobile-backend
Flutter + NestJS: Shipping a Mobile App with a Full-Stack Backend
SoftwareSaaS2025-06-28·7 min

Flutter + NestJS: Shipping a Mobile App with a Full-Stack Backend

How I built MR Jobs with Flutter on the frontend and NestJS on the backend. Architecture choices, state management, and the CI/CD pipeline I run single-handedly.

MR Jobs is a mobile app where companies register and post job openings and candidates browse, filter, and apply. I lead the development and run the entire CI/CD pipeline myself. The stack is Flutter for the client and NestJS with PostgreSQL for the backend. This is the architecture I settled on after the first month of iteration, and why.

Why Flutter over native

The app needs to ship on Android first, with iOS as a fast follow. Writing native for both means two codebases, two teams, and two release cycles. We have one developer. Flutter gives us a single codebase that compiles to both platforms with performance close enough to native that our users do not notice. The widget system is composable in a way that React developers will find familiar, which flattened my learning curve.

State management without a library

I started with BLoC, then tried Riverpod, then ripped both out. For an app this size, ChangeNotifier with a small number of controller classes is enough. The temptation with state management libraries is to model every piece of state as a stream or a provider. Most app state is simple: a list of jobs, a filter, a saved-jobs set. A controller that holds that state and calls notifyListeners when it changes is 20 lines of code and zero dependencies.

State management libraries solve problems you have at scale. If you are not at scale, they solve problems you do not have and create problems you do.

NestJS as the backend contract

NestJS gives me modules, controllers, and DTOs with class-validator. The DTOs are the contract. A request that does not match the DTO is rejected before it reaches the service layer. This means the Flutter client and the backend agree on shapes, and a misnamed field is a 400, not a silent null in the database. I generate the Flutter model classes from the NestJS DTOs with a script, so the two sides cannot drift.

typescript
@Post("jobs")
@UseGuards(JwtAuthGuard, CompanyGuard)
async createJob(
  @Body() dto: CreateJobDto,
  @Req() req: AuthedRequest,
): Promise<Job> {
  return this.jobsService.create({ ...dto, companyId: req.user.companyId });
}

CI/CD as a one-person operation

The pipeline runs on every push to main. Lint, typecheck, unit tests, build the Flutter app for Android, build the NestJS image, push both to the registry, and deploy the backend to the server. The app goes to the internal test track on Google Play. The whole thing takes 9 minutes. The key to running CI/CD alone is making it boring. No clever scripts, no manual gates, no special-case branches. One path, one button, one outcome.

  • Lint and typecheck fail the build on the first error.
  • Unit tests run on every push, integration tests on PRs to main.
  • Backend deploys as a Docker image to the server.
  • Android build goes to the internal test track for review.
  • Every deploy posts a summary to the team channel.
End