Build a Portfolio Website with Flask

Build a multi-page portfolio website with Python, Flask, and Jinja2 templates.

Introduction

30 Second Summary

Every developer needs a portfolio. It is the thing hiring managers look at before your resume, and the thing that proves you can actually build something. The problem is, most people use a drag-and-drop website builder and end up with something that looks like everyone else's.

In this project, you will build a personal portfolio website from scratch using Python and Flask. You will create multiple pages, style them with CSS, and render project data dynamically from Python code.

What You'll Build

When you finish, you will open your browser and see a polished multi-page website with your name, skills, and project showcase. You built the whole thing with Python.

By the end of this project, you'll have:

  • A Home page with your name, a professional headline, and skill tags that tell visitors what you do at a glance.
  • A Projects page that renders project cards straight from a Python list of dictionaries. Add a new project to your code, refresh the browser, and watch it appear instantly.
  • A shared navigation bar and consistent styling across every page, built with Jinja2 template inheritance so you never repeat layout code.
  • Secret Mission: a working contact form that processes submissions on the backend and displays success or error messages.

Are there any prerequisites?

You should be comfortable with basic Python concepts like variables, functions, and strings. You will also need Python and VS Code installed on your machine, but Step 1 walks you through connecting them and setting everything up.

Before We Start

Before you write any code, take a moment to lock in what you're building and why it matters. Your portfolio is how employers see you before they meet you.

Set Up Your Python Development Environment

You have Python and VS Code installed on your machine, but they do not know about each other yet. Without a proper development environment, you cannot run or debug Python code from your editor.

In this step, you will connect VS Code to Python by installing an extension, create a dedicated project folder, and set up an isolated environment for your portfolio's dependencies.

In this step, get ready to:
  • Install the Python extension in VS Code and verify your Python version.
  • Create and open a project folder called portfolio.
  • Set up a virtual environment and install Flask.
Install the Python extension in VS Code

The Python extension gives VS Code superpowers for Python development. It adds syntax highlighting, code completion, and the ability to run Python files directly from the editor.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Open the Extensions view by pressing Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows).
  • Type Python in the search box.
  • Find the extension named Python by Microsoft (it will be the top result).
  • Click Install.

The Install button changes to a gear icon once the extension finishes installing.

Now open the integrated terminal to confirm VS Code can reach your Python installation.

  • Press Ctrl+` (the backtick key, next to the 1 key) to open the integrated terminal.
  • Check your Python version by running this command:

macOS

python3 --version

Windows

python --version

✔️ I see version 3.9 or higher

You are all set. Your Python installation is ready for Flask.

ⓧ I see a version lower than 3.9

Flask 3.1.3 requires Python 3.9 or higher. Visit python.org/downloads to download the latest Python installer for your operating system.

After installing, close and reopen VS Code, then run the version check again.

ⓧ Command not found

On macOS, try python3 --version instead. On Windows, your Python installation may not be on the system PATH.

Reinstall Python from python.org/downloads and check the box labelled Add Python to PATH during installation. Then restart VS Code and try again.

Create your project folder and virtual environment

A virtual environment is an isolated space where your project's packages live. It prevents your portfolio's dependencies from mixing with other Python projects on your machine.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder by running this command:
mkdir portfolio
  • Confirm the folder was created by running:
ls

You should see portfolio listed among your Desktop items.

  • Move into the new folder by running:
cd portfolio

Now open this folder in VS Code so the editor knows where your project lives.

  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the portfolio folder you just created.
  • Click Open (macOS) or Select Folder (Windows).

VS Code reloads with portfolio shown in the Explorer sidebar on the left. You now have a dedicated workspace.

  • Open a fresh integrated terminal by pressing Ctrl+`.
  • Create the virtual environment by running this command:

macOS

python3 -m venv .venv

Windows

python -m venv .venv

What does this command do?

The -m venv flag tells Python to run its built-in virtual environment module. .venv is the folder name where the isolated environment is stored. The dot prefix keeps it hidden from casual directory listings.

  • Activate the virtual environment by running this command:

macOS

source .venv/bin/activate

Windows

.venv\Scripts\activate

Your terminal prompt now shows (.venv) at the beginning. This confirms the virtual environment is active and any packages you install will stay inside it.

Don't see (.venv) in your prompt?

On Windows, if you see an error about execution policies, run this command first and then try activating again:

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

