Today’s goal
Add a search box that filters the three temporary posts. Search uses GET, so the search text appears in the URL and can be refreshed or shared.
After searching for python, the address will look like:
http://127.0.0.1:8000/?q=python
Step 1 — Add the search form
In blog/templates/blog/index.html, place this inside <main class="content">, before the Latest posts heading:
<form class="search-form" method="get" action="{% url 'home' %}">
<label for="search">Search posts</label>
<div class="search-row">
<input
id="search"
type="search"
name="q"
value="{{ query }}"
placeholder="Try Python or Django"
>
<button type="submit">Search</button>
</div>
</form>
The input’s name="q" creates the ?q=... part of the URL. The value must be passed back through context so the input remembers the search after the page reloads.
Submit the form now. The URL should change, but all posts still appear. That is correct—the view has not filtered anything yet.
Step 2 — Read the search in the view
At the beginning of home() in blog/views.py, add:
query = request.GET.get("q", "").strip()
Then add query to context:
context = {
"site_title": "My First Blog",
"posts": posts,
"query": query,
}
.get("q", "") safely returns an empty string when the URL has no q. .strip() removes spaces from the beginning and end.
Step 3 — Filter the sample posts
After the posts list and before context, add:
if query:
query_lower = query.lower()
posts = [
post
for post in posts
if query_lower in post["title"].lower()
or query_lower in post["description"].lower()
]
Read it as a sentence:
Keep a post when the lower-case search text appears in its lower-case title or description.
Step 4 — Show a useful result heading
Replace the fixed Latest posts heading with:
{% if query %}
<h2>Search results for “{{ query }}”</h2>
{% else %}
<h2>Latest posts</h2>
{% endif %}
Replace the empty message with one that understands search:
{% if posts %}
<div class="post-grid">
{% for post in posts %}
<article class="post-card">
<h3>{{ post.title }}</h3>
<p>{{ post.description }}</p>
</article>
{% endfor %}
</div>
{% elif query %}
<div class="empty-state">
<p>No posts matched “{{ query }}”.</p>
<a href="{% url 'home' %}">Clear the search</a>
</div>
{% else %}
<p class="empty-state">No posts have been published yet.</p>
{% endif %}
Step 5 — Style the form
Add this to blog/static/blog/style.css:
.search-form {
margin-bottom: 2rem;
padding: 1rem;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface);
}
.search-form label {
display: block;
margin-bottom: 0.5rem;
font-weight: 700;
}
.search-row {
display: flex;
gap: 0.75rem;
}
.search-row input {
min-width: 0;
flex: 1;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
font: inherit;
}
.search-row button {
padding: 0.75rem 1rem;
border: 0;
border-radius: 8px;
background: var(--accent);
color: white;
cursor: pointer;
font: inherit;
font-weight: 700;
}
@media (max-width: 520px) {
.search-row {
flex-direction: column;
}
}
Step 6 — Test seven small cases
- Visit
/— all posts appear. - Search
Python— the Python post appears. - Search
django— the Django post appears. - Search
page— matching text in a description also works. - Search
PYTHON— capitalization does not matter. - Search
spaceship— the no-match message appears. - Clear the search — all posts return.
Checkpoint
- The URL contains
?q=after a search. - The search input keeps its value after reload.
- Matching ignores upper- and lower-case differences.
- A bad search shows a useful message and clear link.
- You can explain that the form sends data and the view filters data.
If it does not work
| What you see | Check this |
|---|---|
| URL changes but posts do not | Filtering must run before context is built. |
| Input becomes empty | Add query to context and value="{{ query }}". |
KeyError: q | Use request.GET.get("q", ""), not request.GET["q"]. |
| Search is case-sensitive | Use .lower() on both the query and post text. |
Stop here
Today is complete when the browser sends a search and the view returns only matching posts. Tomorrow you will replace the temporary dictionaries with real records saved in SQLite.
