DEV Community

Launchstack310
Launchstack310

Posted on • Originally published at launchstack.space

How to Structure a Production-Ready Flask SaaS Project (Folder Architecture That Scales)

You started with app.py. All your routes in one file. Models at the top, config hardcoded, Stripe webhook handler jammed between the login route and a TODO comment you wrote at 1AM.

It worked. For a while.

Then you added OAuth. Then a webhook handler that needed access to your User model and your email service. Then an admin route that needed authentication middleware. Then environment-specific config for staging vs production. And suddenly, every change required scrolling through 800 lines to find the right section, and every new feature broke something unrelated.

This isn't a Flask problem. Flask gives you freedom. The problem is that no one shows you what production-ready freedom looks like — especially for a SaaS application where authentication, payments, webhooks, and email are first-class concerns from day one.

This is the exact project structure I use for Flask SaaS backends paired with a Next.js frontend. It has survived auth rewrites, Stripe integration, multi-environment deployment, and real production traffic. It scales to 10,000+ users without becoming a maintenance nightmare.


Why Most Flask SaaS Projects Become Unmaintainable

Flask's minimalism is its greatest strength and its most dangerous trap.

The official Flask tutorial gives you a flat structure: auth.py, blog.py, templates/, static/. That's fine for a blog. It's a disaster for a SaaS.

Here's what actually happens when you build a SaaS with a tutorial-grade structure:

Month 1: Everything lives in 3-4 files. You're moving fast. Routes, models, helpers — all accessible, all in one place.

Month 2: You add Stripe. Now your routes.py has authentication endpoints, payment endpoints, webhook handlers, and user profile routes. It's 600+ lines. You start losing track of which functions depend on what.

Month 3: You need to change how you handle Stripe subscription cancellation. The handler is in routes.py, the database update is inline, and the email notification is called directly from the route. You change one thing, break two others.

The root cause is always the same: business logic mixed with route handlers. When your route function handles HTTP parsing, database queries, payment processing, and email sending — you don't have a web application. You have a script that happens to respond to HTTP requests.

The three architectural mistakes that kill Flask SaaS projects

1. No service layer. Route handlers directly query the database, call Stripe, and send emails. Every route becomes a tangled chain of dependencies that's impossible to test or reuse.

2. Flat file organization. All routes in one file, all models in one file. Works until you have 15 models and 40 routes. Then every git merge becomes a conflict.

3. Config hardcoded or scattered. Database URLs in app.py, Stripe keys in routes.py, CORS origins in __init__.py. Deploying to staging vs production becomes a manual find-and-replace operation.


The Production-Ready Architecture Blueprint

Here's the full structure. Every directory has a specific responsibility. Nothing leaks across boundaries.

backend/
├── app/
│   ├── __init__.py              # Application factory
│   ├── extensions.py            # Flask extensions (db, migrate, mail, cors)
│   │
│   ├── config/
│   │   ├── __init__.py
│   │   ├── base.py              # Shared settings
│   │   ├── development.py       # Dev overrides
│   │   ├── production.py        # Prod settings
│   │   └── testing.py           # Test settings
│   │
│   ├── models/
│   │   ├── __init__.py          # Export all models
│   │   ├── user.py              # User + auth fields
│   │   ├── subscription.py      # Stripe subscription data
│   │   └── mixins.py            # Shared model logic (timestamps, soft delete)
│   │
│   ├── routes/
│   │   ├── __init__.py          # Register all blueprints
│   │   ├── auth.py              # Login, register, refresh, logout
│   │   ├── oauth.py             # Google, GitHub OAuth flows
│   │   ├── payments.py          # Checkout, billing, plan management
│   │   ├── webhooks.py          # Stripe webhook handler
│   │   ├── users.py             # Profile, settings, account
│   │   └── admin.py             # Admin-only endpoints
│   │
│   ├── services/
│   │   ├── __init__.py
│   │   ├── auth_service.py      # Token generation, password hashing
│   │   ├── stripe_service.py    # All Stripe API interactions
│   │   ├── email_service.py     # Transactional emails
│   │   └── user_service.py      # User CRUD, account logic
│   │
│   ├── schemas/
│   │   ├── __init__.py
│   │   ├── auth.py              # Login/register validation
│   │   ├── user.py              # User response shapes
│   │   └── payment.py           # Payment request validation
│   │
│   ├── middleware/
│   │   ├── __init__.py
│   │   ├── auth_middleware.py   # JWT verification decorator
│   │   └── admin_middleware.py  # Admin role check
│   │
│   └── utils/
│       ├── __init__.py
│       ├── responses.py         # Standardized JSON responses
│       ├── errors.py            # Error handlers
│       └── helpers.py           # Date formatting, slugs, etc.
│
├── migrations/                   # Alembic migration files
├── tests/
│   ├── conftest.py              # Test fixtures
│   ├── test_auth.py
│   ├── test_payments.py
│   └── test_webhooks.py
│
├── .env.development
├── .env.production
├── .env.example
├── requirements.txt
├── wsgi.py                       # Production entry point
└── run.py                        # Development entry point
Enter fullscreen mode Exit fullscreen mode

