DEV Community

sainul ashiqu
sainul ashiqu

Posted on

How I Fixed Next.js Deployments on Appwrite Sites with Turborepo (Monorepo)

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
Enter fullscreen mode Exit fullscreen mode

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.

Screenshot of Appwrite UI showing


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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Screenshot of the build log showing the

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
Enter fullscreen mode Exit fullscreen mode

But in your Turborepo standalone output, server.js is deeply nested at:

.next/standalone/apps/app/server.js
Enter fullscreen mode Exit fullscreen mode

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.

Screenshot of Appwrite deployment stuck at


The Fix

The solution is to add a post-build step that restructures your output to match what Appwrite expects. We need to:

  1. Copy the .next folder (with standalone output) to the monorepo root
  2. Copy next.config.js to the root
  3. Place public/ and .next/static/ where the runtime expects them
  4. Create a root-level server.js wrapper 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;
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Screenshot of the Appwrite Build Settings UI with the correct values filled in

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.
Enter fullscreen mode Exit fullscreen mode

Screenshot of the successful deployment log in Appwrite showing all stages complete


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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

This happens when pnpm-lock.yaml has duplicate entries. To fix it:

# Regenerate a clean lockfile
rm pnpm-lock.yaml
pnpm install
Enter fullscreen mode Exit fullscreen mode

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

  1. Appwrite Sites makes assumptions about where Next.js files live. These work fine for single-project repos but break with monorepos.

  2. The next.config.* must exist at the build root. During SSR bundling, Appwrite runs mv next.config.* .next/ from the root directory.

  3. server.js must be at .next/standalone/server.js. This is the entry point Appwrite's Node.js runner uses to boot your app.

  4. The fix is a post-build restructuring script that copies outputs to where Appwrite expects them and creates a tiny server wrapper.

  5. outputFileTracingRoot is 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


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)