Express, inside Next.js
Server actions serve your own forms. But sometimes you need a real HTTP endpoint: a mobile app hitting your backend, a webhook from Stripe (a URL another service calls to notify you when something happens, like a payment), a public JSON API. That is a route handler, the special file route.js:
// app/api/posts/route.js export async function GET() { const posts = await db.posts.all(); return Response.json(posts); } export async function POST(request) { const body = await request.json(); const post = await db.posts.insert(body); return Response.json(post, { status: 201 }); }
This is the Backend with Node.js course condensed: GET/POST exports instead of app.get/app.post, the web-standard Request and Response objects instead of Express's req/res, and file-based paths (app/api/posts/route.js → /api/posts) instead of route strings. Even the status codes you learned (201 Created) carry straight over.
Why one folder cannot hold both page.js and route.js
Next.js forbids a folder from containing both files, and the reason is a direct conflict over the URL.
page.js says "this URL renders UI," meaning the response is HTML built from a React tree. route.js says "this URL is a raw HTTP endpoint," meaning your function decides the entire response, headers, status, and body. Both files claim the exact same URL, one answering with HTML and one with whatever you return, and the router has no basis on which to choose between them.
Rather than pick silently and surprise you, Next.js treats the pair as an error at build time. In practice this is rarely a limitation, because API endpoints normally live under a dedicated prefix such as app/api/.
Choosing between a server action and a route handler
| Situation | Reach for |
|---|---|
| Your own app's form submits data | Server action |
| A mobile app or another service needs JSON | Route handler |
| A third party POSTs webhooks to you | Route handler |
| A button in your UI mutates data | Server action |
Rule of thumb: actions are internal wiring, handlers are public doors. Actions keep the type-safe function-call feel inside your app, with no URL to document and no request parsing to write. Handlers speak plain HTTP that anything on the internet can call, exactly like the Express APIs you built before.
Giving Stripe a webhook URL
Stripe needs a URL it can POST payment events to, reachable at /api/webhooks. The file to create is app/api/webhooks/route.js, exporting an async POST(request) function.
External services need a raw HTTP endpoint, which is route handler territory. A server action would not work here, because actions are internal wiring for your own app's forms and buttons, not a public URL contract that a third party can call.
Why it works out that way
- A third-party service POSTing to you is a public HTTP endpoint with a documented URL, a method, and a body format, none of which a server action exposes.
- The folder path spells the URL, so
app/api/webhooks/produces/api/webhooks, and theroute.jsfile name is what marks it as an endpoint rather than a page. - Exporting
POSTspecifically means a strayGETto that URL is rejected automatically, which is the behaviour you want for a webhook.