Understanding HTTP 303 See Other: The Redirect That Always Switches to GET
303 See Other is the redirect code with a guarantee none of its siblings make: however you got here, go fetch the result with GET. Where 302 leaves the resulting method ambiguous and 307 explicitly preserves the original method, 303 explicitly changes it — always to GET, no exceptions. This post covers what 303 actually promises, the Post/Redirect/Get pattern it exists to enable, how it compares to the rest of the 3xx family, and when it beats 307 for the same-looking job.
What Is a 303?
A 303 tells the client that the result of its request is available at a different URI, and that the client should retrieve it with a fresh GET (or HEAD) request — regardless of what method produced this response.
The 303 (See Other) status code indicates that the server is redirecting the user agent to a different resource, as indicated by a URI in the Location header field, which is intended to provide an indirect response to the original request. A user agent can perform a retrieval request targeting that URI (a GET or HEAD request), which is the proper method for obtaining the resource. — RFC 9110, Section 15.4.4
In plain English: "The operation succeeded — now go fetch the result with GET at this other URL." It's built for exactly the situation where a POST, PUT, or DELETE did something, and the response the client actually wants to look at is a separate resource — a confirmation page, the newly created object, a status page — that should itself behave like an ordinary, bookmarkable, cacheable GET target.
303 explicitly changes the method to
GET, unlike 302/307 where the method-preservation behavior is ambiguous or explicitly fixed the other way. Use 303 when you want aPOSTto becomeGETafter the redirect (form submissions, action confirmations). Use 307 when you want thePOSTto stay aPOST(API redirects, retried writes).
The Post/Redirect/Get Pattern
303's defining use case has a name: Post/Redirect/Get, or PRG. The problem it solves is old and familiar — a user submits a form, the browser shows the resulting page, and if the user hits refresh, the browser re-sends the original POST, silently repeating whatever the form did (charging a card twice, creating a duplicate order, posting a comment again). Browsers even warn about this ("Confirm Form Resubmission") because they know it's dangerous.
PRG breaks the cycle in three steps:
- Client sends
POST /orderswith the new order's data. - Server creates the order, then responds
303 See OtherwithLocation: /orders/482. - Client automatically issues
GET /orders/482, which the browser renders as the final page.
Now a refresh only re-runs the harmless GET /orders/482 — the POST that actually created the order is not in the browser's replay history at all. This is the concrete reason 303's method-switching guarantee matters: if the redirect used 302 and a browser preserved the method, or a client library didn't follow the historical POST-to-GET convention, a refresh could resubmit the original POST. 303 removes the ambiguity entirely — the spec requires GET, full stop.
303 vs 301 vs 302 vs 307 vs 308
The redirect family splits along two independent axes: is the move permanent? and what happens to the method?
| Code | Meaning | Permanent? | Method after redirect |
|---|---|---|---|
| 301 Moved Permanently | Gone for good | Yes | May change to GET (legacy behavior) |
| 302 Found | Temporarily elsewhere | No | May change to GET (legacy behavior, ambiguous) |
| 303 See Other | Indirect response to this request | No (not about the resource moving) | Always GET |
| 307 Temporary Redirect | Temporarily elsewhere | No | Never changes — original method preserved |
| 308 Permanent Redirect | Gone for good | Yes | Never changes — original method preserved |
303 is the odd one out on the "permanent?" axis, because it isn't really answering that question at all — it's not saying the requested resource moved, it's saying the response to this specific request lives somewhere else. That's a different relationship than 301/302/307/308, which are all fundamentally about a resource's location changing. Its closest sibling by behavior is actually 307: both are non-permanent, and both give a hard guarantee about the method (one always changes it, the other never does) — the shared origin story is the 307 post's account of the 302 method-preservation bug that both 303 and 307 were introduced to fix.
When 303 Beats 307 (and Vice Versa)
Both codes give an unambiguous guarantee about the method — they just guarantee opposite things, which makes the choice mostly about what should happen to the original method after the redirect:
- Reach for 303 when the redirect is a response to a write and you want the follow-up request to be a plain, safe, cacheable
GET— the PRG pattern, a "your export is ready, here's the download link" flow, or any case where the destination is conceptually a different resource than the one being acted on. - Reach for 307 when the redirect needs the original method (and body) to survive intact — an API endpoint that's moved but still expects the same
POSTwith the same payload, a file upload being redirected to a different storage backend, or any redirect where switching toGETwould silently drop data the server still needs.
A concrete tell: if following the redirect with the original method and body would still make sense at the new location, that's a 307 situation. If the new location is a different kind of resource that only makes sense to GET, that's 303.
Next.js and 303
Next.js's redirect() function surfaces this exact distinction automatically, based on where it's called from: outside a Server Action it returns a 307 by default (preserving the calling method), but inside a Server Action it returns a 303 — because a Server Action is typically invoked as the result of a form submission (functionally a POST), and redirecting the browser to view the result should switch to GET, the same PRG guarantee described above:
"use server";
import { redirect } from "next/navigation";
export async function createOrder(formData: FormData) {
const order = await db.createOrder(formData);
// Inside a Server Action, this redirect is served as a 303 —
// the browser follows it with GET, so a page refresh afterward
// just re-fetches /orders/[id] instead of resubmitting the action.
redirect(`/orders/${order.id}`);
}Express / Node.js
app.post("/orders", async (req, res) => {
const order = await db.createOrder(req.body);
// 303: go fetch the result with GET, don't replay this POST
res.redirect(303, `/orders/${order.id}`);
});
app.get("/orders/:id", async (req, res) => {
const order = await db.getOrder(req.params.id);
if (!order) return res.status(404).json({ error: "not_found" });
res.json(order);
});NGINX
NGINX redirects are almost always used for URL rewrites and permanent moves, so a hand-configured 303 is rare — 303 is inherently a response to a specific application-level action (a form submission, a completed write), not a static routing rule. Where NGINX participates is passing an application's 303 straight through:
location /api/ {
proxy_pass http://backend;
# A 303 (and its Location header) from the backend passes through
# to the client unchanged; NGINX doesn't rewrite redirect semantics.
}Example response
HTTP/1.1 303 See Other
Location: /orders/482Common Pitfalls
- Using 303 for a plain resource move instead of 301/308. 303 isn't a general-purpose "go elsewhere" code — it specifically means "the response to this request is over there," not "this resource has permanently relocated." A moved page belongs on the permanent side of the redirect family.
- Using 302 for the PRG pattern and hoping the method changes. 302's method-changing behavior is a widely-followed legacy convention, not a guarantee — some clients and libraries won't switch to
GET. 303 makes it a hard requirement instead of a hope. - Reaching for 303 when the original method needs to survive. If the redirect target expects the same
POSTbody the client just sent, 303 will silently drop it (the client switches toGET, with no body) — that's a 307 job. - Forgetting that 303 responses themselves are cacheable only under specific conditions. A 303's own response has no meaningful body to cache in the typical case; what actually gets cached (if anything) is the
GETresponse at theLocationURL — treat the redirect and the final resource as separately cacheable. - Not returning a
Locationheader at all. 303 is meaningless without it — the header is what tells the client where theGETshould go; a 303 without one leaves the client with nowhere to follow up.
Wrapping Up
303 is the redirect that exists to make one specific pattern unambiguous. The rules of thumb:
- 303 means go fetch the result with GET at this other URL — a guarantee, not a legacy convention like 302's
- It's the backbone of the Post/Redirect/Get pattern, which stops a page refresh from resubmitting a form
- Its closest relative is 307 — they guarantee opposite things about the method, which is exactly how to choose between them
- Next.js's
redirect()returns 303 automatically inside Server Actions, and 307 everywhere else, for precisely this reason
For more, see our page on 303 See Other, and our posts on 301 redirects, 302 Found, 307 Temporary Redirect, and 308 Permanent Redirect for the rest of the redirect family.