On macOS, make sure you typed source before the path. Without it, the shell does not know how to run the activation script.

Help me troubleshoot this

Install Flask

Flask is the web framework you will use to build your portfolio. It turns your Python code into a website that responds to browser requests.

  • Install Flask into your virtual environment by running this command:
pip install Flask==3.1.3

You will see download and installation progress in the terminal. When it finishes, you are back at the prompt.

Why pin the version?

The ==3.1.3 part locks Flask to a specific version. This ensures your project behaves exactly the same way as this tutorial, regardless of when you run it.

Before you run the next command, what do you think it will print if Flask installed correctly?

  • Verify Flask is installed by running this command:
python -c "import flask; print(flask.__version__)"

The terminal prints 3.1.3. This confirms Flask is installed and ready to use inside your virtual environment.

Seeing ModuleNotFoundError?

Check that your terminal still shows (.venv) at the start of the prompt. If it does not, activate the virtual environment again and re-run the install command.

If you are on macOS and used python3 earlier, try python3 -c "import flask; print(flask.__version__)" instead. Inside an activated virtual environment, python should work on both platforms.

Help me fix this error

Your development environment is ready. You have VS Code connected to Python, a clean project folder, and Flask installed in its own isolated space. Next up, you will write your first Flask route and see it respond in the browser.

Create Your First Route and See It Live

Your development environment is locked in. Flask is installed, your virtual environment is active, and VS Code knows how to run Python.

The fastest way to understand how a web framework works is to see it respond to you. One route, one HTML string, one page load. That's all it takes to confirm the entire pipeline from Python to browser is working.

In this step, get ready to:
  • Create app.py with a Flask route that displays your name.
  • Run the app and view it live in your browser.
  • Add a second route for your About page.
Create and run your Flask app

Flask apps start with a single Python file. You import Flask, create an app instance, and tell it which URLs to respond to.

  • In VS Code's Explorer sidebar, right-click inside the portfolio folder and select New File.
  • Name the file app.py and press Enter.
  • Paste the following code into app.py:
from flask import Flask

# Create the Flask application instance
app = Flask(__name__)

# Define the home page route
@app.route('/')
def home():
    return '<h1>Hi, I am [[YOURNAME="Your Name"]]</h1>'

# Run the app when this file is executed directly
if __name__ == '__main__':
    app.run(debug=True)

