Hybrid Next.js App: Static + SSR + API Routes – “Catch 22” with Vercel, .api.ts
Suffix, and Static Export
My SSR/default vercel builds are failing, seemingly because I have named my route files with a .api.ts
suffix. I include this in my pageExtensions
for my default build, and exclude them from my static build where my client pages call the endpoints from the SSR build. This is required for my static capacitor build.
Project Context
- Framework: Next.js
15.2.4
- Deployment: Vercel (builds triggered on push to a branch)
- Environment: All required environment variables are defined in Vercel
- Static Build: Exists to support a Capacitor-based mobile app (using
output: 'export'
) - SSR Build: Standard Vercel SSR deployment for web
- API Routes: Many dynamic endpoints under
src/app/api
Relevant File Structure
src/app/api/
├── auth/
│ └── reset-password/
│ └── route.api.ts
├── calendly/
│ ├── callback/
│ │ └── route.api.ts
│ ├── end-of-day/
│ │ └── route.api.ts
│ ├── profile/
│ │ └── route.api.ts
│ ├── reset/
│ │ └── route.api.ts
│ ├── route.api.ts
│ └── status/
│ └── route.api.ts
├── jobs/
│ ├── dequeueLeads/
│ │ └── route.api.ts
│ └── updateAvailability/
│ └── route.api.ts
├── leads/
│ └── [userId]/
│ └── route.api.ts
├── login/
│ └── route.api.ts
├── onboarding/
│ └── route.api.ts
├── queue/
│ ├── cron/
│ │ └── route.api.ts
│ ├── increment/
│ │ └── route.api.ts
│ ├── push/
│ │ └── route.api.ts
│ ├── recently-contacted/
│ │ └── [userId]/
│ │ └── route.api.ts
│ ├── status/
│ │ └── route.api.ts
│ └── [userId]/
│ └── route.api.ts
└── user/
├── cover-photo/
│ └── route.api.ts
├── route.api.ts
└── search/
└── route.api.ts
Build Configuration (next.config.mjs
)
// Configuration for Capacitor (static export)
const capacitorConfig = {
output: 'export',
reactStrictMode: true,
images: { unoptimized: true },
pageExtensions: ['tsx', 'ts', 'jsx', 'js'],
distDir: 'out',
};
// Default configuration for SSR/Serverful deployments
const defaultConfig = {
reactStrictMode: true,
images: { unoptimized: false },
pageExtensions: ['tsx', 'ts', 'jsx', 'js', 'api.ts', 'web.tsx'],
};
const isCapacitorBuild = process.env.NEXT_PUBLIC_BUILD_TARGET === 'capacitor';
export default isCapacitorBuild ? capacitorConfig : defaultConfig;
- Intent:
- For SSR: Include
.api.ts
so API routes are available. - For static export: Exclude
.api.ts
so API routes are not included (since static build cannot support them).
- For SSR: Include
- Note: The static build is used by the Capacitor mobile app, which calls the SSR endpoints deployed on Vercel.
The Catch-22
If I use .api.ts
for API routes:
- SSR build works locally (API routes included).
- Static build works locally (API routes excluded).
- But on Vercel, the SSR/default build faiIs with this error:
Traced Next.js server files in: 60.047ms Error: ENOENT: no such file or directory, lstat '/vercel/path0/.next/server/app/api/calendly/callback/route_client-reference-manifest.js' Exiting build container
- This seems to be because Vercel expects a
route.ts
for every route reference, and the exclusion viapageExtensions
causes a mismatch. Meanwhile I’m providing aroute.api.ts
and including this file suffix:.api.ts
in my pageExtensions.
If I use route.ts
for API routes and try to force static:
- Adding
export const dynamic = "force-static";
or similar does not work for dynamic API endpoints. - Static build fails with errors like:
Error occurred prerendering page "/api/calendly". Error: Route /api/calendly with `dynamic = "error"` couldn't be rendered statically because it used `request.url`. Export encountered an error on /api/calendly/route: /api/calendly, exiting the build.
- Why?
- Many API endpoints (e.g.,
/api/calendly/callback
) are fundamentally dynamic: they need to handle OAuth callbacks, parse query params, and userequest.url
at runtime. - Static export cannot support these, and Next.js throws errors if you try.
- Many API endpoints (e.g.,
Example: Why Static Routing Won’t Work (Calendly Callback)
- The endpoint
/api/calendly/callback/route.ts
handles OAuth callbacks from Calendly. - It must:
- Parse the incoming request’s URL and query parameters.
- Handle dynamic, user-specific, and time-sensitive data.
- Static export cannot possibly pre-generate all possible callback URLs or handle dynamic runtime data.
- Forcing this route to be static results in build errors, as Next.js cannot resolve the dynamic logic at build time.
Summary of the Problem
- I need both SSR (with API routes) and a static build (for Capacitor/mobile).
- Excluding API routes via file extension works locally, but fails on Vercel.
- Forcing API routes to be static is not possible due to their dynamic nature.
- There seems to be no clean way to have a hybrid Next.js app with both SSR API routes and a static export for mobile, using a single codebase and Vercel.
What I’m Looking For
- Is there a best-practice way to structure a Next.js project to support both SSR (with API routes) and a static export (for Capacitor/mobile) on Vercel?
- How can I avoid the ENOENT error on Vercel when excluding API routes from the static build?
- Is there a recommended pattern for hybrid SSR/static + API route projects, or is a monorepo/separate app approach the only way?
Any advice, workarounds, or pointers to official guidance would be greatly appreciated!
The only idea I’ve had is writing a script to use for generating my static build that moves the api folder out of the app folder, then moves it back after the build runs. I bet this would work, but boy is it a hack I’d rather avoid.