Intro
After setting up my Python development environment, it was time to build something. I needed a Flask app that would eventually become a Slack bot, but first: the classic "Hello World" REST API.
Coming from Next.js, I had strong opinions about how API routes should work. Turns out, Flask has its own elegant approach that's surprisingly straightforward.
Here's what I learned building my first Flask application, with comparisons to Next.js throughout.
Project Structure
First, I set up a basic Flask project structure:
1slack-bot/2├── app/3│ ├── __init__.py4│ └── main.py # Main Flask application5├── .env # Environment variables6├── Makefile # Task automation7├── pyproject.toml # Dependencies8└── uv.lock # Lock file
Nothing fancy. Flask doesn't enforce a specific structure like Next.js does with its app/
or pages/
directory.
Creating the Flask App
Here's the complete app/main.py
:
1from flask import Flask, jsonify, request2from dotenv import load_dotenv3import os45# Load environment variables (like Next.js does automatically)6load_dotenv()78# Create Flask app9app = Flask(__name__)1011# Get port from environment with default12port = int(os.getenv("PORT", 3000))1314@app.route("/", methods=["GET"])15def hello():16 return jsonify({17 "message": "Hello from Flask! 👋",18 "endpoints": {19 "/": "This hello message",20 "/health": "Health check",21 "/echo": "Echo endpoint (POST)"22 }23 }), 2002425@app.route("/health", methods=["GET"])26def health():27 return jsonify({28 "status": "healthy",29 "service": "slack-bot",30 "version": "0.1.0"31 }), 2003233@app.route("/echo", methods=["POST"])34def echo():35 data = request.get_json()36 return jsonify({37 "received": data,38 "message": "Echo successful! 📡"39 }), 2004041if __name__ == "__main__":42 # Debug mode enables auto-reload (like next dev)43 debug_mode = os.getenv("FLASK_ENV") == "development"44 app.run(host="0.0.0.0", port=port, debug=debug_mode)
Flask vs Next.js: Route Handlers
Let's compare how routes work in both frameworks.
Next.js API Route (App Router)
1// app/api/hello/route.ts2export async function GET(request: Request) {3 return Response.json(4 {5 message: 'Hello from Next.js!',6 },7 { status: 200 },8 )9}1011export async function POST(request: Request) {12 const body = await request.json()13 return Response.json(14 {15 received: body,16 },17 { status: 200 },18 )19}
Flask Equivalent
1# app/main.py2@app.route("/hello", methods=["GET"])3def get_hello():4 return jsonify({"message": "Hello from Flask!"}), 20056@app.route("/hello", methods=["POST"])7def post_hello():8 data = request.get_json()9 return jsonify({"received": data}), 200
Key Differences:
- Next.js: One file per route, exports named functions (
GET
,POST
) - Flask: All routes in one place, uses decorators (
@app.route
) - Next.js: Status code in
Response.json()
options - Flask: Status code as second return value (tuple unpacking)
Both approaches work well. Flask's decorator pattern felt cleaner for simple APIs, while Next.js's file-based routing scales better for large applications.
Handling Request Data
Getting JSON from Request Body
Next.js:
1const body = await request.json()2const name = body.name
Flask:
1data = request.get_json()2name = data.get("name")
Flask's request.get_json()
is simpler - no async/await needed.
Getting Query Parameters
Next.js:
1const { searchParams } = new URL(request.url)2const query = searchParams.get('q')
Flask:
1query = request.args.get('q')
Flask wins here. Way more straightforward.
Environment Variables
Next.js:
1// Automatically loaded from .env.local2const token = process.env.SLACK_TOKEN
Flask:
1from dotenv import load_dotenv2import os34load_dotenv() # Must explicitly load5token = os.getenv("SLACK_TOKEN")
Next.js handles this automatically. In Flask, you need python-dotenv
:
1uv add python-dotenv
Running the Development Server
The Manual Way
1uv run python app/main.py
This works, but you have to remember the path. I automated it with a Makefile:
1.PHONY: dev23dev:4 uv run python app/main.py
Now just:
1make dev
Auto-Reload in Development
Flask has a built-in development mode with auto-reload (like next dev
):
.env:
1FLASK_ENV=development2PORT=3000
In code:
1debug_mode = os.getenv("FLASK_ENV") == "development"2app.run(debug=debug_mode)
Now when you save a file, Flask automatically reloads - just like Next.js.
Important: Never use debug=True
in production. It exposes sensitive information and has security implications.
Testing the Endpoints
Using curl
1# Test root endpoint2curl http://localhost:3000/34# Test health check5curl http://localhost:3000/health67# Test POST endpoint8curl -X POST http://localhost:3000/echo \9 -H "Content-Type: application/json" \10 -d '{"test": "hello", "number": 123}'
Expected Responses
GET /
1{2 "message": "Hello from Flask! 👋",3 "endpoints": {4 "/": "This hello message",5 "/health": "Health check",6 "/echo": "Echo endpoint (POST)"7 }8}
GET /health
1{2 "status": "healthy",3 "service": "slack-bot",4 "version": "0.1.0"5}
POST /echo
1{2 "received": {3 "test": "hello",4 "number": 1235 },6 "message": "Echo successful! 📡"7}
Common Patterns I Learned
1. Returning JSON Responses
Flask's jsonify()
automatically sets the correct Content-Type
header:
1from flask import jsonify23# ✅ Correct - sets Content-Type: application/json4return jsonify({"key": "value"}), 20056# ❌ Don't do this - returns plain string7return str({"key": "value"}), 200
2. Multiple HTTP Methods on Same Route
You can handle multiple methods in one function:
1@app.route("/data", methods=["GET", "POST", "PUT"])2def handle_data():3 if request.method == "GET":4 return jsonify({"action": "fetching"})5 elif request.method == "POST":6 return jsonify({"action": "creating"})7 elif request.method == "PUT":8 return jsonify({"action": "updating"})
But I prefer separate functions for clarity:
1@app.route("/data", methods=["GET"])2def get_data():3 return jsonify({"action": "fetching"})45@app.route("/data", methods=["POST"])6def create_data():7 return jsonify({"action": "creating"})
3. Error Handling
1@app.route("/user/<user_id>", methods=["GET"])2def get_user(user_id):3 if not user_id.isdigit():4 return jsonify({"error": "Invalid user ID"}), 40056 # Fetch user logic here7 return jsonify({"user_id": user_id}), 200
You can also register global error handlers:
1@app.errorhandler(404)2def not_found(error):3 return jsonify({"error": "Not found"}), 40445@app.errorhandler(500)6def internal_error(error):7 return jsonify({"error": "Internal server error"}), 500
Side-by-Side Feature Comparison
Feature | Next.js | Flask |
---|---|---|
Route Definition | File-based (app/api/route.ts ) | Decorator-based (@app.route() ) |
JSON Response | Response.json(data) | jsonify(data) |
Request Body | await request.json() | request.get_json() |
Query Params | searchParams.get('q') | request.args.get('q') |
Status Code | {status: 200} | return data, 200 |
Auto-reload | Built-in (next dev ) | app.run(debug=True) |
Environment | Auto-loaded | Need python-dotenv |
Port Config | package.json or -p 3000 | app.run(port=3000) |
Common Mistakes I Made
1. Forgetting to Return Status Code
1# ❌ Wrong - defaults to 2002@app.route("/error")3def error():4 return jsonify({"error": "Something went wrong"})56# ✅ Correct - explicitly set 5007@app.route("/error")8def error():9 return jsonify({"error": "Something went wrong"}), 500
2. Using app.run()
in Production
1# ❌ Never do this in production2if __name__ == "__main__":3 app.run(debug=True)
Flask's built-in server is for development only. In production, use a proper WSGI server like Gunicorn:
1uv add gunicorn2uv run gunicorn app.main:app --bind 0.0.0.0:8080
3. Not Checking Request Method
1# ❌ Allows any HTTP method2@app.route("/data")3def handle_data():4 data = request.get_json() # Breaks on GET requests5 return jsonify(data)67# ✅ Explicitly specify methods8@app.route("/data", methods=["POST"])9def handle_data():10 data = request.get_json()11 return jsonify(data)
Complete Makefile
Here's my full Makefile for Flask development:
1.PHONY: dev format lint test check23dev:4 uv run python app/main.py56format:7 uv run black .89lint:10 uv run flake8 app/1112test:13 uv run pytest1415check: format lint test16 @echo "✅ All checks passed!"
Usage:
1make dev # Start development server2make format # Format code with black3make lint # Lint code with flake84make test # Run tests with pytest5make check # Run all quality checks
What I Liked About Flask
- Simplicity - No magic. Just decorators and functions.
- Explicit - You control everything. No hidden conventions.
- Lightweight - Minimal boilerplate to get started.
- Flexible - Structure your app however you want.
What I Missed from Next.js
- File-based routing - Flask requires manual route registration
- Auto environment loading - Need
python-dotenv
in Flask - TypeScript - Python's type hints aren't as robust (though mypy helps)
- Built-in caching - Next.js has this; Flask needs extensions
Next Steps
Now that I have a basic Flask app running, the next steps are:
- Connect to Slack API using Slack Bolt SDK
- Integrate with Google Cloud's Vertex AI
- Deploy to Cloud Run
- Set up proper testing with pytest
Wrapping Up
Building my first Flask app as a Next.js developer was surprisingly smooth. The concepts translate well - routes, JSON responses, environment variables - they all work similarly.
Flask's decorator-based routing felt weird at first, but after building a few endpoints, I appreciated its simplicity. No file structure to remember, no export naming conventions - just functions with @app.route()
decorators.
If you're a TypeScript/Next.js developer dipping into Python, Flask is a great starting point. It's simple, well-documented, and the ecosystem is mature.
Pro Tips
Use jsonify()
instead of manually setting headers. It handles serialization and Content-Type for you.
Always specify HTTP methods in @app.route()
. Being explicit prevents bugs where endpoints accept unintended methods.
Create a health check endpoint. Cloud platforms (like Cloud Run, EKS, etc.) need it for liveness/readiness probes.
Use environment variables for configuration. Never hardcode ports, tokens, or API keys.
Don't use debug=True
in production. It exposes sensitive info and has security implications. Use a proper WSGI server like Gunicorn instead.