What does this code do?

  • app = Flask(__name__) creates a new Flask application. The __name__ variable tells Flask where to find your project files.
  • @app.route('/') connects the URL path / (your site's homepage) to the home() function below it.
  • The function returns an HTML string that Flask sends straight to the browser.
  • app.run(debug=True) starts a local web server. The debug=True flag makes Flask auto-reload whenever you save a change.
  • Save app.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Run the app by entering this command in your integrated terminal:
python app.py

What happens when you run this?

This command starts a local web server that takes over your terminal. You will see output including a line that says Running on http://127.0.0.1:5000. The terminal will stay busy serving your app until you stop it with Ctrl+C.

  • Open your web browser and visit http://127.0.0.1:5000/ by pasting it into the address bar.

You should see your name displayed as a large heading on a blank white page.

Don't see your name in the browser?

  • Check that your terminal shows Running on http://127.0.0.1:5000. If you see an error instead, make sure your virtual environment is still active (you should see (.venv) at the start of your terminal prompt).
  • Make sure you saved app.py before running the command. Unsaved changes won't be picked up.
  • Confirm the URL in your browser is exactly http://127.0.0.1:5000/ with the trailing slash.

Still stuck? Help me troubleshoot why my Flask app isn't showing in the browser.

Add a second route

A website needs more than one page. Flask lets you add new pages by defining more routes, each connected to its own Python function.

  • In app.py, find the line that says # Run the app when this file is executed directly.
  • Add the following code above that comment, with one blank line separating it from the home() function:
# Define the about page route
@app.route('/about')
def about():
    return '<h1>About Me</h1><p>This is my about page.</p>'

What does this code do?

This creates a second route at /about. When someone visits http://127.0.0.1:5000/about, Flask calls the about() function and sends back the HTML string it returns.

Each route is independent. You can have as many as you want, each with its own URL path and function.

  • Save app.py.

Because you set debug=True, Flask detects the file change and auto-reloads. You do not need to restart the server.

Before you check the browser, what do you expect to see at http://127.0.0.1:5000/about?

  • Visit http://127.0.0.1:5000/about in your browser.

You should see a heading that says About Me with a paragraph below it. In your terminal, you'll also see log lines showing both the / and /about requests Flask handled.

Seeing a 404 Not Found error?

  • Make sure the URL is http://127.0.0.1:5000/about with no typos. The path must match your @app.route('/about') decorator exactly.
  • Check your terminal for error messages. If Flask failed to reload, press Ctrl+C to stop the server, then run python app.py again.
  • Confirm that the about() function sits above the if __name__ block, not inside it.

Still stuck? Help me figure out why my /about route returns a 404 in Flask.

✔️ Awesome, I've got everything!

Great. Make sure app.py is saved and both routes respond in the browser.

ⓧ I'd like to double check the full code

Here is the complete app.py at this point:

from flask import Flask

app = Flask(__name__)

@app.route('/')
def home():
    return '<h1>Hi, I am [[YOURNAME="Your Name"]]</h1>'

@app.route('/about')
def about():
    return '<h1>About Me</h1><p>This is my about page.</p>'

if __name__ == '__main__':
    app.run(debug=True)

You have a running web app with two pages, built entirely in Python. Next up, you'll move those raw HTML strings into proper template files so your pages can share a layout and grow without getting messy.

Move to Proper HTML Templates

Right now, your routes return raw HTML strings directly from Python. That works for a single heading, but imagine writing an entire page with navigation, styling, and content all crammed into a Python string.

Flask's template system (Jinja2) solves this by letting you write real HTML files and inject Python data into them. This is how professional web apps separate logic from presentation.

In this step, get ready to:
  • Create a base template with a shared navigation bar using Jinja2 template inheritance.
  • Build child templates for Home and About that extend the base layout.
  • Update your Flask routes to render templates instead of raw strings.
Create the base template

Template inheritance lets you define a single layout file (the "base") that every page shares. Each page then fills in only the parts that change.

  • In VS Code's Explorer sidebar, right-click inside your portfolio folder and select New Folder.
  • Name the folder templates and press Enter.
  • Right-click the new templates folder and select New File.
  • Name it base.html and press Enter.
  • Paste the following code into base.html:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}My Portfolio{% endblock %}</title>
</head>
<body>
    <nav>
        <a href="{{ url_for('home') }}">Home</a>
        <a href="{{ url_for('about') }}">About</a>
        <a href="{{ url_for('show_projects') }}">Projects</a>
    </nav>
    <main>
        {% block content %}{% endblock %}
    </main>
</body>
</html>

What does this template do?

This is the shared skeleton every page on your site will use. The key pieces are:

  • {% block title %} is a named slot. Child templates can override it with their own page title. If they don't, it defaults to "My Portfolio".
  • {{ url_for('home') }} generates the URL for each route by its function name. This means your links never break even if you change the URL paths later.
  • {% block content %}{% endblock %} is where each child page injects its own unique content.
  • Save base.html.
  • Confirm the file exists by running this in your terminal:
ls templates/

You should see base.html listed in the output.

Don't see base.html?

Make sure you created the templates folder directly inside your portfolio folder, not nested inside another subfolder.

Flask expects the folder to be named exactly templates (lowercase, plural). A different name will cause Flask to not find your files later.

Help me figure out why Flask can't find my templates folder.

Create the child templates

Each page on your site "extends" the base template and fills in only the content block. The nav bar, HTML skeleton, and head section are inherited automatically.

  • Right-click the templates folder and select New File.
  • Name it index.html and paste this code:
{% extends "base.html" %}

{% block title %}Home - My Portfolio{% endblock %}

{% block content %}
<section class="hero">
    <h1>Hi, I'm [Your Name]</h1>
    <p>Aspiring Software Developer and Data Analyst</p>
</section>
{% endblock %}

What does extends do?

{% extends "base.html" %} tells Jinja2 to use base.html as the starting point. Everything outside the {% block %} tags is inherited. Only the content inside the blocks gets replaced with this page's own HTML.

  • Save index.html.
  • Create another new file in templates called about.html and paste this code:
{% extends "base.html" %}

{% block title %}About - My Portfolio{% endblock %}

{% block content %}
<section class="about">
    <h1>About Me</h1>
    <p>I recently completed a Python course focused on data science and AI.</p>
