TL;DR: If your Appwrite Sites deployment builds successfully but gets stuck at "Finalizing" forever — the problem is that Appwrite's internal SSR bundler can't find your next.config.js and server.js at the monorepo root. Here's the exact fix.
The Problem
I'm building Mathsido, an ed-tech platform, using a Turborepo monorepo with Next.js 16 and deploying to Appwrite Sites. The folder structure looks like this:
mathsido/
├── apps/
│ ├── app/ ← Next.js app lives here
│ │ ├── next.config.js
│ │ ├── package.json
│ │ └── src/
│ └── landing/
├── packages/
│ ├── types/
│ ├── utils/
│ └── ui/
├── package.json ← root workspace
├── pnpm-workspace.yaml
└── turbo.json
When I deployed to Appwrite Sites, the build would succeed perfectly — all pages compiled, static pages generated, everything green. But then the deployment would get stuck on "Finalizing" for hours, sometimes days, and the site would never become reachable.
Understanding Appwrite's Build Pipeline
Before we fix the problem, let's understand what happens behind the scenes when Appwrite Sites deploys a Next.js app. This is key to understanding why the build fails in a monorepo.
Appwrite's Internal Build Steps
Appwrite Sites uses open-runtimes under the hood. When you trigger a deployment, it runs through these stages:
1. Environment Preparation → Sets up Node.js, installs dependencies
2. Build Command Execution → Runs YOUR build command (e.g., `pnpm build:app`)
3. SSR Bundling → Appwrite's INTERNAL step (this is where it breaks)
4. Build Packaging → Creates the deployment archive
5. Edge Distribution → Deploys to Appwrite's edge network
6. Screenshot Capturing → Takes a preview screenshot
The critical step is Step 3: SSR Bundling. This is NOT your code — it's Appwrite's internal script that prepares your Next.js app for their serverless runner.
What Appwrite Expects (The Conventions)
Here's where it gets interesting. During the SSR Bundling step, Appwrite's runner makes several assumptions about your project structure:
| What Appwrite Looks For | Expected Location | Why |
|---|---|---|
next.config.js / next.config.mjs / next.config.ts
|
Root of the build directory (/usr/local/build/) |
Appwrite runs mv next.config.* .next/ to bundle the config |
.next/ folder |
Root of the build directory | This is where Next.js build output lives |
.next/standalone/server.js |
Root of .next/standalone/ |
Appwrite boots your app using this entry point |
public/ folder |
Alongside server.js |
Static assets need to be accessible at runtime |
.next/static/ |
Inside .next/ next to server.js |
Client-side JS/CSS bundles |
These conventions work perfectly for a standard single-project Next.js app:
my-app/ ← Appwrite builds from here
├── next.config.js ✅ Found at root
├── public/ ✅ Found at root
├── .next/
│ ├── static/ ✅ Found inside .next
│ └── standalone/
│ └── server.js ✅ Found at standalone root
└── package.json
But in a Monorepo, Everything is Nested
When you use Turborepo with output: 'standalone' and outputFileTracingRoot (which you must set for monorepos), Next.js generates the standalone output relative to your monorepo root, not the app directory.
Here's what the build output actually looks like:
/usr/local/build/ ← Appwrite's build root
├── next.config.js ❌ MISSING (it's in apps/app/)
├── .next/ ❌ MISSING (it's in apps/app/.next/)
├── apps/
│ └── app/
│ ├── next.config.js ← Appwrite can't find this
│ └── .next/
│ ├── static/
│ └── standalone/
│ ├── apps/
│ │ └── app/
│ │ └── server.js ← Deeply nested!
│ └── node_modules/
├── package.json
└── turbo.json
This mismatch causes two cascading failures:
Failure #1: The next.config.* Error
During SSR Bundling, Appwrite runs something like:
mv /usr/local/build/next.config.* /usr/local/build/.next/
Since next.config.js lives inside apps/app/, not at the root, the mv command fails:
mv: can't rename '/usr/local/build/next.config.*': No such file or directory
In some cases, this error is non-fatal and the build continues, but it's the first red flag.
Failure #2: The "Finalizing" Hang (The Silent Killer)
Even if the build completes and packaging succeeds, Appwrite's runtime needs to find server.js to boot your app. It looks for it at:
.next/standalone/server.js
But in your Turborepo standalone output, server.js is deeply nested at:
.next/standalone/apps/app/server.js
Appwrite can't find the entry point, fails to start the server, can't take a screenshot, and the deployment gets stuck in "Finalizing" indefinitely. There's no error, no timeout — it just... hangs.
The Fix
The solution is to add a post-build step that restructures your output to match what Appwrite expects. We need to:
- Copy the
.nextfolder (with standalone output) to the monorepo root - Copy
next.config.jsto the root - Place
public/and.next/static/where the runtime expects them - Create a root-level
server.jswrapper that delegates to the real nested server
Step 1: Update next.config.js
First, make sure your Next.js config has these critical settings:
// apps/app/next.config.js
const path = require('path');
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
// Required for Appwrite SSR deployment
output: 'standalone',
// Required for monorepo: tells Next.js to trace dependencies
// starting from the monorepo root, not just this app's directory
outputFileTracingRoot: path.join(__dirname, '../../'),
// If you use node-appwrite SDK, mark it as external
serverExternalPackages: ['node-appwrite'],
// List your internal workspace packages
transpilePackages: [
'@mathsido/types',
'@mathsido/utils',
// ... your packages
],
};
module.exports = nextConfig;
Why output: 'standalone'? This tells Next.js to create a self-contained server that includes only the files needed for production. Appwrite's SSR runner specifically looks for this output format.
Why outputFileTracingRoot? In a monorepo, your app imports code from packages/. Without this setting, Next.js only traces files inside apps/app/, missing all your shared packages. Setting it to the monorepo root (../../ relative to apps/app/) ensures everything is included.
Step 2: Create the Appwrite Build Script
Add this script to your root package.json:
{
"scripts": {
"build:app": "turbo run build --filter=@mathsido/app",
"build:app:appwrite": "pnpm build:app && rm -rf .next && mkdir -p .next/standalone && cp -r apps/app/.next/standalone/* .next/standalone/ && cp -r apps/app/public .next/standalone/apps/app/public && cp -r apps/app/.next/static .next/standalone/apps/app/.next/static && echo \"require('./apps/app/server.js')\" > .next/standalone/server.js"
}
}
Let's break down what this script does:
# 1. Run the normal Turborepo build
pnpm build:app
# 2. Clean any previous .next at root (avoid conflicts)
rm -rf .next
# 3. Create the directory structure Appwrite expects
mkdir -p .next/standalone
# 4. Copy the entire standalone output to root .next/standalone/
cp -r apps/app/.next/standalone/* .next/standalone/
# 5. Copy public assets to where the server expects them
cp -r apps/app/public .next/standalone/apps/app/public
# 6. Copy static client bundles (JS/CSS) to the right place
cp -r apps/app/.next/static .next/standalone/apps/app/.next/static
# 7. Create a root server.js wrapper that delegates to the real one
echo "require('./apps/app/server.js')" > .next/standalone/server.js
The magic is in step 7: we create a tiny server.js at the root of standalone/ that simply requires the real server. When Appwrite runs node server.js, it finds our wrapper, which boots the actual Next.js server from its nested location.
Step 3: Configure Appwrite Build Settings
In the Appwrite Console, go to your Site → Settings → Build, and update:
| Setting | Value |
|---|---|
| Framework | Next.js |
| Install command | pnpm install |
| Build command | pnpm run build:app:appwrite |
| Output directory | .next/standalone |
| Rendering mode | Server side rendering |
Step 4: Deploy!
Push your changes and trigger a new deployment. You should now see the complete build pipeline succeed:
[open-runtimes] Environment preparation started.
[open-runtimes] Environment preparation finished.
[open-runtimes] Build command execution started.
...
✓ Compiled successfully
✓ Generating static pages (17/17)
...
[open-runtimes] Bundling for SSR started.
[open-runtimes] Bundling for SSR finished. ← No more errors!
[open-runtimes] Build packaging started.
[open-runtimes] Build packaging finished.
[appwrite] Edge distribution started.
[appwrite] Edge distribution finished (6/6). ← Success!
[appwrite] Deployment finished.
The Complete Picture
Here's a visual summary of the problem and the fix:
Before (Broken)
Appwrite looks for: What Turborepo generates:
───────────────── ────────────────────────
/usr/local/build/ /usr/local/build/
├── next.config.js ❌ MISS ├── apps/app/next.config.js
├── .next/ ├── apps/app/.next/
│ └── standalone/ │ └── standalone/
│ └── server.js ❌ MISS │ └── apps/app/server.js
└── public/ ❌ MISS └── apps/app/public/
After (Fixed)
/usr/local/build/
├── next.config.js ← Copied from apps/app/
├── .next/ ← Copied from apps/app/.next/
│ └── standalone/
│ ├── server.js ← Wrapper: require('./apps/app/server.js')
│ ├── apps/
│ │ └── app/
│ │ ├── server.js ← Real Next.js server
│ │ ├── public/ ← Static assets
│ │ └── .next/
│ │ └── static/ ← Client bundles
│ └── node_modules/
└── package.json
Bonus: Fixing the Broken Lockfile Warning
You might also notice this warning in your build logs:
WARN Ignoring broken lockfile: duplicated mapping key (1032:3)
This happens when pnpm-lock.yaml has duplicate entries. To fix it:
# Regenerate a clean lockfile
rm pnpm-lock.yaml
pnpm install
This is benign — pnpm will skip the broken lockfile and resolve fresh — but it adds ~10-15 seconds to every build. Fixing it once saves time on every deployment.
Key Takeaways
Appwrite Sites makes assumptions about where Next.js files live. These work fine for single-project repos but break with monorepos.
The
next.config.*must exist at the build root. During SSR bundling, Appwrite runsmv next.config.* .next/from the root directory.server.jsmust be at.next/standalone/server.js. This is the entry point Appwrite's Node.js runner uses to boot your app.The fix is a post-build restructuring script that copies outputs to where Appwrite expects them and creates a tiny server wrapper.
outputFileTracingRootis mandatory for monorepos. Without it, Next.js won't include your shared packages in the standalone build.
Environment & Versions
For reference, here's the exact stack this was tested with:
| Tool | Version |
|---|---|
| Next.js | 16.1.6 |
| Turborepo | 2.8.10 |
| pnpm | 10.0.0 / 9.15.9 (Appwrite) |
| Node.js | 22.x |
| Appwrite Sites | Cloud (Feb 2026) |
Useful Links
- Appwrite Sites Documentation
- Next.js Standalone Output
- Turborepo Getting Started
- open-runtimes GitHub
If this helped you, share it with someone else stuck on the same issue. Happy deploying! 🚀
Tags: #nextjs #appwrite #turborepo #monorepo #deployment #devops #webdev #typescript






Top comments (0)