Skip to content

Creating a New Post

Authenticated users can create posts. The form collects a title and body, submits to POST /posts, and redirects to the new post’s detail page on success.

// src/api/postsApi.ts (add to existing file)
export async function createPost(data: { title: string; body: string }): Promise<Post> {
const res = await apiClient.post<Post>('/posts', data)
return res.data
}

The axios interceptor from Module 09 attaches the Authorization header automatically — this call doesn’t need to do it manually.

src/pages/CreatePost.tsx
import { useState, FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { createPost } from '../api/postsApi'
export default function CreatePost() {
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const navigate = useNavigate()
async function handleSubmit(e: FormEvent) {
e.preventDefault()
if (!title.trim() || !body.trim()) {
setError('Title and body are required.')
return
}
setSubmitting(true)
setError(null)
try {
const post = await createPost({ title, body })
navigate(`/posts/${post.id}`)
} catch {
setError('Failed to create post. Please try again.')
} finally {
setSubmitting(false)
}
}
return (
<main>
<h1>New Post</h1>
{error && <p style={{ color: 'red' }}>{error}</p>}
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="title">Title</label>
<input
id="title"
value={title}
onChange={e => setTitle(e.target.value)}
required
/>
</div>
<div>
<label htmlFor="body">Body</label>
<textarea
id="body"
value={body}
onChange={e => setBody(e.target.value)}
rows={6}
required
/>
</div>
<button type="submit" disabled={submitting}>
{submitting ? 'Posting...' : 'Post'}
</button>
</form>
</main>
)
}

Creating a post requires authentication. Wrap the route with ProtectedRoute:

// src/App.tsx (routes excerpt)
<Route
path="/create"
element={
<ProtectedRoute>
<CreatePost />
</ProtectedRoute>
}
/>

An unauthenticated user who visits /create is redirected to /login, then back to /create after logging in — the redirect flow built in Module 09 handles this automatically.

Put a link in your navigation so logged-in users can reach the form:

// src/components/Nav.tsx (excerpt)
import { useAuth } from '../context/AuthContext'
const { isAuthenticated } = useAuth()
// in JSX:
{isAuthenticated && <Link to="/create">New Post</Link>}

Only show the link if the user is authenticated — a nice UI guard on top of the route-level guard.

Client-side validation vs server-side validation

Section titled “Client-side validation vs server-side validation”

The form checks that title and body are non-empty before submitting. The server also validates these — the form check is a fast fail to give immediate feedback, not a replacement for server validation. Never rely solely on frontend checks: a user can bypass them by calling the API directly.

Open src/pages/CreatePost.tsx in your Bulletin project.

  1. Add createPost to src/api/postsApi.ts.
  2. Create CreatePost.tsx with controlled inputs for title and body, a submit handler, and error/submitting state.
  3. Wrap the /create route in ProtectedRoute in App.tsx.
  4. Add a “New Post” link to your Nav component that only shows when isAuthenticated is true.
  5. Log in as a test user, navigate to /create, fill in the form, and submit. Confirm you land on the new post’s detail page (a 404 for now — that’s Lesson 03).
  • createPost() posts to /posts; the axios interceptor attaches the auth token.
  • Client-side validation provides fast feedback; server-side validation is the real gate.
  • Wrap the /create route in ProtectedRoute so unauthenticated users are redirected.
  • navigate('/posts/' + post.id) redirects to the new post after creation.