Today’s goal
Create a page at /posts/new/ where you can enter a title and body. Django will validate the form, save a Post, and redirect to its detail page.
This form is for learning on your local computer. Do not deploy a public create-post form until you add login and permission checks.
Step 1 — Create a ModelForm
Create a new file named blog/forms.py:
from django import forms
from .models import Post
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ["title", "body"]
widgets = {
"title": forms.TextInput(
attrs={"class": "form-input", "placeholder": "Post title"}
),
"body": forms.Textarea(
attrs={
"class": "form-input",
"rows": 10,
"placeholder": "Write your post here...",
}
),
}
A ModelForm builds form fields from a model and knows how to save a valid Post.
List the fields explicitly. created_at is not included because Django fills it automatically.
Step 2 — Add the form URL
Update blog/urls.py:
urlpatterns = [
path("", views.home, name="home"),
path("posts/new/", views.post_create, name="post_create"),
path("posts/<int:pk>/", views.post_detail, name="post_detail"),
]
Place posts/new/ before the route containing <int:pk>. The exact route is easier to recognize first.
Step 3 — Build the create view
Update the imports in blog/views.py:
from django.shortcuts import get_object_or_404, redirect, render
from .forms import PostForm
from .models import Post
Keep your existing views and add:
def post_create(request):
if request.method == "POST":
form = PostForm(request.POST)
if form.is_valid():
post = form.save()
return redirect("post_detail", pk=post.pk)
else:
form = PostForm()
context = {
"form": form,
}
return render(request, "blog/post_create.html", context)
The same URL has two jobs:
GET /posts/new/ → show an empty form
POST /posts/new/ → validate
├─ valid → save → redirect to the new post
└─ invalid → show the form and its errors again
The redirect prevents a browser refresh from submitting the same post twice.
Step 4 — Create the form template
Create blog/templates/blog/post_create.html:
{% load static %}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>New post · My First Blog</title>
<link rel="stylesheet" href="{% static 'blog/style.css' %}">
</head>
<body>
<header class="site-header">
<p><a href="{% url 'home' %}">My First Blog</a></p>
<h1>Write a new post</h1>
</header>
<main class="content">
<form class="post-form" method="post">
{% csrf_token %}
{{ form.non_field_errors }}
<p class="field">
<label for="{{ form.title.id_for_label }}">Title</label>
{{ form.title }}
{{ form.title.errors }}
</p>
<p class="field">
<label for="{{ form.body.id_for_label }}">Body</label>
{{ form.body }}
{{ form.body.errors }}
</p>
<button type="submit">Publish</button>
</form>
</main>
</body>
</html>
Every Django POST form needs {% csrf_token %}. Django checks this secret value so another website cannot easily submit the form as if it were you.
Step 5 — Link to the new page
In blog/templates/blog/index.html, add this above the search form:
<p>
<a href="{% url 'post_create' %}">Write a new post</a>
</p>
Step 6 — Style the form
Add to blog/static/blog/style.css:
.post-form {
padding: 1.5rem;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface);
}
.field {
margin: 0 0 1rem;
}
.field label {
display: block;
margin-bottom: 0.4rem;
font-weight: 700;
}
.form-input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
font: inherit;
}
.post-form button {
padding: 0.75rem 1.1rem;
border: 0;
border-radius: 8px;
background: var(--accent);
color: white;
cursor: pointer;
font: inherit;
font-weight: 700;
}
.errorlist {
padding: 0;
color: #a31818;
list-style: none;
}
Step 7 — Test the complete flow
- Open
/posts/new/— an empty form appears. - Enter a title and body.
- Click Publish.
- You land on the new post’s detail page.
- Return to
/— the post appears at the top. - Open
/admin/— the same post appears there.
To test validation, try submitting without a title. The browser or Django should stop the save and show an error instead of crashing.
Final project checkpoint
-
/lists posts from the database. - Search filters titles and bodies.
- Each post opens at
/posts/<id>/. -
/posts/new/validates and saves a post. - Saving redirects to the new post.
-
/admin/can still create, edit, and delete posts. - You can trace URL → view → template for every page.
If it does not work
| What you see | Check this |
|---|---|
403 CSRF verification failed | Put {% csrf_token %} inside the form. |
PostForm is not defined | Import it with from .forms import PostForm. |
| Save returns to a blank form | Display form.title.errors and form.body.errors. |
| Refresh creates another post | Redirect after form.save(). |
/posts/new/ returns 404 | Check the path and the post_create function name. |
You finished
You now have a complete first Django project—not a production platform, but a small system you can explain:
- URLs choose views.
- Views prepare data.
- Templates display data.
- Static files style the pages.
- GET reads and searches.
- Models save records.
- POST validates and creates.
Your best next step is to rebuild the same project once without copying every line. After that, add login before allowing public visitors to create posts, then learn editing, deleting, tests, and deployment one feature at a time.