This isn't theoretical. This is the structure running in production behind a real SaaS application.

Layer-by-layer breakdown

app/__init__.py — The application factory

The application factory pattern is non-negotiable for any serious Flask project. It lets you create multiple app instances with different configs — essential for testing.

# app/__init__.py
import os
from flask import Flask
from app.extensions import db, migrate, cors, mail

def create_app(config_name=None):
    app = Flask(__name__)

    # Load config
    if config_name is None:
        config_name = os.environ.get('FLASK_ENV', 'development')

    app.config.from_object(f'app.config.{config_name}.Config')

    # Initialize extensions
    db.init_app(app)
    migrate.init_app(app, db)
    cors.init_app(app, 
        origins=app.config['ALLOWED_ORIGINS'],
        supports_credentials=True
    )
    mail.init_app(app)

    # Register blueprints
    from app.routes import register_blueprints
    register_blueprints(app)

    # Register error handlers
    from app.utils.errors import register_error_handlers
    register_error_handlers(app)

    return app
Enter fullscreen mode Exit fullscreen mode

app/extensions.py — Extension instances

Extensions are instantiated without an app, then initialized in the factory. This prevents circular imports — one of the most common Flask headaches.

# app/extensions.py
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_cors import CORS
from flask_mail import Mail

db = SQLAlchemy()
migrate = Migrate()
cors = CORS()
mail = Mail()
Enter fullscreen mode Exit fullscreen mode

app/config/ — Environment-specific settings

Never use a single config file with if statements. Separate files per environment. Each inherits from a base.

# app/config/base.py
import os

class Config:
    SECRET_KEY = os.environ.get('SECRET_KEY')
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY')

    # Stripe
    STRIPE_SECRET_KEY = os.environ.get('STRIPE_SECRET_KEY')
    STRIPE_WEBHOOK_SECRET = os.environ.get('STRIPE_WEBHOOK_SECRET')

    # Email
    MAIL_SERVER = os.environ.get('MAIL_SERVER')
    MAIL_PORT = int(os.environ.get('MAIL_PORT', 587))

    # CORS
    ALLOWED_ORIGINS = os.environ.get('ALLOWED_ORIGINS', '').split(',')
Enter fullscreen mode Exit fullscreen mode
# app/config/development.py
from app.config.base import Config

class Config(Config):
    DEBUG = True
    SQLALCHEMY_DATABASE_URI = 'sqlite:///dev.db'
    ALLOWED_ORIGINS = ['http://localhost:3000']
Enter fullscreen mode Exit fullscreen mode
# app/config/production.py
import os
from app.config.base import Config

class Config(Config):
    DEBUG = False
    SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')
    ALLOWED_ORIGINS = [
        'https://yourdomain.com',
        'https://www.yourdomain.com'
    ]
Enter fullscreen mode Exit fullscreen mode

The Service Layer — Why It Changes Everything

The service layer is the single most important architectural decision in this structure, and the one most Flask developers skip.

Without a service layer:

# Route handler doing everything
@auth_bp.route('/api/auth/register', methods=['POST'])
def register():
    data = request.get_json()

    # Validation — in the route
    if not data.get('email') or not data.get('password'):
        return jsonify({'error': 'Missing fields'}), 400

    # Business logic — in the route
    existing = User.query.filter_by(email=data['email']).first()
    if existing:
        return jsonify({'error': 'Email taken'}), 409

    # More business logic — still in the route
    user = User(email=data['email'])
    user.set_password(data['password'])
    db.session.add(user)
    db.session.commit()

    # Token generation — also in the route
    tokens = generate_tokens(user.id)

    # Email — you guessed it, in the route
    send_welcome_email(user.email)

    response = make_response(jsonify({...}))
    response.set_cookie('refresh_token', tokens['refresh_token'], ...)
    return response