</section>
{% endblock %}
  • Save about.html.
  • Confirm all three template files exist by running:
ls templates/

You should see base.html, index.html, and about.html listed.

Update your routes to use templates

Your template files are ready, but Flask is still returning raw strings. You need to tell each route to render a template instead.

  • Open app.py and find this line at the top:
from flask import Flask
  • Replace it with this line to also import render_template:
from flask import Flask, render_template
  • Now find the home function:
@app.route('/')
def home():
    return '<h1>Hi, I am [Your Name]</h1>'
  • Replace the return line so the function looks like this:
@app.route('/')
def home():
    return render_template('index.html')
  • Find the about function:
@app.route('/about')
def about():
    return '<h1>About Me</h1><p>This is my about page.</p>'
  • Replace the return line so the function looks like this:
@app.route('/about')
def about():
    return render_template('about.html')

What does render_template do?

render_template('index.html') tells Flask to look inside the templates folder for that file, process any Jinja2 tags (like {% extends %} and {{ url_for() }}), and return the final HTML to the browser.

Your base.html nav bar includes a Projects link, but that route doesn't exist yet. You need to add a temporary placeholder route so the templates can render without errors.

  • In app.py, add this placeholder route below your about function (above the if __name__ block):
@app.route('/projects')
def show_projects():
    return 'Projects page coming soon!'

Why add a placeholder?

The {{ url_for('show_projects') }} tag in your nav bar needs a route named show_projects to exist. Without it, Flask raises a BuildError when trying to render the page. You'll replace this placeholder with the real projects page in the next step.

  • Save app.py.

Before you run the app, what do you think you'll see when you visit the home page now? Will it look different from the raw HTML string version?

  • Run your app by entering this in the terminal:
python app.py
  • Open http://127.0.0.1:5000/ in your browser.
  • Click the About link in the navigation bar.
  • Click the Home link to go back.

Both pages now share the same navigation bar. Notice the browser tab title changes to "Home - My Portfolio" and "About - My Portfolio" as you click between them.

Seeing a TemplateNotFound error?

  • Check that your templates folder is in the same directory as app.py. Flask looks for it right next to your app file.
  • Verify the file names are spelled exactly index.html and about.html (all lowercase, no extra spaces).
  • If you see a BuildError mentioning show_projects, make sure you added the placeholder route from the step above.

Help me debug my Flask templates not loading.

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files before moving on.

ⓧ I'd like to double check the full code

Here is app.py as it should look now:

from flask import Flask, render_template

app = Flask(__name__)

@app.route('/')
def home():
    return render_template('index.html')

@app.route('/about')
def about():
    return render_template('about.html')

if __name__ == '__main__':
    app.run(debug=True)

Here is templates/base.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}My Portfolio{% endblock %}</title>
</head>
<body>
    <nav>
        <a href="{{ url_for('home') }}">Home</a>
        <a href="{{ url_for('about') }}">About</a>
        <a href="{{ url_for('show_projects') }}">Projects</a>
    </nav>
    <main>
        {% block content %}{% endblock %}
    </main>
</body>
</html>

Here is templates/index.html:

{% extends "base.html" %}

{% block title %}Home - My Portfolio{% endblock %}

{% block content %}
<section class="hero">
    <h1>Hi, I'm [Your Name]</h1>
    <p>Aspiring Software Developer and Data Analyst</p>
</section>
{% endblock %}

Here is templates/about.html:

{% extends "base.html" %}

{% block title %}About - My Portfolio{% endblock %}

{% block content %}
<section class="about">
    <h1>About Me</h1>
    <p>I recently completed a Python course focused on data science and AI.</p>
</section>
{% endblock %}

Your portfolio now has a proper template structure with shared navigation across pages. Next up, you'll build the dynamic Projects page that renders data straight from Python into your HTML.

Build the Dynamic Projects Page

Your portfolio now has a shared layout and two pages that render through templates. But imagine you finish a new project next week.

Right now you would need to open the HTML, copy a block of markup, paste it, and edit every field by hand. A data-driven approach stores project info in Python and lets a template loop render it automatically. Add a project to the list, refresh, done.

In this step, get ready to:
  • Store project data in a Python list and create a route that passes it to a template.
  • Create a template that loops over the data to render project cards.
  • Add CSS styling to give the entire portfolio a polished, professional look.
Define your project data and create the route

