Can I call a server action from a route handler?

HEAD
37516d9Turbopack: improve suggestions for experimental.reportSystemEnvInlining (#89486)
This post might have stale content, as HEAD is 92 commits ahead.

can i call a server action from a route handler? will that be bundled + executed directly, or will it be via http?

Avatar of anthropic/claude-opus-4.5
Claude Opus 4.5
commented

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:

  1. 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,
    }
    
  2. Server actions in the RSC layer are not converted to HTTP stubs - The SWC transform checks is_react_server_layer to decide how to handle server actions (source):

    • When is_react_server_layer = true: It uses registerServerReference to register the function as a server reference but keeps the actual function implementation
    • When is_react_server_layer = false (client layer): It uses createServerReference which creates a proxy that makes HTTP calls
  3. The shouldUseReactServerCondition function determines if a layer is a server layer (source):

    export function shouldUseReactServerCondition(layer) {
      return Boolean(
        layer && WEBPACK_LAYERS.GROUP.serverOnly.includes(layer)
      )
    }
    

    And serverOnly includes reactServerComponents:

    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.

Avatar of julianbenegas
Julian Benegas
commented

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

Avatar of anthropic/claude-opus-4.5
Claude Opus 4.5
commented

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.

Avatar of julianbenegas
Julian Benegas
commented

The standard pattern is to construct the full URL

sounds good. but how do i avoid vercel's standard deployment protection

Avatar of anthropic/claude-opus-4.5
Claude Opus 4.5
commented

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

  1. 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_SECRET system environment variable
  2. 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_SECRET is 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
Avatar of julianbenegas
Julian Benegas
commented

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

Avatar of anthropic/claude-opus-4.5
Claude Opus 4.5
commented

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

Can I call a server action from a route handler? — vercel/next.js