Enter fullscreen mode Exit fullscreen mode

With a service layer:

# Route handler — only handles HTTP
@auth_bp.route('/api/auth/register', methods=['POST'])
def register():
    data = request.get_json()

    result, error = auth_service.register_user(
        email=data.get('email'),
        password=data.get('password')
    )

    if error:
        return error_response(error.message, error.status_code)

    response = make_response(success_response(result.user_data))
    response.set_cookie('refresh_token', result.refresh_token, ...)
    return response
Enter fullscreen mode Exit fullscreen mode
# app/services/auth_service.py
def register_user(email, password):
    """Pure business logic. No HTTP. No Flask request/response."""

    if not email or not password:
        return None, ServiceError('Missing fields', 400)

    existing = User.query.filter_by(email=email).first()
    if existing:
        return None, ServiceError('Email already registered', 409)

    user = User(email=email)
    user.set_password(password)
    db.session.add(user)
    db.session.commit()

    tokens = generate_tokens(user.id)
    email_service.send_welcome(user.email)

    return AuthResult(user=user, tokens=tokens), None
Enter fullscreen mode Exit fullscreen mode

The difference:

  • Routes handle HTTP concerns only: parsing requests, setting cookies, returning responses.
  • Services handle business logic: validation, database operations, calling other services.
  • Services are testable without HTTP. You can test register_user() without mocking Flask requests.
  • Services are reusable. The same register_user() works whether called from a REST endpoint, a CLI command, or a background job.

Structuring the Stripe service

Stripe integration is where clean architecture pays off the most. Every Stripe interaction goes through one service:

# app/services/stripe_service.py
import stripe
import os

stripe.api_key = os.environ.get('STRIPE_SECRET_KEY')

class StripeService:

    @staticmethod
    def create_checkout_session(user, price_id, success_url, cancel_url):
        """Create a Stripe checkout session."""
        return stripe.checkout.Session.create(
            customer_email=user.email,
            payment_method_types=['card'],
            line_items=[{'price': price_id, 'quantity': 1}],
            mode='subscription',
            success_url=success_url,
            cancel_url=cancel_url,
            metadata={'user_id': str(user.id)}
        )

    @staticmethod
    def cancel_subscription(subscription_id):
        """Cancel a Stripe subscription."""
        return stripe.Subscription.modify(
            subscription_id,
            cancel_at_period_end=True
        )

    @staticmethod
    def verify_webhook(payload, sig_header, webhook_secret):
        """Verify and construct a webhook event."""
        return stripe.Webhook.construct_event(
            payload, sig_header, webhook_secret
        )
Enter fullscreen mode Exit fullscreen mode

Now your webhook route is clean:

# app/routes/webhooks.py
@webhooks_bp.route('/api/webhooks/stripe', methods=['POST'])
def stripe_webhook():
    payload = request.data
    sig_header = request.headers.get('Stripe-Signature')

    try:
        event = stripe_service.verify_webhook(
            payload, sig_header, current_app.config['STRIPE_WEBHOOK_SECRET']
        )
    except Exception:
        return error_response('Invalid signature', 400)

    handlers = {
        'checkout.session.completed': handle_checkout,
        'customer.subscription.deleted': handle_cancellation,
        'invoice.payment_failed': handle_payment_failed,
    }

    handler = handlers.get(event['type'])
    if handler:
        handler(event['data']['object'])

    return success_response({'received': True})
Enter fullscreen mode Exit fullscreen mode

For the full implementation of JWT authentication, Stripe webhook verification, and CORS configuration that goes inside this structure, see the companion guide: Flask JWT Authentication, Stripe Webhooks & CORS: The Complete Python SaaS Guide.


What Most Tutorials Get Wrong

Putting everything in __init__.py

The application factory should create the app and register extensions. It should not contain routes, models, or business logic. If your __init__.py is more than 40 lines, you're doing too much there.

Using a single models.py

One file works until you have User, Subscription, Invoice, PasswordReset, OAuthToken, and AuditLog models. Split models by domain. Import them all in models/__init__.py so Alembic can detect them:

# app/models/__init__.py
from app.models.user import User
from app.models.subscription import Subscription

# Alembic needs all models imported to detect schema changes
__all__ = ['User', 'Subscription']
Enter fullscreen mode Exit fullscreen mode

