Building My First Flask App: A Next.js Developer‘s Perspective

8 min read
PythonFlask

Table of Contents

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__.py
4│ └── main.py # Main Flask application
5├── .env # Environment variables
6├── Makefile # Task automation
7├── pyproject.toml # Dependencies
8└── 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, request
2from dotenv import load_dotenv
3import os
4
5# Load environment variables (like Next.js does automatically)
6load_dotenv()
7
8# Create Flask app
9app = Flask(__name__)
10
11# Get port from environment with default
12port = int(os.getenv("PORT", 3000))
13
14@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 }), 200
24
25@app.route("/health", methods=["GET"])
26def health():
27 return jsonify({
28 "status": "healthy",
29 "service": "slack-bot",
30 "version": "0.1.0"
31 }), 200
32
33@app.route("/echo", methods=["POST"])
34def echo():
35 data = request.get_json()
36 return jsonify({
37 "received": data,
38 "message": "Echo successful! 📡"
39 }), 200
40
41if __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.ts
2export async function GET(request: Request) {
3 return Response.json(
4 {
5 message: 'Hello from Next.js!',
6 },
7 { status: 200 },
8 )
9}
10
11export 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.py
2@app.route("/hello", methods=["GET"])
3def get_hello():
4 return jsonify({"message": "Hello from Flask!"}), 200
5
6@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.local
2const token = process.env.SLACK_TOKEN

Flask:

1from dotenv import load_dotenv
2import os
3
4load_dotenv() # Must explicitly load
5token = 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: dev
2
3dev:
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=development
2PORT=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 endpoint
2curl http://localhost:3000/
3
4# Test health check
5curl http://localhost:3000/health
6
7# Test POST endpoint
8curl -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": 123
5 },
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 jsonify
2
3# ✅ Correct - sets Content-Type: application/json
4return jsonify({"key": "value"}), 200
5
6# ❌ Don't do this - returns plain string
7return 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"})
4
5@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"}), 400
5
6 # Fetch user logic here
7 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"}), 404
4
5@app.errorhandler(500)
6def internal_error(error):
7 return jsonify({"error": "Internal server error"}), 500

Side-by-Side Feature Comparison

FeatureNext.jsFlask
Route DefinitionFile-based (app/api/route.ts)Decorator-based (@app.route())
JSON ResponseResponse.json(data)jsonify(data)
Request Bodyawait request.json()request.get_json()
Query ParamssearchParams.get('q')request.args.get('q')
Status Code{status: 200}return data, 200
Auto-reloadBuilt-in (next dev)app.run(debug=True)
EnvironmentAuto-loadedNeed python-dotenv
Port Configpackage.json or -p 3000app.run(port=3000)

Common Mistakes I Made

1. Forgetting to Return Status Code

1# ❌ Wrong - defaults to 200
2@app.route("/error")
3def error():
4 return jsonify({"error": "Something went wrong"})
5
6# ✅ Correct - explicitly set 500
7@app.route("/error")
8def error():
9 return jsonify({"error": "Something went wrong"}), 500

2. Using app.run() in Production

1# ❌ Never do this in production
2if __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 gunicorn
2uv run gunicorn app.main:app --bind 0.0.0.0:8080

3. Not Checking Request Method

1# ❌ Allows any HTTP method
2@app.route("/data")
3def handle_data():
4 data = request.get_json() # Breaks on GET requests
5 return jsonify(data)
6
7# ✅ Explicitly specify methods
8@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 check
2
3dev:
4 uv run python app/main.py
5
6format:
7 uv run black .
8
9lint:
10 uv run flake8 app/
11
12test:
13 uv run pytest
14
15check: format lint test
16 @echo "✅ All checks passed!"

Usage:

1make dev # Start development server
2make format # Format code with black
3make lint # Lint code with flake8
4make test # Run tests with pytest
5make check # Run all quality checks

What I Liked About Flask

  1. Simplicity - No magic. Just decorators and functions.
  2. Explicit - You control everything. No hidden conventions.
  3. Lightweight - Minimal boilerplate to get started.
  4. Flexible - Structure your app however you want.

What I Missed from Next.js

  1. File-based routing - Flask requires manual route registration
  2. Auto environment loading - Need python-dotenv in Flask
  3. TypeScript - Python's type hints aren't as robust (though mypy helps)
  4. 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:

  1. Connect to Slack API using Slack Bolt SDK
  2. Integrate with Google Cloud's Vertex AI
  3. Deploy to Cloud Run
  4. 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.

Related Articles