Instead of writing HTML for each project, you will store your project information in a Python list of dictionaries. Each dictionary holds a project's title, description, and tools. Flask passes this list to a template at render time.

  • Switch back to app.py in VS Code.
  • Select all the existing code and replace it with the following. This is the top section of the file containing imports, app setup, and project data:
from flask import Flask, render_template, request, flash, redirect, url_for

app = Flask(__name__)
app.secret_key = "dev-secret-key-change-in-production"

# Project data stored as a list of dictionaries
projects = [
    {
        "title": "Data Analysis with Pandas",
        "description": "Cleaned and visualized a dataset of 10,000+ rows to uncover sales trends.",
        "tools": "Python, Pandas, Matplotlib"
    },
    {
        "title": "Machine Learning Classifier",
        "description": "Built a model to predict customer churn with 85% accuracy.",
        "tools": "Python, Scikit-learn, Jupyter"
    },
    {
        "title": "Web Scraper",
        "description": "Automated data collection from job listing websites.",
        "tools": "Python, BeautifulSoup, Requests"
    }
]

What does this code do?

The import line brings in extra Flask tools we will use later in the project (request, flash, redirect, url_for).

The secret_key is required for Flask session features you will use in a future step. The projects list holds three dictionaries, each with title, description, and tools keys.

  • Directly below the projects list (with no blank lines between), paste the route definitions and the main block:

@app.route("/")
def home():
    return render_template("index.html")


@app.route("/about")
def about():
    return render_template("about.html")


@app.route("/projects")
def show_projects():
    return render_template("projects.html", projects=projects)


if __name__ == "__main__":
    app.run(debug=True)

What does the new route do?

The /projects route calls render_template("projects.html", projects=projects). The second projects passes your Python list into the template so Jinja2 can loop over it and render each project as HTML.

  • Save app.py.
  • Check the terminal where your server is running. You should see a line containing Restarting with stat confirming Flask detected the change and reloaded.

Server not restarting?

Make sure debug=True is set in the app.run() call. Without it, Flask will not auto-reload on save.

If you see a syntax error in the terminal, check for missing commas between dictionary items or mismatched quotes in the projects list.

Why isn't my Flask server restarting when I save?

✔️ Awesome, I've got everything!

Great. Confirm you have saved app.py and the terminal shows the reload message.

ⓧ I'd like to double check the full code

Your app.py has two sections. Scroll up and confirm both are present in this order:

  • Section 1: imports, app creation, secret_key, and the projects list of 3 dictionaries.
  • Section 2: three @app.route definitions (/, /about, /projects) followed by the if __name__ block.
Create the projects template

The template uses a Jinja2 for loop to iterate over the list Flask passed in. Each iteration renders one project card.

  • Create a new file called projects.html inside your templates folder.
  • Paste the following template code:
{% extends "base.html" %}

{% block title %}Projects - My Portfolio{% endblock %}

{% block content %}
<section class="projects">
    <h1>My Projects</h1>
    <div class="project-grid">
        {% for project in projects %}
        <div class="project-card">
            <h2>{{ project.title }}</h2>
            <p>{{ project.description }}</p>
            <span class="tools">{{ project.tools }}</span>
        </div>
        {% endfor %}
    </div>
</section>
{% endblock %}

What does the for loop do?

The {% for project in projects %} block repeats everything inside it once for each dictionary in the list. Each time around the loop, project.title, project.description, and project.tools pull values from the current dictionary.

If you add a 4th dictionary to the Python list later, the template automatically renders a 4th card with no HTML changes needed.

  • Save templates/projects.html.
  • Open your browser and visit http://127.0.0.1:5000/projects.

You should see three project titles, descriptions, and tool lists rendered as plain unstyled text. The content is there but the page looks bare.

Seeing a TemplateNotFound error?

Check that the file is named exactly projects.html (not project.html) and lives directly inside the templates folder.

Help me fix a TemplateNotFound error in Flask

Style the portfolio with CSS

The pages work but look plain. A single CSS file linked through the base template will style every page at once.

  • Create a folder called static at the same level as your templates folder (inside your portfolio project folder).
  • Inside static, create a new file called style.css.
  • Paste the following base layout styles into style.css:
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    line-height: 1.6;
    color: #333;
    max-width: 900px;
    margin: 0 auto;
    padding: 20px;
}