Skipping the schema layer

Without input validation, your services accept anything and fail unpredictably. Schemas validate incoming data before it touches business logic:

# app/schemas/auth.py
from dataclasses import dataclass

@dataclass
class RegisterInput:
    email: str
    password: str

    def validate(self):
        errors = []
        if not self.email or '@' not in self.email:
            errors.append('Valid email required')
        if not self.password or len(self.password) < 8:
            errors.append('Password must be at least 8 characters')
        return errors
Enter fullscreen mode Exit fullscreen mode

Hardcoding environment values

Every config value that changes between environments belongs in an environment variable. No exceptions. Not the database URL. Not the Stripe key. Not the CORS origins. Not the JWT secret. If it's hardcoded, it will break when you deploy.


Scaling Considerations

Scaling to a team

This structure makes onboarding straightforward. A new developer needs to understand:

  • Routes handle HTTP only
  • Services handle business logic
  • Models handle data
  • Config handles environment

They can work on payments.py without understanding oauth.py. They can modify stripe_service.py without touching any route handler. Merge conflicts drop dramatically because concerns live in separate files.

Scaling to features

Adding a new feature follows a predictable pattern:

  1. Create model in models/
  2. Create service in services/
  3. Create routes in routes/
  4. Add schema in schemas/ if needed
  5. Register blueprint in routes/__init__.py

Every feature follows the same pattern. No guessing where code should live.

Scaling to integrations

SaaS applications accumulate integrations: Stripe, email providers, analytics, background jobs. Each integration gets its own service. The service layer acts as an adapter — if you switch from SendGrid to Resend, you change one file. No routes touched.

Scaling to 10,000+ users

At this scale, the structure itself doesn't change. What changes is infrastructure:

  • Database: Move from SQLite to PostgreSQL (config change only — same SQLAlchemy models)
  • Caching: Add Redis for session storage and frequently accessed data
  • Background jobs: Add Celery for email sending and heavy processing
  • Monitoring: Add structured logging in the service layer

The architecture supports all of this because concerns are already separated. Adding Redis caching to user_service.py doesn't require touching any routes.


Integrating Cleanly with a Next.js Frontend

A Flask SaaS backend paired with Next.js has a specific communication pattern. The structure supports it by design:

All routes return JSON. There are no templates, no server-rendered HTML. The routes/ directory is purely an API layer. Every response follows a consistent shape:

# app/utils/responses.py
def success_response(data, status_code=200):
    return jsonify({
        'status': 'success',
        'data': data
    }), status_code

def error_response(message, status_code=400):
    return jsonify({
        'status': 'error',
        'error': {'message': message}
    }), status_code
Enter fullscreen mode Exit fullscreen mode

CORS is configured once, globally. In extensions.py and initialized in the factory. Not scattered across individual routes.

Authentication flows through middleware. The auth_middleware.py decorator verifies JWT tokens. Routes that need protection simply add the decorator. The frontend sends tokens in the Authorization header. Refresh tokens travel via httpOnly cookies.

Webhooks bypass auth middleware. Stripe webhooks come from Stripe's servers, not from your frontend. The webhooks.py blueprint doesn't use JWT verification — it uses Stripe signature verification instead. Keeping webhooks in a separate blueprint makes this natural.

This clean separation means your Next.js frontend can be developed independently. It talks to a documented JSON API. It doesn't depend on Flask internals, template rendering, or server-side state. You can replace the frontend entirely without touching the backend.


From Architecture to Execution

Good project structure is a force multiplier. It doesn't write your code for you, but it ensures that every line of code you write has a clear place to live, a clear responsibility, and a clear path to scale.

The structure in this guide has been tested across real SaaS applications — from first commit to production deployment to paying users. It handles authentication, payments, webhooks, email, and multi-environment configuration without becoming a maze of interdependent files.

If you're starting a Flask SaaS project today, adopt this structure from the beginning. Migrating a messy codebase into clean architecture is ten times harder than starting clean.

And if you'd rather skip the setup entirely and start with this architecture already built — with JWT authentication, Google and GitHub OAuth, Stripe payments, email system, and admin dashboard all wired together and production-ready — that's exactly what LaunchStack is.

Launching February 24 on Product Hunt. Early bird at $99.

The architecture is the easy part to explain. The hard part is building everything inside it correctly. That's the part that takes weeks.

Top comments (0)