You push a branch, open a pull request, and wait for CI to pass. The tests go green, but you can't actually *see* the changes in a browser until you merge to main and deploy to production. Your reviewer has to pull the branch locally, run npm install && npm run dev, and hope their environment matches yours.
Preview environments fix this. Every pull request gets its own deployed URL. You push a commit, the branch auto-deploys, and the reviewer clicks a link to see the live site. No local setup, no guessing whether it works in production. If the PR breaks something, the preview shows it before you merge.
How preview environments work
- 1You push a branch (
feature/new-homepage) and open a pull request. - 2A GitHub webhook fires and tells the platform "branch
feature/new-homepagehas new commits." - 3The platform creates a new project (or updates an existing one) for that branch.
- 4The build runs, the static export is deployed, and you get a URL:
https://nextjs-app-feature-new-homepage-xyz.pandastack.app. - 5You add the URL to the PR as a comment (manually or via GitHub Actions).
- 6The reviewer clicks it, tests the changes, approves the PR.
- 7You merge to main, and the production deployment updates.
Setting up a Next.js static export
Next.js can run as a server (Node.js runtime) or as a static site (pre-rendered HTML). For preview environments, static exports are simpler — no server to keep running, no cold starts, no scaling concerns. The trade-off: no server-side rendering or API routes at request time. Everything is baked at build time.
Add output: 'export' to next.config.js:
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
images: {
unoptimized: true // Image optimization requires a server
}
};
module.exports = nextConfig;Run npm run build. Next.js outputs static files to out/. This is what gets deployed.
Deploying the main branch first
Before setting up preview environments, deploy the production site from main:
curl -X POST https://api.pandastack.io/v1/projects \
-H "Authorization: Bearer $PANDASTACK_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"slug": "static",
"name": "nextjs-app",
"repositoryName": "yourorg/nextjs-app",
"branch": "main",
"autoDeploy": true,
"buildCommand": "npm run build",
"outputDir": "out"
}'This creates a project that auto-deploys whenever main is pushed. The URL is stable (e.g. https://nextjs-app-abc123.pandastack.app). You can add a custom domain later.
Auto-deploying feature branches
For preview environments, enable autoDeploy on a wildcard branch pattern or deploy each branch manually via the API. The second approach (manual API calls) gives you more control over when previews are created and deleted.
Here's a GitHub Actions workflow that deploys every branch as a preview:
name: Deploy Preview
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy preview
run: |
BRANCH_NAME=$(echo "${{ github.head_ref }}" | sed 's/\//-/g')
PROJECT_NAME="nextjs-app-preview-${BRANCH_NAME}"
curl -X POST https://api.pandastack.io/v1/projects \
-H "Authorization: Bearer ${{ secrets.PANDASTACK_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{
\"slug\": \"static\",
\"name\": \"${PROJECT_NAME}\",
\"repositoryName\": \"${{ github.repository }}\",
\"branch\": \"${{ github.head_ref }}\",
\"autoDeploy\": true,
\"buildCommand\": \"npm run build\",
\"outputDir\": \"out\"
}" \
> response.json
DEPLOY_URL=$(jq -r '.data.deploymentUrl' response.json)
echo "DEPLOY_URL=${DEPLOY_URL}" >> $GITHUB_ENV
- name: Comment PR
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `Preview deployed: ${process.env.DEPLOY_URL}`
})What this does:
- Triggers on PR open, sync (new commits), or reopen.
- Sanitises the branch name (replaces
/with-sofeature/new-homepagebecomesfeature-new-homepage). - Creates a new project with a unique name (
nextjs-app-preview-feature-new-homepage). - Deploys the branch with
autoDeploy: true, so every subsequent push auto-deploys. - Posts the preview URL as a PR comment.
Storing the project ID for updates
The first time the workflow runs, it creates a new project. On subsequent commits to the same PR, you want to update the existing project, not create duplicates. Store the project ID in the PR or in a database.
One approach: use PR labels to track the project ID. After the first deploy, add a label like preview:abc123 (where abc123 is the project ID). On subsequent runs, check for the label, extract the ID, and redeploy:
- name: Check for existing preview
id: check
run: |
PROJECT_ID=$(gh pr view ${{ github.event.pull_request.number }} --json labels --jq '.labels[] | select(.name | startswith("preview:")) | .name | split(":")[1]')
echo "PROJECT_ID=${PROJECT_ID}" >> $GITHUB_ENV
- name: Deploy or redeploy
run: |
if [ -z "$PROJECT_ID" ]; then
# Create new project (same as above)
...
# Add label
gh pr edit ${{ github.event.pull_request.number }} --add-label "preview:${PROJECT_ID}"
else
# Redeploy existing project
curl -X POST https://api.pandastack.io/v1/projects/${PROJECT_ID}/deploy \
-H "Authorization: Bearer ${{ secrets.PANDASTACK_TOKEN }}"
fiThis avoids creating a new project on every commit.
Cleaning up preview environments
When a PR is merged or closed, delete the preview to avoid accumulating stale deployments:
name: Cleanup Preview
on:
pull_request:
types: [closed]
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Get project ID from label
id: get_id
run: |
PROJECT_ID=$(gh pr view ${{ github.event.pull_request.number }} --json labels --jq '.labels[] | select(.name | startswith("preview:")) | .name | split(":")[1]')
echo "PROJECT_ID=${PROJECT_ID}" >> $GITHUB_ENV
- name: Delete project
if: env.PROJECT_ID != ''
run: |
curl -X DELETE https://api.pandastack.io/v1/projects/${PROJECT_ID} \
-H "Authorization: Bearer ${{ secrets.PANDASTACK_TOKEN }}"This runs when a PR is closed (merged or abandoned) and deletes the associated preview project.
Using pandastack.json for consistent config
If your Next.js app has environment variables (e.g. NEXT_PUBLIC_API_URL), you want the same config in production and previews. Define them in pandastack.json:
{
"type": "static",
"name": "nextjs-app",
"buildCommand": "npm run build",
"outputDir": "out",
"env": [
{
"key": "NEXT_PUBLIC_API_URL",
"description": "Backend API endpoint"
}
]
}The GitHub Actions workflow can read this file and pass the environment variables to both production and preview deploys. Alternatively, set different values per environment:
- Production:
NEXT_PUBLIC_API_URL=https://api.example.com - Preview:
NEXT_PUBLIC_API_URL=https://api-staging.example.com
Comparing this to Vercel and Netlify
Vercel: preview environments are automatic. Every branch gets a URL, no workflow needed. The trade-off: less control over when previews are created or deleted, and they count toward your bandwidth and build minute limits.
Netlify: similar automatic previews. You can configure which branches trigger previews via the Netlify UI.
PandaStack: manual control via the API. You write the GitHub Actions workflow, decide when to create and delete previews, and customise the naming scheme. This is more flexible but requires a few lines of YAML.
For smaller teams or personal projects, manual control is fine. For larger teams deploying dozens of PRs per day, automatic previews (Vercel-style) save time.
Debugging "Preview deployed but shows old content"
If you push a commit and the preview URL still shows the old version:
- 1Check the deployment status in the dashboard. The build might have failed.
- 2Verify the
branchparameter in the API call matches the PR branch (github.head_ref). - 3Clear your browser cache. The CDN caches HTML for a short time; a hard refresh (
Cmd+Shift+R) bypasses the cache.
If the build succeeded but the site is definitely stale, the deployment might be pointing at the wrong commit. Check the commit SHA in the deployment logs.
Environment-specific configuration
You might want different values for preview vs production:
// next.config.js
const isProd = process.env.BRANCH_NAME === 'main';
const nextConfig = {
output: 'export',
basePath: isProd ? '' : `/preview/${process.env.BRANCH_NAME}`,
assetPrefix: isProd ? '' : `/preview/${process.env.BRANCH_NAME}`
};Pass BRANCH_NAME as an environment variable in the deploy payload:
{
"env": [
{ "name": "BRANCH_NAME", "value": "feature-new-homepage" }
]
}Now preview builds include the branch name in the asset paths, making it easier to debug which version is deployed.
The CLI alternative
If you're testing locally and want a quick preview without opening a PR:
panda login
panda projects create \
--repo yourorg/nextjs-app \
--branch feature/new-homepage \
--name nextjs-preview-new-homepage \
--type static \
--build-command "npm run build" \
--output-dir outThe CLI reads pandastack.json if it exists, or you can pass everything as flags. After the deploy, you get a URL. Share it with teammates, test it, then delete the project when you're done:
panda projects delete <project-id>What you've built
A GitHub Actions workflow that deploys every pull request as a preview environment, posts the URL as a PR comment, and cleans up when the PR is merged. Reviewers click the link and see the changes in a real browser, with no local setup required.
Next.js static exports are served from a CDN with zero idle cost. Hashed assets are cached for a year, index.html is revalidated on every deploy. The platform purges the cache automatically when you redeploy, so users never see stale JavaScript.
Preview environments are free on the Free tier (up to 5 static sites). If you need more simultaneous previews, the Pro plan ($15/mo) allows unlimited static sites. Bandwidth limits are 100 GB/month (Free) or 500 GB/month (Pro).
References
- [Next.js static exports](https://nextjs.org/docs/app/building-your-application/deploying/static-exports)
- [GitHub Actions syntax](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions)
- [PandaStack API projects endpoints](https://docs.pandastack.io/api/projects/)
- [PandaStack static sites](https://docs.pandastack.io/projects/static/)