nav {
    display: flex;
    gap: 20px;
    padding: 20px 0;
    border-bottom: 1px solid #eee;
    margin-bottom: 40px;
}

What do these styles do?

The * reset removes default browser margins so spacing is consistent. The body rule centers content at a readable 900px width. The nav uses flexbox to lay navigation links out horizontally with even gaps.

  • Now link this stylesheet in your base template. Open templates/base.html and find this line inside <head>:
    <title>{% block title %}My Portfolio{% endblock %}</title>
  • Add this line directly below it:
    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">

Why url_for for the stylesheet?

Flask's url_for('static', filename='style.css') generates the correct path to your static file regardless of how the app is deployed. Hardcoding the path would break if you ever move the app.

  • Save both style.css and base.html.
  • Refresh http://127.0.0.1:5000/ in your browser.

You should see the navigation links now sit in a horizontal row with a subtle border underneath. The page content is centered and the font is cleaner.

  • Return to static/style.css and add the navigation link and hero section styles below your existing code:
nav a {
    text-decoration: none;
    color: #555;
    font-weight: 500;
}

nav a:hover {
    color: #000;
}

.hero {
    text-align: center;
    padding: 60px 0;
}

.hero h1 {
    font-size: 2.5rem;
    margin-bottom: 10px;
}

.hero p {
    font-size: 1.2rem;
    color: #666;
    margin-bottom: 20px;
}

.skills {
    display: flex;
    gap: 10px;
    justify-content: center;
    flex-wrap: wrap;
}

What do these styles target?

The nav a rules remove underlines and add a hover colour change. The .hero and .skills classes will style the Home page once you add skill tags to index.html next.

  • Open templates/index.html and replace its entire content with this updated version that adds skill tags:
{% extends "base.html" %}

{% block title %}Home - My Portfolio{% endblock %}

{% block content %}
<section class="hero">
    <h1>Hi, I'm [Your Name]</h1>
    <p>Aspiring Software Developer and Data Analyst</p>
    <div class="skills">
        <span class="tag">Python</span>
        <span class="tag">Data Science</span>
        <span class="tag">Machine Learning</span>
        <span class="tag">Flask</span>
    </div>
</section>
{% endblock %}
  • Save style.css and index.html.
  • Refresh the Home page in your browser.

Your name is now large and centered. Below it you should see four rounded skill tags (Python, Data Science, Machine Learning, Flask) in a horizontal row.

  • Back in static/style.css, add the tag and About page styles below your existing code:
.tag {
    background: #f0f0f0;
    padding: 6px 14px;
    border-radius: 20px;
    font-size: 0.9rem;
}

.about h1 {
    margin-bottom: 20px;
}

.about p {
    margin-bottom: 20px;
}

.about h2 {
    margin-top: 30px;
    margin-bottom: 10px;
}

.about ul {
    padding-left: 20px;
}

.about li {
    margin-bottom: 8px;
}

What does this style?

The .tag class gives each skill a pill-shaped background. The .about rules add spacing to headings, paragraphs, and list items on the About page.

  • Open templates/about.html and replace its content with this expanded version that includes a skills list:
{% extends "base.html" %}

{% block title %}About - My Portfolio{% endblock %}

{% block content %}
<section class="about">
    <h1>About Me</h1>
    <p>I recently completed a Python course focused on data science and AI. I'm passionate about using code to solve real-world problems and turning data into actionable insights.</p>
    <h2>Technical Skills</h2>
    <ul>
        <li>Python (data analysis, automation, web development)</li>
        <li>Data Science (Pandas, NumPy, Matplotlib)</li>
        <li>Machine Learning (Scikit-learn)</li>
        <li>Web Development (Flask, HTML, CSS)</li>
    </ul>
</section>
{% endblock %}
  • Save style.css and about.html.
  • Visit the About page in your browser.

You should see your About heading, a paragraph of text, a Technical Skills subheading, and a neatly spaced bullet list. The CSS handles all the spacing.

  • Finally, add the project card styles to the bottom of static/style.css:
.projects h1 {
    margin-bottom: 30px;
}

.project-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
    gap: 20px;
}

.project-card {
    border: 1px solid #eee;
    border-radius: 8px;
    padding: 20px;
}

.project-card h2 {
    font-size: 1.1rem;
    margin-bottom: 10px;
}

.project-card p {
    color: #666;
    margin-bottom: 12px;
    font-size: 0.95rem;
}

