Can I call a server action from a route handler?
can i call a server action from a route handler? will that be bundled + executed directly, or will it be via http?
Now I have a complete picture. Let me summarize:
##Answer
Yes, you can call a server action from a route handler, and it will be bundled and executed directly — no HTTP request involved.
Here's why:
###How it works:
-
Route handlers run in the RSC (React Server Components) layer - specifically
WEBPACK_LAYERS.reactServerComponents(source):return { import: `next-edge-app-route-loader?${stringify(loaderParams)}!`, layer: WEBPACK_LAYERS.reactServerComponents, } -
Server actions in the RSC layer are not converted to HTTP stubs - The SWC transform checks
is_react_server_layerto decide how to handle server actions (source):- When
is_react_server_layer = true: It usesregisterServerReferenceto register the function as a server reference but keeps the actual function implementation - When
is_react_server_layer = false(client layer): It usescreateServerReferencewhich creates a proxy that makes HTTP calls
- When
-
The
shouldUseReactServerConditionfunction determines if a layer is a server layer (source):export function shouldUseReactServerCondition(layer) { return Boolean( layer && WEBPACK_LAYERS.GROUP.serverOnly.includes(layer) ) }And
serverOnlyincludesreactServerComponents:serverOnly: [ WEBPACK_LAYERS_NAMES.reactServerComponents, WEBPACK_LAYERS_NAMES.actionBrowser, WEBPACK_LAYERS_NAMES.instrument, WEBPACK_LAYERS_NAMES.middleware, ],
###Practical example:
// app/actions.ts
'use server'
export async function myAction(data: string) {
// This runs directly when called from route handler
return { processed: data }
}
// app/api/my-route/route.ts
import { myAction } from '../../actions'
export async function POST(request: Request) {
const data = await request.json()
// This calls myAction DIRECTLY - no HTTP roundtrip!
const result = await myAction(data.input)
return Response.json(result)
}
The server action is bundled inline with your route handler and executes as a regular function call on the server.
any way to not have that behavior? i'm trying to find a good way to call another route handler, from a route handler... (without having to bundle the code together). fetch('/relative-path') doesn't work
Based on my research, here's the situation:
##The Problem
You're right that fetch('/relative-path') doesn't work from a route handler because there's no browser context with a base URL. And as we established, directly importing a server action bundles it inline.
##Solutions for HTTP isolation between route handlers:
###1. Use an absolute URL with environment variables
The standard pattern is to construct the full URL:
// app/api/caller/route.ts
export async function GET() {
const baseUrl = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: `http://localhost:${process.env.PORT || 3000}`
const response = await fetch(`${baseUrl}/api/other-route`)
const data = await response.json()
return Response.json(data)
}
###2. Use headers() to get the host dynamically
// app/api/caller/route.ts
import { headers } from 'next/headers'
export async function GET() {
const headersList = await headers()
const host = headersList.get('host')
const protocol = headersList.get('x-forwarded-proto') || 'http'
const response = await fetch(`${protocol}://${host}/api/other-route`)
const data = await response.json()
return Response.json(data)
}
###3. Extract shared logic into a plain function (not a server action)
If you want code sharing without HTTP but also without the "server action" machinery:
// lib/shared-logic.ts (no "use server" directive)
export async function doSomething(input: string) {
// Your logic here - this is just a regular function
return { result: input.toUpperCase() }
}
// app/api/route-a/route.ts
import { doSomething } from '@/lib/shared-logic'
export async function GET() {
const result = await doSomething('hello')
return Response.json(result)
}
// app/api/route-b/route.ts
import { doSomething } from '@/lib/shared-logic'
export async function POST(req: Request) {
const { input } = await req.json()
const result = await doSomething(input)
return Response.json(result)
}
This bundles the function in both routes but avoids the server action overhead.
Why there's no built-in "call another route handler directly via HTTP" helper:
Next.js doesn't provide this because it would create a self-referential HTTP call which:
- Adds latency (network round-trip to yourself)
- Can cause issues during SSR/build time when the server might not be fully ready
- In serverless environments, could potentially spin up another instance
Is there a specific use case driving this? (e.g., microservice-style isolation, different auth contexts, rate limiting per-route, etc.) That might help identify the best pattern for your situation.
The standard pattern is to construct the full URL
sounds good. but how do i avoid vercel's standard deployment protection
Got it! Here's the solution:
##Bypassing Vercel Deployment Protection for Internal Requests
Use the VERCEL_AUTOMATION_BYPASS_SECRET environment variable with the x-vercel-protection-bypass header:
###Setup
-
Enable Protection Bypass in Vercel Dashboard:
- Go to your project → Settings → Deployment Protection
- Enable "Protection Bypass for Automation"
- This automatically creates the
VERCEL_AUTOMATION_BYPASS_SECRETsystem environment variable
-
Use it in your route handler:
// app/api/caller/route.ts
import { headers } from 'next/headers'
export async function GET() {
const headersList = await headers()
const host = headersList.get('host')
const protocol = headersList.get('x-forwarded-proto') || 'https'
const response = await fetch(`${protocol}://${host}/api/other-route`, {
headers: {
'x-vercel-protection-bypass': process.env.VERCEL_AUTOMATION_BYPASS_SECRET || '',
},
})
const data = await response.json()
return Response.json(data)
}
###Alternative: Forward cookies (for same-domain requests)
If you're already authenticated, you can forward the incoming request's cookies instead:
// app/api/caller/route.ts
import { headers, cookies } from 'next/headers'
export async function GET(request: Request) {
const headersList = await headers()
const host = headersList.get('host')
const protocol = headersList.get('x-forwarded-proto') || 'https'
// Forward cookies from incoming request (includes auth cookie if present)
const cookieStore = await cookies()
const cookieHeader = cookieStore.getAll()
.map(c => `${c.name}=${c.value}`)
.join('; ')
const response = await fetch(`${protocol}://${host}/api/other-route`, {
headers: {
cookie: cookieHeader,
},
})
const data = await response.json()
return Response.json(data)
}
###Key Notes
- The
VERCEL_AUTOMATION_BYPASS_SECRETis set at build time, so if you regenerate/delete the secret, you'll need to redeploy - This bypasses Password Protection, Vercel Authentication, and Trusted IPs
- For local development, you won't have this variable set, so you may want a fallback or just skip the header when
!process.env.VERCEL_AUTOMATION_BYPASS_SECRET
that sounds good, but i don't think that it gets auto-generated. the user will need to go do it themselves, which is annoying IMO
You're right, it's not automatic - the user has to manually go to the Vercel dashboard and enable it. That's definitely friction.
A few alternatives to consider:
###1. Just extract the shared logic into a plain function
This is probably the cleanest solution if you don't actually need HTTP isolation:
// lib/my-logic.ts (no "use server" directive)
export async function doTheThing(input: string) {
// shared business logic
return { result: input }
}
// app/api/route-a/route.ts
import { doTheThing } from '@/lib/my-logic'
export async function GET() {
return Response.json(await doTheThing('hello'))
}
// app/api/route-b/route.ts
import { doTheThing } from '@/lib/my-logic'
export async function POST(req: Request) {
const { input } = await req.json()
return Response.json(await doTheThing(input))
}
It bundles the code in both routes, but avoids the HTTP overhead entirely.
###2. What's driving the need for HTTP isolation?
There might be a better pattern depending on your use case:
- Different auth contexts? → Maybe middleware or a wrapper function
- Rate limiting per-route? → Could use Vercel's rate limiting at the edge
- Wanting microservice-style separation? → Might need separate Vercel projects or external services
- Testing/mocking? → Could use dependency injection patterns
- Avoiding code bundling for size reasons? → Dynamic imports or separate deployments
###3. Feature request territory?
Honestly, a built-in way to do internal route-to-route calls without HTTP (or with automatic bypass) would be a reasonable feature request for Next.js. Something like:
// hypothetical API
import { callRoute } from 'next/server'
const response = await callRoute('/api/other-route', { method: 'POST', body: {...} })
What's your actual use case? There might be a pattern that fits better than the HTTP approach.
END OF POST