.tools {
    font-size: 0.8rem;
    color: #888;
    font-style: italic;
}

How does the grid layout work?

The grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)) rule creates as many columns as fit, each at least 260px wide. On a wide screen you see cards side by side. On a narrow screen they stack into one column automatically.

  • Save style.css.
  • Visit http://127.0.0.1:5000/projects in your browser.

Your three project cards now appear in a responsive grid. Each card shows its title in bold, a grey description, and italicized tools at the bottom.

Styles not appearing?

Make sure the static folder sits at the same level as templates (both inside your portfolio project folder). Flask looks for a folder named exactly static.

Try a hard refresh (Cmd+Shift+R on macOS or Ctrl+Shift+R on Windows) to bypass the browser cache.

Help me debug why my CSS is not loading in Flask

✔️ Awesome, I've got everything!

Great. Confirm you have saved all files and the Projects page shows styled cards.

ⓧ I'd like to double check the full code

Your static/style.css should contain all four groups of styles in this order. Scroll up and confirm each is present:

  • Group 1: * reset, body, and nav layout.
  • Group 2: nav a, .hero, .hero h1, .hero p, and .skills.
  • Group 3: .tag and the .about section rules.
  • Group 4: .projects h1, .project-grid, .project-card, and .tools.

Now for the real test. Before you try this, predict what you think will happen if you add a new project to the Python list and refresh the browser.

  • Open app.py and add a 4th dictionary to the projects list (any title, description, and tools you like).
  • Save the file and refresh http://127.0.0.1:5000/projects in your browser.

A 4th card appears in the grid instantly. You did not touch the HTML template. That is the power of a data-driven page.

Why is this useful?

In a real application your project data might come from a database or an API. The template does not care where the list comes from. It just loops and renders. This same pattern powers every data-driven web app you use daily.

  • Remove the 4th project you just added (so your list has the original 3) and save app.py.

Your portfolio now has three polished pages, a shared layout, and a data-driven projects page. Next up, you will tackle a Secret Mission that adds a working contact form to complete the full picture.

Secret mission

Add a Working Contact Form

Your portfolio looks great, but it only displays information. Can you add a working contact form that processes user input on the backend and shows feedback messages? This proves you understand the full request-response cycle.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine. There are no cloud resources, no paid services, and no ongoing costs.

Resources you used:

  • The Flask development server (running process in your terminal).
  • The portfolio project folder on your Desktop.
  • The .venv virtual environment inside the project folder (about 30MB of disk space).

Keep everything running

No action needed. Choose this if you are still actively building on your portfolio or plan to continue customizing it.

  • Your Flask server continues running at http://127.0.0.1:5000/.
  • All your project files remain in the portfolio folder on your Desktop.
  • To restart later if you close your terminal: open the portfolio folder in VS Code, activate the virtual environment with .venv\Scripts\activate, and run python app.py.

Pause - I'll come back to this later

Stop the running server to free up your terminal, but keep all your files so you can pick up where you left off.

  • Press Ctrl+C in the terminal where your Flask server is running. This stops the server.
  • Your project files stay in the portfolio folder on your Desktop.
  • To resume: open the portfolio folder in VS Code, activate the virtual environment with .venv\Scripts\activate, and run python app.py.

Delete - I don't want to use this again

Remove all project files and reclaim disk space. You can always rebuild using the same steps from this project.

  • Press Ctrl+C in the terminal to stop the Flask server if it is still running.
  • Delete the entire project folder by running the command for your operating system below.

On macOS or Linux:

cd ~/Desktop
rm -rf portfolio

On Windows:

cd ~/Desktop
rmdir /s /q portfolio

This removes the portfolio folder, all template files, static files, and the .venv virtual environment in one step.

Nice Work!

Nice Work!

Nice work! You just built a fully functional portfolio website from scratch using Python and Flask.

You've learned how to:

  • Connected URLs to Python functions using Flask routing, turning app.py into a multi-page web server.
  • Used Jinja2 template inheritance to share a navigation bar and layout across every page from a single base.html file.
  • Rendered dynamic content by passing a Python list of dictionaries into an HTML template and looping over it with a {% for %} block.
  • Secret Mission: Processed form submissions on the backend, validated user input, and displayed flash messages to prove you understand the full request-response cycle.

Ready to quiz yourself?