next-forge

GitHub

Production-grade Turborepo template for Next.js apps.

AI Prompts & Endpoints
CodeWiki Knowledge Base

README

Geistdocs

A modern documentation template built with Next.js and Fumadocs. Designed for spinning up Vercel documentation sites quickly and consistently with built-in AI chat, GitHub discussions integration, and a beautiful UI.

Features

- 📝 MDX-powered documentation - Write docs in MDX with full component support
- 🤖 AI-powered chat - Built-in AI assistant that understands your documentation
- 💬 GitHub Discussions integration - Allow users to provide feedback directly to GitHub
- 🎨 Modern UI - Beautiful, accessible components built with Radix UI
- 🔍 Advanced search - Fast, fuzzy search through all documentation
- 🌙 Dark mode - Built-in theme switching
- 📱 Responsive - Mobile-first design that works everywhere
- ⚡ Fast - Built on Next.js 16 with App Router for optimal performance
- 📰 RSS - Built-in RSS feed for your documentation

Read the docs to get started.

---

Content/Docs/Examples/Ai Chatbot

---
title: Create an AI Chatbot
description: Let's use next-forge to create a simple AI chatbot.
type: guide
summary: Build a simple AI chatbot using next-forge and Vercel AI SDK.
prerequisites:
- /docs/setup/quickstart
---

Today we're going to create an AI chatbot using next-forge and the built-in AI package, powered by Vercel AI SDK.

1. Create a new project

``bash title="Terminal"
npx next-forge@latest init ai-chatbot

text
This will create a new project with the name ai-chatbot and install the necessary dependencies.

2. Configure your environment variables

Follow the guide on Environment Variables to fill in your environment variables.

Specifically, make sure you set an OPENAI_API_KEY environment variable to your apps/app/.env.local file.

<Tip>Make sure you have some credits in your OpenAI account.</Tip>

3. Create the chatbot UI

We're going to start by creating a simple chatbot UI with a text input and a button to send messages.

Create a new file called Chatbot in the app/components directory. We're going to use a few things here:

- useChat from our AI package to handle the chat logic.
-
Button and Input components from our Design System to render the form.
-
Thread and Message components from our AI package to render the chat history.
-
handleError from our Design System to handle errors.
-
SendIcon from lucide-react to create a send icon.

tsx title="apps/app/app/(authenticated)/components/chatbot.tsx"
'use client';

import { Message } from '@repo/ai/components/message';
import { Thread } from '@repo/ai/components/thread';
import { useChat } from '@repo/ai/lib/react';
import { Button } from '@repo/design-system/components/ui/button';
import { Input } from '@repo/design-system/components/ui/input';
import { handleError } from '@repo/design-system/lib/utils';
import { SendIcon } from 'lucide-react';

export const Chatbot = () => {
const { messages, input, handleInputChange, isLoading, handleSubmit } =
useChat({
onError: handleError,
api: '/api/chat',
});

return (
<div className="flex h-[calc(100vh-64px-16px)] flex-col divide-y overflow-hidden">
<Thread>
{messages.map((message) => (
<Message key={message.id} data={message} />
))}
</Thread>
<form
onSubmit={handleSubmit}
className="flex shrink-0 items-center gap-2 px-8 py-4"
aria-disabled={isLoading}
>
<Input
placeholder="Ask a question!"
value={input}
onChange={handleInputChange}
/>
<Button type="submit" size="icon" disabled={isLoading}>
<SendIcon className="h-4 w-4" />
</Button>
</form>
</div>
);
};

text

4. Create the chatbot API route

Create a new file called chat in the app/api directory. This Next.js route handler will handle the chatbot's responses.

We're going to use the streamText function from our AI package to stream the chatbot's responses to the client. We'll also use the provider function from our AI package to get the OpenAI provider, and the log function from our Observability package to log the chatbot's responses.

tsx title="apps/app/app/api/chat/route.ts"
import { streamText } from '@repo/ai';
import { log } from '@repo/observability/log';
import { models } from '@repo/ai/lib/models';

export const POST = async (req: Request) => {
const body = await req.json();

log.info('🤖 Chat request received.', { body });
const { messages } = body;

log.info('🤖 Generating response...');
const result = streamText({
model: models.chat,
system: 'You are a helpful assistant.',
messages,
});

log.info('🤖 Streaming response...');
return result.toDataStreamResponse();
};

text

5. Update the app

Finally, we'll update the app/page.tsx file to be a simple entry point that renders the chatbot UI.

tsx title="apps/app/app/(authenticated)/page.tsx"
import { auth } from '@repo/auth/server';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { Chatbot } from './components/chatbot';
import { Header } from './components/header';

const title = 'Acme Inc';
const description = 'My application.';

export const metadata: Metadata = {
title,
description,
};

const App = async () => {
const { orgId } = await auth();

if (!orgId) {
notFound();
}

return (
<>
<Header pages={['Building Your Application']} page="AI Chatbot" />
<Chatbot />
</>
);
};

export default App;

text

6. Run the app

Run the app development server and you should be able to see the chatbot UI at http://localhost:3000.

sh title="Terminal"
bun dev --filter app
text
That's it! You've now created an AI chatbot using next-forge and the built-in AI package. If you have any questions, please reach out to me on Twitter or open an issue on GitHub.

---

Content/Docs/Deployment/Docker

---
title: Deploying with Docker
description: How to deploy next-forge with Docker for self-hosting.
type: guide
summary: How to deploy next-forge with Docker for self-hosting.
prerequisites:
- /docs/setup/env
related:
- /docs/deployment/vercel
- /docs/deployment/netlify
---

Docker lets you self-host next-forge on any platform that supports containers — such as Railway, Fly.io, Coolify, DigitalOcean App Platform, or your own server.

Enable standalone output

First, you'll need to enable Next.js standalone output in your shared config. This creates a self-contained build that includes only the files needed to run your app, significantly reducing the final image size.

In packages/next-config/index.ts, add the output property:

ts title="packages/next-config/index.ts"
export const config: NextConfig = {
output: "standalone",

// ... rest of your config
};

text

Create a Dockerfile

Create a Dockerfile in the root of your repository. This uses a multi-stage build to keep the final image small:

dockerfile title="Dockerfile"
FROM oven/bun:1 AS base

Stage 1: Install dependencies


FROM base AS deps
WORKDIR /app
COPY package.json bun.lock turbo.json ./
COPY apps/ ./apps/
COPY packages/ ./packages/
RUN bun install --frozen-lockfile

Stage 2: Build the application


FROM base AS builder
WORKDIR /app
COPY --from=deps /app/ ./

Set the app to build: app, web, or api


ARG APP_NAME=app
ENV APP_NAME=$APP_NAME

Add build-time environment variables here


ARG DATABASE_URL


ENV DATABASE_URL=$DATABASE_URL

RUN bun run build --filter=@repo/${APP_NAME}

Stage 3: Production runner


FROM node:20-slim AS runner
WORKDIR /app

ENV NODE_ENV=production

RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs

Set the app to run


ARG APP_NAME=app
ENV APP_NAME=$APP_NAME

COPY --from=builder /app/apps/${APP_NAME}/public ./apps/${APP_NAME}/public
COPY --from=builder --chown=nextjs:nodejs /app/apps/${APP_NAME}/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/${APP_NAME}/.next/static ./apps/${APP_NAME}/.next/static

USER nextjs

EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

CMD ["node", "apps/${APP_NAME}/server.js"]

text
<Callout type="info">
The
CMD above uses a shell-interpolated variable. If your container runtime doesn't support this, replace ${APP_NAME} with the actual app name e.g. node apps/app/server.js.
</Callout>

Create a .dockerignore

Add a .dockerignore to speed up builds and keep secrets out of the image:

txt title=".dockerignore"
node_modules
.next
.git
.env
.env.*
text

Build and run

Build and run the image for a specific app by passing the APP_NAME build argument:

bash

Build the app


docker build --build-arg APP_NAME=app -t next-forge-app .

Run it


docker run -p 3000:3000 --env-file .env.local next-forge-app
text
Repeat for web and api if you want to deploy all three.

Using Docker Compose

If you'd prefer to run all apps together, create a docker-compose.yml:

yaml title="docker-compose.yml"
services:
app:
build:
context: .
args:
APP_NAME: app
ports:
- "3000:3000"
env_file:
- .env.local

web:
build:
context: .
args:
APP_NAME: web
ports:
- "3001:3000"
env_file:
- .env.local

api:
build:
context: .
args:
APP_NAME: api
ports:
- "3002:3000"
env_file:
- .env.local

text
Then run everything with:
bash
docker compose up --build
text

Environment variables

When deploying with Docker, pass your environment variables at runtime using --env-file or -e flags. Do not bake secrets into the image. Learn more about how environment variables work in next-forge.

If certain variables are needed at build time (e.g. DATABASE_URL for Prisma), uncomment the relevant ARG and ENV lines in the builder stage.

---

Content/Docs/Deployment/Netlify

---
title: Deploying to Netlify
description: How to deploy next-forge to Netlify.
type: guide
summary: How to deploy next-forge to Netlify.
prerequisites:
- /docs/setup/env
related:
- /docs/deployment/vercel
---

To deploy next-forge on Netlify, you need to create 3 new projects for the app, api and web apps. After selecting your repository, change the "Site to deploy" selection to the app of choice e.g. apps/app. This should automatically detect the Next.js setup and as such, the build command and output directory.

Then, add all your environment variables to the project.

Finally, just hit "Deploy" and Netlify will take care of the rest!

Environment variables

If you're deploying on Netlify, we recommend making use of the Shared Environment Variables feature. Variables used by libraries need to exist in all packages and duplicating them can be a headache. Learn more about how environment variables work in next-forge.

---

Content/Docs/Deployment/Vercel

---
title: Deploying to Vercel
description: How to deploy next-forge to Vercel.
type: guide
summary: How to deploy next-forge to Vercel.
prerequisites:
- /docs/setup/env
related:
- /docs/deployment/netlify
---

To deploy next-forge on Vercel, you need to create 3 new projects for the app, api and web apps. After selecting your repository, change the Root Directory option to the app of choice e.g. apps/app. This should automatically detect the Next.js setup and as such, the build command and output directory.

Then, add all your environment variables to the project.

Finally, just hit "Deploy" and Vercel will take care of the rest!

Want to see it in action? next-forge is featured on the Vercel Marketplace - try deploying the app:

<VercelButton />

Environment variables

If you're deploying on Vercel, we recommend making use of the Team Environment Variables feature. Variables used by libraries need to exist in all packages and duplicating them can be a headache. Learn more about how environment variables work in next-forge.

Integrations

We also recommend installing the BetterStack and Sentry integrations. This will take care of the relevant environment variables.

---

Content/Docs/Apps/Api

---
title: API
description: How the "API" application works in next-forge.
type: reference
product: API
summary: How the API application works in next-forge.
related:
- /docs/packages/security/rate-limiting
- /docs/packages/webhooks/inbound
---

<Tip>The api application runs on port 3002. We recommend deploying it to api.{yourdomain}.com.</Tip>

next-forge exports the API from the apps/api directory. It is designed to be run separately from the main app, and is used to run isolate functions that are not part of the main user-facing application e.g. webhooks, cron jobs, etc.

Overview

The API is designed to run serverless functions, and is not intended to be used as a traditional Node.js server. However, it is designed to be as flexible as possible, and you can switch to running a traditional server if you need to.

Functionally speaking, splitting the API from the main app doesn't matter if you're running these projects on Vercel. Serverless functions are all independent pieces of infrastructure and can scale independently. However, having it run independently provides a dedicated endpoint for non-web applications e.g. mobile apps, smart home devices, etc.

Features

- Cron jobs: The API is used to run cron jobs. These are defined in the apps/api/app/cron directory. Each cron job is a .ts file that exports a route handler.
- Webhooks: The API is used to run inbound webhooks. These are defined in the
apps/api/app/webhooks directory. Each webhook is a .ts file that exports a route handler.

Connecting to the API

By default, the API app only handles webhooks and cron jobs. However, you can add your own endpoints for use by the app, web, or external clients like mobile apps.

When you need the API

In many cases, you don't need to call the API app at all. Since app and web are both Next.js applications, they can use Server Components, Server Actions, and Route Handlers to access the database and other services directly through shared packages like @repo/database.

The API app is useful when you need:

- A dedicated endpoint for non-web clients (mobile apps, IoT devices, third-party integrations)
- A public REST API for your platform
- Long-running or resource-intensive operations isolated from your user-facing apps

Adding an endpoint

Create a new route handler in apps/api/app. For example, to create a /users endpoint:

ts title="apps/api/app/users/route.ts"
import { database } from '@repo/database';

export const GET = async () => {
const users = await database.user.findMany();

return Response.json(users);
};

text

Calling the API from another app

Each app has a NEXT_PUBLIC_API_URL environment variable pre-configured in its .env.example file, pointing to http://localhost:3002 for local development. Use this variable when making requests to the API.

From a Server Component or Server Action:

ts title="apps/app/app/actions/users.ts"
'use server';

import { env } from '@/env';

export const getUsers = async () => {
const response = await fetch(
${env.NEXT_PUBLIC_API_URL}/users);

return response.json();
};

text
From a client component:
tsx title="apps/app/components/users.tsx"
'use client';

const Users = () => {
const fetchUsers = async () => {
const response = await fetch(
${process.env.NEXT_PUBLIC_API_URL}/users
);

return response.json();
};

// ...
};

text

Preview deployments

In local development, the API URL defaults to http://localhost:3002. In production, you set NEXT_PUBLIC_API_URL to your API's production URL (e.g. https://api.yourdomain.com).

For preview deployments on Vercel, each project gets a unique URL. Since the app or web preview can't automatically discover the api preview URL, you have a few options:

1. Point previews at the production API. Set NEXT_PUBLIC_API_URL to your production API URL in Vercel's environment variable settings for "Preview" environments. This is the simplest approach and works well if your API is stable.
2. Use Vercel's branch-based URLs. Vercel generates deterministic URLs based on the branch name (e.g.
api-git-my-branch-yourteam.vercel.app). You can construct the API URL from the VERCEL_GIT_COMMIT_REF environment variable if all apps share the same repository and branch.
3. Set the URL manually per preview. For full isolation, override
NEXT_PUBLIC_API_URL in the Vercel deployment settings for each preview deployment.

---

Content/Docs/Apps/App

---
title: App
description: How the main application works in next-forge.
type: reference
product: App
summary: How the main user-facing application works in next-forge.
related:
- /docs/packages/authentication
- /docs/packages/database
- /docs/packages/design-system/components
---

<Tip>The app application runs on port 3000. We recommend deploying it to app.{yourdomain}.com.</Tip>

next-forge exports the main app from the apps/app directory. It is designed to be run on a subdomain of your choice, and is used to run the main user-facing application.

Overview

The app application is the main user-facing application built on Next.js. It is designed to be a starting point for your own unique projects, containing all the core functionality you need.

Features

- Design System: The app is connected to the Design System and includes a variety of components, hooks, and utilities to help you get started.
- Authentication: The app includes a fully-featured authentication system with support for email login. You can easily extend it to support other providers and authentication methods. The app is also broken into authenticated and unauthenticated route groups.
- Database: The app is connected to the Database and can fetch data in React Server Components.
- Collaboration: The app is connected to the Collaboration and contains Avatar Stack and Live Cursor components.

---

Content/Docs/Apps/Docs

---
title: Documentation
description: How the documentation is configured in next-forge.
type: reference
product: Docs
summary: How the documentation application is configured.
related:
- /docs/packages/cms/overview
---

<Tip>The docs application runs on port 3004. We recommend deploying it to docs.{yourdomain}.com.</Tip>

next-forge uses Mintlify to generate beautiful docs. Each page is a .mdx file, written in Markdown, with built-in UI components and API playground.

Creating a new page

To create a new documentation page, add a new MDX file to the apps/docs directory. The file name will be used as the slug for the page and the frontmatter will be used to generate the docs page. For example:

mdx title="apps/docs/hello-world.mdx"
---
title: 'Quickstart'
description: 'Start building modern documentation in under five minutes.'
---
text
Learn more supported meta tags.

Adding a page to the navigation

To add a page to the sidebar, you'll need to define it in the mint.json file in the apps/docs directory. From the previous example, here's how you can add it to the sidebar:

mdx title="mint.json {2-5}"
"navigation": [
{
"group": "Getting Started",
"pages": ["hello-world"]
},
{
// ...
}
]
text

Advanced

You can build the docs you want with advanced features.

<Card title="Global Settings" icon="wrench" href="https://mintlify.com/docs/settings/global" horizontal>
Customize your documentation using the mint.json file
</Card>

<Card title="Components" icon="shapes" href="https://mintlify.com/docs/content/components" horizontal>
Explore the variety of components available
</Card>

---

Content/Docs/Apps/Email

---
title: Email
description: How email templates work in next-forge
type: reference
product: Email
summary: How the email preview application works.
related:
- /docs/packages/email
---

<Tip>The email application runs on port 3003.</Tip>

next-forge comes with react.email built in, allowing you to create and send beautiful emails using React and TypeScript.

react.email has a preview server, so you can preview the emails templates in the browser.

To preview the emails templates, simply run the email app:

sh title="Terminal"
bun dev --filter email
text
---

Content/Docs/Apps/Storybook

---
title: Storybook
description: Frontend workshop for the design system
type: reference
product: Storybook
summary: How the Storybook design system workshop works.
related:
- /docs/packages/design-system/components
---

<Tip>The storybook application runs on port 6006.</Tip>

next-forge uses Storybook as a frontend workshop for the design system. It allows you to interact with the components in the design system, and see how they behave in different states.

Configuration

By default, Storybook is configured with every component from shadcn/ui, and allows you to interact with them. It is also configured with the relevant fonts and higher-order components to ensure a consistent experience between your application and Storybook.

Running the workshop

Storybook will start automatically when you run bun dev. You can also start it independently with bun dev --filter storybook. The preview will be available at localhost:6006.

Adding stories

You can add your own components to the workshop by adding them to the apps/storybook/stories directory. Each component should have its own .stories.tsx file.

---

Content/Docs/Apps/Studio

---
title: Studio
description: Visualize and edit your database in a UI.
type: reference
product: Studio
summary: How the database studio application works.
related:
- /docs/packages/database
---

<Tip>The studio application runs on port 3005.</Tip>

next-forge includes Prisma Studio, which is a visual editor for your database. To start it, run the following command:

sh title="Terminal"
bun dev --filter studio
text
---

Content/Docs/Apps/Web

---
title: Web
description: How the website application works in next-forge.
type: reference
product: Web
summary: How the marketing website application works.
related:
- /docs/packages/seo/metadata
- /docs/packages/cms/overview
---

<Tip>The web application runs on port 3001. We recommend deploying it to www.{yourdomain}.com.</Tip>

next-forge comes with a default website application, which is located in the apps/web folder.

Overview

It's built on Next.js and Tailwind CSS, with some example pages scaffolded using TWBlocks. It's designed for you to customize and extend to your needs, whether that means keeping the default pages or replacing them with your own.

Features

- Design System: The app is connected to the Design System and includes a variety of components, hooks, and utilities to help you get started.
- CMS: The app is connected to the CMS package to power your type-safe blog.
- SEO: The app is connected to the SEO package which optimizes the site for search engines.
- Analytics: The app is connected to the Analytics package to track visitor behavior.
- Observability: The app is connected to the Observability package to track errors and performance.

---

Content/Docs/Addons/C15t

---
title: c15t
description: How to add privacy consent management to your app with c15t.
type: integration
summary: How to add consent management with c15t.
---

Overview

c15t is an open-source consent management platform that transforms privacy consent from a compliance checkbox into a fully observable system. It provides a TypeScript-first SDK with automatic jurisdiction detection, a customizable consent banner, and support for managing third-party scripts based on user consent.

Quickstart


npx @c15t/cli generate
text

Installation

Install the c15t Next.js package in the app(s) that need consent management:

package-install
npm install @c15t/nextjs
text

Setup

1. Create the provider

tsx title="components/consent-manager/provider.tsx"
'use client';

import { type ReactNode } from 'react';
import {
ConsentManagerProvider,
CookieBanner,
ConsentManagerDialog,
} from '@c15t/nextjs/client';

export default function ConsentManager({
children,
}: { children: React.ReactNode }) {
return (
<ConsentManagerProvider
options={{
mode: 'c15t',
backendURL: '/api/c15t',
consentCategories: ['necessary', 'measurement', 'marketing'],
}}
>
<CookieBanner />
<ConsentManagerDialog />
{children}
</ConsentManagerProvider>
);
}

text
<Tip>
For local development or prototyping, you can use
mode: 'offline' instead of mode: 'c15t' to store consent in cookies without a backend.
</Tip>

2. Add to your root layout

Wrap your app with the ConsentManager in your root layout:

tsx title="app/layout.tsx"
import { ConsentManager } from '@/components/consent-manager';

export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<ConsentManager>
{children}
</ConsentManager>
</body>
</html>
);
}

text

3. Configure Next.js rewrites (optional)

To optimize c15t further you can proxy c15t API requests through your Next.js server, which improves latency and reduces risk of blocking via an ad-blocker.

ts title="next.config.ts"
import type { NextConfig } from 'next';

const config: NextConfig = {
async rewrites() {
return [
{
source: '/api/c15t/:path*',
destination:
${process.env.NEXT_PUBLIC_C15T_URL}/:path*,
},
];
},
};

export default config;

text

Managing scripts

c15t can conditionally load third-party scripts based on user consent. Pass a scripts array to the provider options:

tsx
import { googleTagManager } from "@c15t/scripts/google-tag-manager"
import { metaPixel } from "@c15t/scripts/meta-pixel"

<ConsentManagerProvider
options={{
mode: 'c15t',
backendURL: '/api/c15t',
scripts: [
googleTagManager({ id: 'GTM-XXXXXXX' }),
metaPixel({ pixelId: '123456789012345' }),
{
id: 'example',
src: 'https://analytics.example.com/script.js',
category: 'measurement',
},
],
}}
`

<Tip>
Check the c15t integrations docs for pre-built helpers for popular services like Google Tag Manager, PostHog, and more.
</Tip>


For more information and detailed documentation, visit the c15t docs.

---

Content/Docs/Addons/Dub

---
title: Dub
description: How to add link tracking to your app with Dub.
type: integration
summary: How to add link tracking and analytics with Dub.
---

While next-forge does not come with link tracking and analytics out of the box, you can easily add it to your app with Dub.

Overview

Dub is an open-source link tracking and analytics platform that allows you to track the performance of your links and see how they're performing. It comes with a suite of features that make it a great choice for marketing teams, including link shortening, custom domains, branded QR codes, and more.

Signing up

You can sign up for a Dub account on their website.

Once you've signed up, you can create a link by clicking the "Create Link" button in the top right corner.

From here, simply replace all href values with the Dub link!

tsx
<a href="https://dub.co/example">Example</a>
<Link href="https://dub.co/example">Example</Link>

Interfacing programmatically

Dub provides a simple SDK for creating links, managing customers, tracking leads and more. You can install it with:

package-install
npm install dub

For more information on the SDK, you can refer to the official documentation.

---

Content/Docs/Addons/Fuse

---
title: Fuse.js
description: A powerful, lightweight fuzzy-search library, with zero dependencies.
type: integration
summary: How to add fuzzy search with Fuse.js.
---

Installation

To install fuse.js, simply run the following command:

package-install
npm install fuse.js

Usage

Here is an example of how to use fuse.js for searching through an array of objects:

`tsx title="search.ts"
import Fuse from 'fuse.js';

const data = [
{ id: 1, name: 'John Doe', email: '[email protected]' },
{ id: 2, name: 'Jane Doe', email: '[email protected]' },
];

const fuse = new Fuse(data, {
keys: ['name', 'email'],
minMatchCharLength: 1,
threshold: 0.3,
});

const results = fuse.search('john');

console.log(results);

text

Benefits

- fuse.js is easy to use and has a simple API.
- Performant:
fuse.js is performant and has zero dependencies.

For more information and detailed documentation, visit the fuse.js GitHub repo.

---

Content/Docs/Addons/Joyful

---
title: Joyful
description: Generate delightful, random word combinations for your app — perfect for project names, usernames, or unique identifiers.
type: integration
summary: How to generate friendly random words for project names.
---

Installation

To install joyful, simply run the following command:

package-install
npm install joyful
text

Usage

Here is an example of how to use joyful for generating friendly words:

tsx title="get-project-name.ts"
import { joyful } from "joyful";

const words = joyful(); // "amber-fox"
const words = joyful({ segments: 3 }); // "golden-marble-cathedral"
const words = joyful({ segments: 3, separator: "_" }); // "swift_northern_lights"
const words = joyful({ maxLength: 8 }); // "tan-elk"

text

Benefits

- Easy to Use: joyful is easy to use and generates friendly words with a simple API.
- Customizable: You can customize the number of segments and the separator.

For more information and detailed documentation, visit the joyful GitHub repo.

---

Content/Docs/Addons/Metabase

---
title: Metabase
description: How to add business intelligence and analytics to your app with Metabase.
type: integration
summary: How to add embedded business intelligence with Metabase.
---

While next-forge doesn't include BI tooling out of the box, you can easily add business intelligence and analytics to your app with Metabase.

Try it locally or in the cloud:

<div className="block -mt-6">
<a href="https://www.metabase.com/start/oss" className="block -mb-6">
<img src="https://img.shields.io/badge/Self--host-Metabase-blue?logo=metabase" alt="Self-host Metabase" />
</a>
<a href="https://metabase.com/start" className="block -mt-6">
<img src="https://img.shields.io/badge/Try%20Cloud-Metabase-brightgreen?logo=metabase" alt="Try Metabase Cloud" />
</a>
</div>

Overview

Metabase is an open-source business intelligence platform. You can use Metabase to ask questions about your data, or embed Metabase in your app to let your customers explore their data on their own.

Installing Metabase

Metabase provides an official Docker image via Docker Hub that can be used for deployments on any system that is running Docker. Here's a one-liner that will start a container running Metabase:

sh
docker run -d --name metabase -p 3000:3000 metabase/metabase
text
For full installation instructions: 
- Docker Documentation
- Jar File Documentation


Database Connection

By default, next-forge uses Neon as its database provider. Metabase works seamlessly with Postgres. To connect, you'll need:

- The hostname of the server where your database lives
- The
port the database server uses
- The
database name
- The
username you use for the database
- The
password you use for the database

You can find these details in your DATABASE_URL:

js
DATABASE_URL="postgresql://[username]:[password]@[hostname]:[port]/[database_name]?sslmode=require"
text
Then plug your database connection credentials into Metabase:

Metabase supports over 20 databases. For other database options, see Metabase Database Documentation.

Asking Questions and Building Dashboards

Once connected, you can start asking Questions and building Dashboards.

---

Content/Docs/Addons/Motion

---
title: Motion
description: A library for animating React components with ease.
type: integration
summary: How to add animations with the Motion library.
---

<Tip>Motion was formerly known as Framer Motion.</Tip>

Installation

To install Motion, simply run the following command:

package-install
npm install motion
text

Usage

Here is an example of how to use Motion to animate a component:

tsx title="my-component.tsx"
import { motion } from 'motion';

function MyComponent() {
return (
<motion.div animate={{ x: 100 }}>This is a component that is animated.</motion.div>
);
}

text

Benefits

- Easy Animation: Motion makes it easy to animate components with a simple and intuitive API.
- Customization: Motion allows you to customize animations to your needs, providing a high degree of control over the animation process.
- Performance: Motion is performant and has minimal impact on your application's performance.

For more information and detailed documentation, visit the Motion website.

---

Content/Docs/Addons/Next Safe Action

---
title: Next Safe Action
description: A powerful library for managing and securing your Next.js Server Actions.
type: integration
summary: How to add type-safe server actions with next-safe-action.
---

Installation

To install Next Safe Action, simply run the following command:

package-install
npm install next-safe-action zod --filter app
text
By default, Next Safe Action uses Zod to validate inputs, but it also supports adapters for Valibot, Yup, and Typebox.

Basic Usage

Here is a basic example of how to use Next Safe Action to call your Server Actions:

Server Action

ts title="action.ts"
"use server"

import { createSafeActionClient } from "next-safe-action";
import { z } from "zod";

export const serverAction = createSafeActionClient()
.schema(
z.object({
name: z.string(),
id: z.string()
})
)
.action(async ({ parsedInput: { name, id } }) => {
// Fetch data in server
const data = await fetchData(name, id);

// Write server logic here ...

// Return here the value to the client
return data;
});

text

Client Component

tsx title="my-component.tsx"
"use client"

import { serverAction } from "./action"
import { useAction } from "next-safe-action/hooks";
import { toast } from "@repo/design-system/components/ui/sonner";

function MyComponent() {
const { execute, isPending } = useAction(serverAction, {
onSuccess() {
// Display success message to client
toast.success("Action Success");
},
onError({ error }) {
// Display error message to client
toast.error("Action Failed");
},
});

const onClick = () => {
execute({ name: "next-forge", id: "example" });
};

return (
<div>
<Button disabled={isPending} onClick={onClick}>
Click to call action
</Button>
</div>
);
}

text
In this example, we create an action with input validation on the server, and call it on the client to with type-safe inputs and convinient callback utilities to simplify state management and error handling.

Benefits

- Simplified State Management: Next Safe Action simplifies server action state management by providing callbacks and status utilities.
- Type-safe: By using Zod or other validation libraries, your inputs are type-safe and validated end-to-end.
- Easy Integration: Next Safe Action is extremely easy to integrate, and you can incrementally use more of its feature like optimistic updates and middlewares.

For more information and detailed documentation, visit the Next Safe Action website.

---

Content/Docs/Addons/Nuqs

---
title: NUQS
description: A powerful library for managing URL search parameters in your application. It provides a simple and efficient way to handle state management through URL search parameters.
type: integration
summary: How to add type-safe URL search parameter management with nuqs.
---

Installation

To install NUQS, simply run the following command:

package-install
npm install nuqs
text

Usage

Here is an example of how to use NUQS for URL search parameter state management:

tsx title="my-component.tsx"
import { useQueryState, parseAsString } from 'nuqs';

function MyComponent() {
const [query, setQuery] = useQueryState('query', parseAsString.withDefault(''));

return (
<div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<p>Search Query: {query}</p>
</div>
);
}

text
In this example, the useQueryState hook from nuqs is used to manage a single URL search parameter with type-safe parsing. The setQuery function updates the query URL parameter whenever the input value changes.

Benefits

- Simplified State Management: NUQS simplifies state management by using URL search parameters, making it easy to share and persist state across different parts of your application.
- SEO-Friendly: By using URL search parameters, NUQS helps improve the SEO of your application by making the state accessible through the URL.
- Easy Integration: NUQS is easy to integrate into your existing React application, providing a seamless experience for managing URL search parameters.

For more information and detailed documentation, visit the NUQS website.

---

Content/Docs/Addons/React Wrap Balancer

---
title: React Wrap Balancer
description: A simple React component that makes titles more readable
type: integration
summary: How to add balanced text wrapping with React Wrap Balancer.
---

Installation

To install react-wrap-balancer, simply run the following command:

package-install
npm install react-wrap-balancer
text

Usage

Here is an example of how to use react-wrap-balancer to make titles more readable:

tsx title="my-component.tsx"
import { Balancer } from 'react-wrap-balancer';

function MyComponent() {
return (
<div>
<Balancer>This is a title that is too long to fit in one line.</Balancer>
</div>
);
}

text

Benefits

- Improved Readability: react-wrap-balancer makes titles more readable by automatically wrapping them at the appropriate breakpoints.
- Easy Integration:
react-wrap-balancer is easy to integrate into your existing React application, providing a seamless installation experience.

For more information and detailed documentation, visit the react-wrap-balancer website.

---

Content/Docs/Addons/Trunk

---
title: Trunk
description: How to add Trunk's merge queue and flaky test detection to your next-forge project.
type: integration
summary: How to add a merge queue and flaky test detection with Trunk.
---

Trunk provides a merge queue and flaky test detection for GitHub repositories. This guide covers setting up both features with your next-forge project.

Setup

Create a Trunk account at app.trunk.io and connect your GitHub repository.

Merge Queue

Trunk Merge Queue tests PRs against the predicted state of the target branch before merging, including PRs ahead of yours in the queue.

Update CI workflow triggers

The merge queue creates trunk-merge/ branches to test PR combinations. Your CI workflows need to run on these branches:

yaml title=".github/workflows/test.yml"
on:
pull_request:
branches: [main]
push:
branches:
- main
- 'trunk-merge/'
text
Apply the same change to any other workflows that must pass before merging.

Configure branch protection

In your GitHub repository settings under Branches > Branch protection rules for main:

- Allow the trunk-io bot to push to your protected branch
- Disable "Require branches to be up to date before merging"
- Ensure
trunk-temp/ and trunk-merge/ branches are not blocked by wildcard protection rules

Guard main-only steps

Steps that should only run on actual merges to main (not queue test branches) need a condition:

yaml
- name: Create Release
if: github.ref == 'refs/heads/main'
run: npx auto shipit
text

Usage

Submit PRs to the queue by either:

- Checking the box in the Trunk bot's PR comment
- Commenting
/trunk merge on the PR

For more information, visit the Trunk Merge Queue documentation.

Flaky Tests

Trunk Flaky Tests tracks your test results over time and identifies tests with inconsistent pass/fail behavior. It ingests JUnit XML reports uploaded from CI.

Adding JUnit reporters

Add the JUnit reporter to each Vitest config. In apps/app/vitest.config.mts and apps/api/vitest.config.mts:

ts title="apps/app/vitest.config.mts"
export default defineConfig({
// ...existing config
test: {
environment: "jsdom",
reporters: [
"default",
["junit", { outputFile: "./junit.xml", addFileAttribute: true }],
],
},
});
text
<Tip>
Disable automatic test retries in Vitest, as retries compromise flaky test detection accuracy.
</Tip>

Uploading results from CI

Add the upload step after your test command. Use if: always() so results are uploaded even when tests fail:

yaml title=".github/workflows/test.yml"
- name: Run tests
run: bun run test
continue-on-error: true

- name: Upload test results to Trunk
if: always()
uses: trunk-io/analytics-uploader@v1
with:
junit-paths: '/junit.xml'
org-slug: $\{{ vars.TRUNK_ORG_SLUG }}
token: $\{{ secrets.TRUNK_API_TOKEN }}

text

Required secrets

Add the following to your GitHub repository:

- TRUNK_API_TOKEN — API token from Trunk organization settings
-
TRUNK_ORG_SLUG — Your Trunk organization slug (can be a repository variable)

For more information, visit the Trunk Flaky Tests documentation.

---

Content/Docs/Addons/Zustand

---
title: Zustand
description: A small, fast, and scalable bearbones state-management solution for React applications. It provides a simple and efficient way to manage state in your application.
type: integration
summary: How to add client-side state management with Zustand.
---

Installation

To install Zustand, run the following command:

package-install
npm install zustand
text

Usage

Here is an example of how to use Zustand for state management:

tsx title="counter.tsx"
import { create } from 'zustand';

const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}));

function Counter() {
const { count, increment, decrement } = useStore();

return (
<div>
<h1>{count}</h1>
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
</div>
);
}

text
In this example, the create function from Zustand is used to create a store with a count state and increment and decrement actions. The useStore hook is then used in the Counter component to access the state and actions.

Benefits

- Simple API: Zustand provides a simple and intuitive API for managing state in your React application.
- Performance: Zustand is optimized for performance, making it suitable for large-scale applications.
- Scalability: Zustand is scalable and can be used to manage state in applications of any size.

For more information and detailed documentation, visit the Zustand website.

---

Content/Docs/Migrations/Storage/Appwrite

---
title: Switch to Appwrite Storage
description: How to change the default storage provider to Appwrite Storage.
type: integration
summary: How to switch the storage provider to Appwrite Storage.
prerequisites:
- /docs/packages/storage
related:
- /docs/migrations/storage/upload-thing
- /docs/migrations/authentication/appwrite
- /docs/migrations/database/appwrite
---

Appwrite Storage is a file storage service that's part of the Appwrite platform. It provides file uploads, downloads, previews, and image transformations with built-in permission controls.

next-forge uses Vercel Blob as the default storage provider. This guide will help you switch from Vercel Blob to Appwrite Storage.

1. Replace the storage package dependencies

Uninstall the existing Vercel Blob dependency from the storage package...

package-install
npm uninstall @vercel/blob --filter @repo/storage
text
...and install the Appwrite dependencies:
package-install
npm install node-appwrite appwrite --filter @repo/storage
text

2. Update environment variables

Add the following environment variables to your .env.local file:

bash title=".env.local"
NEXT_PUBLIC_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
NEXT_PUBLIC_APPWRITE_PROJECT_ID=your-project-id
APPWRITE_API_KEY=your-api-key
APPWRITE_BUCKET_ID=your-bucket-id
text
<Note>
You'll need to create a storage bucket in the Appwrite Console first. Navigate to Storage → Create Bucket and note the bucket ID.
</Note>

3. Update the environment keys

Update the keys.ts file to validate the new environment variables:

ts title="packages/storage/keys.ts"
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';

export const keys = () =>
createEnv({
server: {
APPWRITE_API_KEY: z.string().min(1),
APPWRITE_BUCKET_ID: z.string().min(1),
},
client: {
NEXT_PUBLIC_APPWRITE_ENDPOINT: z.string().url(),
NEXT_PUBLIC_APPWRITE_PROJECT_ID: z.string().min(1),
},
runtimeEnv: {
APPWRITE_API_KEY: process.env.APPWRITE_API_KEY,
APPWRITE_BUCKET_ID: process.env.APPWRITE_BUCKET_ID,
NEXT_PUBLIC_APPWRITE_ENDPOINT:
process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT,
NEXT_PUBLIC_APPWRITE_PROJECT_ID:
process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID,
},
});

text

4. Update the server storage file

Replace the contents of index.ts with a configured Appwrite Storage client:

ts title="packages/storage/index.ts"
import 'server-only';
import { Client, Storage, ID, Permission, Role, InputFile } from 'node-appwrite';

const client = new Client()
.setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT!)
.setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID!)
.setKey(process.env.APPWRITE_API_KEY!);

export const storage = new Storage(client);
export const bucketId = process.env.APPWRITE_BUCKET_ID!;

export { ID, Permission, Role, InputFile };

text

5. Update the client storage file

Update client.ts with client-side Appwrite Storage helpers:

ts title="packages/storage/client.ts"
'use client';

import { Client, Storage, ID } from 'appwrite';

const client = new Client()
.setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT!)
.setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID!);

export const storage = new Storage(client);
export { ID };

text

6. Create a storage bucket

Create a storage bucket in the Appwrite Console:

1. Go to your Appwrite project → Storage
2. Click Create Bucket
3. Give it a name (e.g.,
uploads)
4. Configure allowed file extensions and maximum file size
5. Set the bucket permissions (e.g., allow authenticated users to create files)
6. Copy the bucket ID and set it as
APPWRITE_BUCKET_ID

You can also create a bucket programmatically:

ts title="Example: Creating a bucket"
import { storage, Permission, Role } from '@repo/storage';

await storage.createBucket(
'uploads',
'uploads',
[
Permission.read(Role.any()),
Permission.create(Role.users()),
Permission.update(Role.users()),
Permission.delete(Role.users()),
],
false, // fileSecurity
true, // enabled
10 1024 1024, // maximumFileSize (10MB)
['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf'] // allowedFileExtensions
);

text

7. File operations

Upload a file (server-side)

ts
import { storage, bucketId, ID, InputFile } from '@repo/storage';

const file = await storage.createFile(
bucketId,
ID.unique(),
InputFile.fromBuffer(buffer, 'image.png')
);

text

Upload a file (client-side)

ts
import { storage, ID } from '@repo/storage/client';

const bucketId = process.env.NEXT_PUBLIC_APPWRITE_BUCKET_ID!;

const file = await storage.createFile(
bucketId,
ID.unique(),
document.getElementById('file-input').files[0]
);

text

Download a file

ts
import { storage, bucketId } from '@repo/storage';

const fileData = await storage.getFileDownload(bucketId, 'file-id');

text

Delete a file

ts
import { storage, bucketId } from '@repo/storage';

await storage.deleteFile(bucketId, 'file-id');

text

Get a file preview URL

Appwrite provides built-in image transformations through the preview endpoint:

ts
import { storage, bucketId } from '@repo/storage';

// Get a preview with transformations
const preview = storage.getFilePreview(
bucketId,
'file-id',
400, // width
300, // height
'center', // gravity
90 // quality
);

text

Get file metadata

ts
import { storage, bucketId } from '@repo/storage';

const file = await storage.getFile(bucketId, 'file-id');

console.log(file.name); // Original filename
console.log(file.sizeOriginal); // File size in bytes
console.log(file.mimeType); // MIME type

text

8. Update your apps

Replace Vercel Blob usage throughout your application:

tsx
// Before (Vercel Blob)
import { put, del } from '@repo/storage';
const blob = await put('image.png', file, { access: 'public' });
await del(blob.url);

// After (Appwrite)
import { storage, bucketId, ID, InputFile } from '@repo/storage';
const file = await storage.createFile(
bucketId,
ID.unique(),
InputFile.fromBuffer(buffer, 'image.png')
);
await storage.deleteFile(bucketId, file.$id);

text

9. File permissions

Appwrite supports fine-grained file-level permissions. You can set permissions when creating files:

ts
import { storage, bucketId, ID, Permission, Role, InputFile } from '@repo/storage';

const file = await storage.createFile(
bucketId,
ID.unique(),
InputFile.fromBuffer(buffer, 'private-doc.pdf'),
[
Permission.read(Role.user('user-123')),
Permission.update(Role.user('user-123')),
Permission.delete(Role.user('user-123')),
]
);

text

Additional features

Image transformations

Appwrite Storage provides built-in image transformations without needing a separate image CDN:

- Resize (width, height)
- Crop with gravity (center, top-left, etc.)
- Quality adjustment
- Format conversion
- Border radius and background color
- Rotation and opacity

Bucket configuration

Each bucket can be configured with:

- Allowed file extensions — Restrict which file types can be uploaded
- Maximum file size — Set upload size limits
- Encryption — Enable at-rest encryption
- Antivirus — Scan uploaded files for malware
- Compression — Automatic file compression (gzip, zstd)

For more information, see the Appwrite Storage documentation.

---

Content/Docs/Migrations/Storage/Upload Thing

---
title: Switch to uploadthing
description: How to change the default storage provider to uploadthing.
type: integration
summary: How to switch the storage provider to uploadthing.
prerequisites:
- /docs/packages/storage
---

uploadthing is a platform for storing files in the cloud. It's a great alternative to AWS S3 and it's free for small projects. Here's how to switch the default storage provider to uploadthing.

1. Swap out the required dependencies

First, uninstall the existing dependencies from the Storage package...

package-install
npm uninstall @vercel/blob --filter @repo/storage
text
... and install the new dependencies...
package-install
npm install uploadthing @uploadthing/react --filter @repo/storage
text

2. Update the environment variables

Next, update the environment variables across the project, for example:

js title="apps/app/.env"
// Remove this:
BLOB_READ_WRITE_TOKEN=""

// Add this:
UPLOADTHING_TOKEN=""

text
Additionally, replace all instances of BLOB_READ_WRITE_TOKEN with UPLOADTHING_TOKEN in the packages/env/index.ts file.

3. Update the existing storage files

Update the index.ts and client.ts to use the new uploadthing packages:

Storage

ts title="packages/storage/index.ts"
import { createUploadthing } from 'uploadthing/next';

export { type FileRouter, createRouteHandler } from 'uploadthing/next';
export { UploadThingError as UploadError, extractRouterConfig } from 'uploadthing/server';

export const storage = createUploadthing();

text

Client

ts title="packages/storage/client.ts"
export * from '@uploadthing/react';
text

4. Create new SSR file

We'll also need to create a new file for the storage package to handle the Tailwind CSS classes and SSR.

ts title="packages/storage/ssr.ts"
export { NextSSRPlugin as StorageSSRPlugin } from '@uploadthing/react/next-ssr-plugin';
text

5. Create a file router in your app

Create a new file in your app's lib directory to define the file router. This file will be used to define the file routes for your app, using your Auth package to get the current user.

ts title="apps/app/app/lib/upload.ts"
import { currentUser } from '@repo/auth/server';
import { type FileRouter, UploadError, storage } from '@repo/storage';

export const router: FileRouter = {
imageUploader: storage({
image: {
maxFileSize: '4MB',
maxFileCount: 1,
},
})
.middleware(async () => {
const user = await currentUser();

if (!user) {
throw new UploadError('Unauthorized');
}

return { userId: user.id };
})
.onUploadComplete(({ metadata, file }) => ({ uploadedBy: metadata.userId }),
};

text

6. Create a route handler

Create a new route handler in your app's api directory to handle the file routes.

ts title="apps/app/app/api/upload/route.ts"
import { router } from '@/app/lib/upload';
import { createRouteHandler } from '@repo/storage';

export const { GET, POST } = createRouteHandler({ router });

text

7. Update your root layout

Update your root layout to include the StorageSSRPlugin. This will add SSR hydration and avoid a loading state on your upload button.

tsx title="apps/app/app/layout.tsx {4,5,7,16}"
import '@repo/design-system/styles/globals.css';
import { DesignSystemProvider } from '@repo/design-system';
import { fonts } from '@repo/design-system/lib/fonts';
import { extractRouterConfig } from '@repo/storage';
import { StorageSSRPlugin } from '@repo/storage/ssr';
import type { ReactNode } from 'react';
import { router } from './lib/upload';

type RootLayoutProperties = {
readonly children: ReactNode;
};

const RootLayout = ({ children }: RootLayoutProperties) => (
<html lang="en" className={fonts} suppressHydrationWarning>
<body>
<StorageSSRPlugin routerConfig={extractRouterConfig(router)} />
<DesignSystemProvider>{children}</DesignSystemProvider>
</body>
</html>
);

export default RootLayout;

text

8. Update your Tailwind CSS

Update your design system's globals.css file to include the following:

css title="packages/design-system/styles/globals.css"
@import "uploadthing/tw/v4";
@source "../node_modules/@uploadthing/react/dist";
text

9. Create your upload button

Create a new component for your upload button. This will use the generateUploadButton function to create a button that will upload files to the imageUploader endpoint.

tsx title="apps/app/app/(authenticated)/components/upload-button.tsx"
'use client';

import type { router } from '@/app/lib/upload';
import { generateUploadButton } from '@repo/storage/client';
import { toast } from 'sonner';

const UploadButton = generateUploadButton<typeof router>();

export const UploadForm = () => (
<UploadButton
endpoint="imageUploader"
onClientUploadComplete={(res) => {
// Do something with the response
console.log('Files: ', res);
toast.success('Upload Completed');
}}
onUploadError={(error: Error) => {
toast.error(
ERROR! ${error.message});
}}
/>
);

text
Now you can import this component into your app and use it as a regular component.

10. Advanced configuration

uploadthing is a powerful platform that offers a lot of advanced configuration options. You can learn more about them in the uploadthing documentation.

- File Routes
- Security

---

Content/Docs/Migrations/Payments/Lemon Squeezy

---
title: Switch to Lemon Squeezy
description: How to change the default payment processor to Lemon Squeezy.
type: integration
summary: How to switch the payment processor to Lemon Squeezy.
prerequisites:
- /docs/packages/payments
related:
- /docs/migrations/payments/paddle
---

Lemon Squeezy is an all-in-one platform for running your SaaS business. It handles payments, subscriptions, global tax compliance, fraud prevention, multi-currency, and more. Here's how to switch the default payment processor from Stripe to Lemon Squeezy.

<Warning>
Lemon Squeezy was acquired by Stripe in July 2024. New signups are being transitioned to Stripe Managed Payments. If you're starting a new project, consider using Stripe directly. Existing Lemon Squeezy integrations continue to work.
</Warning>

1. Swap out the required dependencies

First, uninstall the existing dependencies from the Payments package...

package-install
npm uninstall stripe --filter @repo/payments
text
... and install the new dependencies...
package-install
npm install @lemonsqueezy/lemonsqueezy.js --filter @repo/payments
text

2. Update the environment variables

Next, update the environment variables across the project, for example:

js title="apps/app/.env"
LEMON_SQUEEZY_API_KEY=""
text
Additionally, replace all instances of STRIPE_SECRET_KEY with LEMON_SQUEEZY_API_KEY in the packages/env/index.ts file.

<Note>
The API key should be a server-side environment variable (without the
NEXT_PUBLIC_ prefix), as it should not be exposed to the client.
</Note>

3. Update the payments client

Initialize the payments client in the packages/payments/index.ts file with the new API key. Then, export the lemonSqueezySetup function from the file.

ts title="packages/payments/index.ts"
import { env } from '@repo/env';
import { lemonSqueezySetup } from '@lemonsqueezy/lemonsqueezy.js';

lemonSqueezySetup({
apiKey: env.LEMON_SQUEEZY_API_KEY,
onError: (error) => console.error("Error!", error),
});

export * from '@lemonsqueezy/lemonsqueezy.js';

text

4. Update the payments webhook handler

Update the webhook handler for Lemon Squeezy:

ts title="apps/api/app/webhooks/payments/route.ts"
import { NextResponse } from 'next/server';

export const POST = async (request: Request) => {
return NextResponse.json({ message: 'Hello World' });
};

text
There's quite a lot you can do with Lemon Squeezy, so check out the following resources for more information:

- Webhooks Overview
- Signing Requests

5. Use Lemon Squeezy in your app

Finally, use the new payments client in your app.

tsx title="apps/app/app/(authenticated)/page.tsx"
import { getStore } from '@repo/payments';

const Page = async () => {
const store = await getStore(123456);

return (
<pre>{JSON.stringify(store, null, 2)}</pre>
);
};

text
---

Content/Docs/Migrations/Payments/Paddle

---
title: Switch to Paddle Billing
description: How to change the default payment processor to Paddle Billing.
type: integration
summary: How to switch the payment processor to Paddle Billing.
prerequisites:
- /docs/packages/payments
related:
- /docs/migrations/payments/lemon-squeezy
---

Paddle Billing is a merchant of record for selling digital products and subscriptions. It takes care of payments, global tax compliance, fraud prevention, localization, and subscriptions. Here's how to switch the default payment processor from Stripe to Paddle Billing.

<Note>
This guide is for Paddle Billing, which is the latest version of Paddle. It doesn't include Paddle Classic.
</Note>


1. Swap out the required dependencies

First, uninstall the existing dependencies from the Payments package...

package-install
npm uninstall stripe --filter @repo/payments
text
... and install the new dependencies...
package-install
npm install @paddle/paddle-node-sdk --filter @repo/payments
text

2. Update the Payment keys

Update the required Payment keys in the packages/payments/keys.ts file:

ts title="packages/payments/keys.ts"
import { createEnv } from '@t3-oss/env-nextjs';
import { Environment } from '@paddle/paddle-node-sdk'
import { z } from 'zod';

export const keys = () =>
createEnv({
server: {
PADDLE_SECRET_KEY: z.string().min(1),
PADDLE_WEBHOOK_SECRET: z.string().optional(),
PADDLE_ENV: z.enum([Environment.sandbox, Environment.production]).optional(),
},
client: {
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN: z
.union([
z.string().min(1).startsWith('live_'),
z.string().min(1).startsWith('test_'),
]),
NEXT_PUBLIC_PADDLE_ENV: z.enum([Environment.sandbox, Environment.production]).optional(),
},
runtimeEnv: {
PADDLE_SECRET_KEY: process.env.PADDLE_SECRET_KEY,
PADDLE_WEBHOOK_SECRET: process.env.PADDLE_WEBHOOK_SECRET,
PADDLE_ENV: process.env.PADDLE_ENV,
NEXT_PUBLIC_PADDLE_ENV: process.env.NEXT_PUBLIC_PADDLE_ENV,
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN: process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN,
},
});

text

3. Update the environment variables

Next, update the environment variables across the project, replacing the existing Stripe keys with the new Paddle keys:

js title="apps/app/.env"

Server


PADDLE_SECRET_KEY=""
PADDLE_WEBHOOK_SECRET=""
PADDLE_ENV="sandbox"

Client


NEXT_PUBLIC_PADDLE_ENV="sandbox"
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN="test_"
text

4. Update the payments client

Initialize the payments client in the packages/payments/index.ts file with the new API key.

ts title="packages/payments/index.ts"
import 'server-only';
import { Paddle } from '@paddle/paddle-node-sdk';
import { keys } from './keys';

const { PADDLE_SECRET_KEY, PADDLE_ENV } = keys();

export const paddle = new Paddle(PADDLE_SECRET_KEY, {
environment: PADDLE_ENV,
});

export * from '@paddle/paddle-node-sdk';

text

5. Update the payments webhook handler

Update the webhook handler for Paddle:

ts title="apps/api/app/webhooks/payments/route.ts"
import { keys } from '@repo/payments/keys';
import { NextResponse } from 'next/server';
import { headers } from 'next/headers';
import { paddle } from '@repo/payments';

export const POST = async (request: Request) => {
try {
const body = await request.text();
const headerPayload = await headers();
const signature = headerPayload.get('paddle-signature');

if (!signature) {
throw new Error('missing paddle-signature header');
}

const event = await paddle.webhooks.unmarshal(
body,
keys().PADDLE_WEBHOOK_SECRET,
signature
);

switch (event.eventType) {}

return NextResponse.json({ result: event, ok: true });
} catch (error) {
return NextResponse.json({ error: 'Webhook error' }, { status: 400 });
}
};

text
There's quite a lot you can do with Paddle, so check out the following resources for more information:

- Webhooks Overview
- Signature Verification
- Simulate Webhooks

6. Create a Checkout hook

Create a new file for checkout and install paddle-js:

package-install
npm install @paddle/paddle-js --filter @repo/payments
text
Then, create a new hook to initialize Paddle in the packages/payments/checkout.tsx file:
tsx title="packages/payments/checkout.tsx"
'use client';

import {
type Environments,
type Paddle,
initializePaddle,
} from '@paddle/paddle-js';
import { useEffect, useState } from 'react';
import { keys } from './keys';

const { NEXT_PUBLIC_PADDLE_CLIENT_TOKEN, NEXT_PUBLIC_PADDLE_ENV } = keys();

export function usePaddle() {
const [paddle, setPaddle] = useState<Paddle>();

useEffect(() => {
initializePaddle({
environment: NEXT_PUBLIC_PADDLE_ENV,
token: NEXT_PUBLIC_PADDLE_CLIENT_TOKEN,
checkout: {
settings: {
variant: 'one-page',
},
},
}).then((paddleInstance: Paddle | undefined) => {
if (paddleInstance) {
setPaddle(paddleInstance);
}
});
}, []);

return paddle;
}

text

7. Use the Checkout hook

Finally, open a checkout on your pricing page:

tsx title="apps/web/app/pricing/page.tsx"
'use client';

import { usePaddle } from '@repo/payments/checkout';

const Pricing = () => {
const paddle = usePaddle();

function openCheckout(priceId: string) {
paddle?.Checkout.open({
items: [
{
priceId,
quantity: 1,
},
],
});
}

return (
<Button
className="mt-8 gap-4"
onClick={() => openCheckout('pri_01jkzb4x1hc91s8w38cr3m86yy')}
>
Subscribe now <MoveRight className="h-4 w-4" />
</Button>
);
};

export default Pricing;

text
---

Content/Docs/Migrations/Notifications/Novu

---
title: Switch to Novu
description: How to change the notifications provider to Novu.
type: integration
summary: How to switch the notifications provider to Novu.
prerequisites:
- /docs/packages/notifications
---

Novu is an open-source notification infrastructure platform that supports in-app, email, SMS, push, and chat channels. It's self-hostable and provides a unified API for managing notifications across multiple channels. Here's how to switch the default notifications provider from Knock to Novu.

1. Swap out the required dependencies

First, uninstall the existing dependencies from the Notifications package...

package-install
npm uninstall @knocklabs/node @knocklabs/react --filter @repo/notifications
text
... and install the new dependencies...
package-install
npm install @novu/api @novu/react --filter @repo/notifications
text

2. Update the Notification keys

Update the required Notification keys in the packages/notifications/keys.ts file:

ts title="packages/notifications/keys.ts"
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';

export const keys = () =>
createEnv({
server: {
NOVU_SECRET_KEY: z.string().optional(),
},
client: {
NEXT_PUBLIC_NOVU_APP_ID: z.string().optional(),
},
runtimeEnv: {
NOVU_SECRET_KEY: process.env.NOVU_SECRET_KEY,
NEXT_PUBLIC_NOVU_APP_ID: process.env.NEXT_PUBLIC_NOVU_APP_ID,
},
});

text

3. Update the environment variables

Next, update the environment variables across the project, replacing the existing Knock keys with the new Novu keys:

js title="apps/app/.env"
NOVU_SECRET_KEY=""
NEXT_PUBLIC_NOVU_APP_ID=""
text

4. Update the notifications client

Initialize the notifications client in the packages/notifications/index.ts file with the new API key:

ts title="packages/notifications/index.ts"
import { Novu } from '@novu/api';
import { keys } from './keys';

const key = keys().NOVU_SECRET_KEY;

export const notifications = new Novu({ secretKey: key });

text

5. Update the notifications provider

Replace the Knock provider with Novu's NovuProvider in packages/notifications/components/provider.tsx:

tsx title="packages/notifications/components/provider.tsx"
'use client';

import { NovuProvider } from '@novu/react';
import type { ReactNode } from 'react';
import { keys } from '../keys';

const novuAppId = keys().NEXT_PUBLIC_NOVU_APP_ID;

interface NotificationsProviderProps {
children: ReactNode;
theme: 'light' | 'dark';
userId: string;
}

export const NotificationsProvider = ({
children,
theme,
userId,
}: NotificationsProviderProps) => {
if (!novuAppId) {
return children;
}

return (
<NovuProvider
applicationIdentifier={novuAppId}
subscriberId={userId}
appearance={{ variables: { colorScheme: theme } }}
>
{children}
</NovuProvider>
);
};

text

6. Update the notifications trigger

Replace the Knock notification components with Novu's Inbox in packages/notifications/components/trigger.tsx:

tsx title="packages/notifications/components/trigger.tsx"
'use client';

import { Inbox } from '@novu/react';
import { keys } from '../keys';

export const NotificationsTrigger = () => {
if (!keys().NEXT_PUBLIC_NOVU_APP_ID) {
return null;
}

return <Inbox />;
};

text
You can also remove the packages/notifications/styles.css file, as Novu's Inbox component handles its own styling.

7. Update the app-level notifications provider

Update the wrapper in apps/app/app/(authenticated)/components/notifications-provider.tsx. The existing wrapper should work as-is since the NotificationsProvider already accepts a theme prop. If the types differ, update accordingly:

tsx title="apps/app/app/(authenticated)/components/notifications-provider.tsx"
'use client';

import { NotificationsProvider as RawNotificationsProvider } from '@repo/notifications/components/provider';
import { useTheme } from 'next-themes';
import type { ReactNode } from 'react';

interface NotificationsProviderProperties {
children: ReactNode;
userId: string;
}

export const NotificationsProvider = ({
children,
userId,
}: NotificationsProviderProperties) => {
const { resolvedTheme } = useTheme();

return (
<RawNotificationsProvider
theme={resolvedTheme as 'light' | 'dark'}
userId={userId}
>
{children}
</RawNotificationsProvider>
);
};

text

8. Triggering notifications

To trigger a notification from the server, use the Novu API:

ts
import { notifications } from '@repo/notifications';

await notifications.trigger({
workflowId: 'your-workflow-id',
to: {
subscriberId: 'user-123',
},
payload: {
message: 'Hello from Novu!',
},
});

text
There's quite a lot you can do with Novu, so check out the following resources for more information:

- Novu Documentation
- Workflows
- Inbox Component
- Self-hosting

---

Content/Docs/Migrations/Formatting/Eslint

---
title: Switch to ESLint
description: How to change the default linter to ESLint.
type: integration
summary: How to switch the linter to ESLint.
prerequisites:
- /docs/packages/formatting
---

Here's how to switch from Biome to ESLint. In this example, we'll also add the Next.js and React plugins, as well as the new ESLint Flat Config.

1. Swap out the required dependencies

First, uninstall the existing dependencies from the root package.json file...

package-install
npm uninstall @biomejs/biome ultracite
text
...and install the new ones:
package-install
npm install -D eslint @next/eslint-plugin-next eslint-plugin-react eslint-plugin-react-hooks typescript-eslint
text

2. Configure ESLint

Delete the existing biome.json file in the root of the project, and create a new eslint.config.mjs file:

js title="eslint.config.mjs"
import react from 'eslint-plugin-react';
import next from '@next/eslint-plugin-next';
import hooks from 'eslint-plugin-react-hooks';
import ts from 'typescript-eslint'

export default [
...ts.configs.recommended,
{
ignores: ['/.next'],
},
{
files: ['/.ts', '/.tsx'],
plugins: {
react: react,
'react-hooks': hooks,
'@next/next': next,
},
rules: {
...react.configs['jsx-runtime'].rules,
...hooks.configs.recommended.rules,
...next.configs.recommended.rules,
...next.configs['core-web-vitals'].rules,
'@next/next/no-img-element': 'error',
},
},
]

text

3. Install the ESLint VSCode extension

<Tip>
This is generally installed if you selected "JavaScript" as a language to support when you first set up Visual Studio Code.
</Tip>

Install the ESLint VSCode extension to get linting and formatting support in your editor.

4. Update your .vscode/settings.json file

Add the following to your .vscode/settings.json file to match the following:

json title=".vscode/settings.json"
{
"editor.codeActionsOnSave": {
"source.fixAll": true,
"source.fixAll.eslint": true
},
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"editor.formatOnPaste": true,
"editor.formatOnSave": true,
"emmet.showExpandedAbbreviation": "never",
"prettier.enable": true,
"tailwindCSS.experimental.configFile": "./packages/tailwind-config/config.ts",
"typescript.tsdk": "node_modules/typescript/lib"
}
text

5. Re-enable the lint script

As Next.js uses ESLint for linting, we can re-enable the lint script in the root package.json files. In each of the Next.js apps, update the package.json file to include the following:

json title="apps/app/package.json {3}"
{
"scripts": {
"lint": "bun --bun next lint"
}
}
text
---

Content/Docs/Migrations/Flags/Hypertune

---
title: Switch to Hypertune
description: How to change the feature flag provider to Hypertune.
type: integration
summary: How to switch the feature flag provider to Hypertune.
prerequisites:
- /docs/packages/flags
---

Hypertune is the most flexible platform for feature flags, A/B testing, analytics and app configuration. Built with full end-to-end type-safety, Git version control and local, synchronous, in-memory flag evaluation. Optimized for TypeScript, React and Next.js.

Here's how to switch your next-forge project to use Hypertune for feature flags!

1. Create a new Hypertune project

Go to Hypertune and create a new project using the next-forge template. Then go to the Settings page of your project and copy the main token.

2. Update the environment variables

Update the environment variables across the project. For example:

js title="apps/app/.env"
// Add this:
NEXT_PUBLIC_HYPERTUNE_TOKEN=""
text
Add a .env file to the feature-flags package with the following contents:
js title="packages/feature-flags/.env"
NEXT_PUBLIC_HYPERTUNE_TOKEN=""
HYPERTUNE_FRAMEWORK=nextApp
HYPERTUNE_OUTPUT_DIRECTORY_PATH=generated
HYPERTUNE_PLATFORM=vercel
HYPERTUNE_GET_HYPERTUNE_IMPORT_PATH=../lib/getHypertune
text

3. Update the keys.ts file in the feature-flags package

Use the NEXT_PUBLIC_HYPERTUNE_TOKEN environment variable in the call to createEnv:

ts title="packages/feature-flags/keys.ts {6-8,14}"
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';

export const keys = () =>
createEnv({
client: {
NEXT_PUBLIC_HYPERTUNE_TOKEN: z.string().min(1),
},
server: {
FLAGS_SECRET: z.string().optional(),
},
runtimeEnv: {
FLAGS_SECRET: process.env.FLAGS_SECRET,
NEXT_PUBLIC_HYPERTUNE_TOKEN: process.env.NEXT_PUBLIC_HYPERTUNE_TOKEN,
},
});

text

4. Swap out the required dependencies

First, delete the create-flag.ts file.

Then, uninstall the existing dependencies from the feature-flags package:

package-install
npm uninstall @repo/analytics --filter @repo/feature-flags
text
Then, install the new dependencies:
package-install
npm install hypertune server-only --filter @repo/feature-flags
text

5. Set up Hypertune code generation

Add analyze and build scripts to the package.json file for the feature-flags package, which both execute the hypertune command:

json title="packages/feature-flags/package.json"
{
"scripts": {
"analyze": "hypertune",
"build": "hypertune"
}
}
text
Then run code generation with the following command:
sh title="Terminal"
bun run build --filter @repo/feature-flags
text
This will generate the following files:
txt
packages/feature-flags/generated/hypertune.ts
packages/feature-flags/generated/hypertune.react.tsx
packages/feature-flags/generated/hypertune.vercel.tsx
text

6. Set up Hypertune client instance

Add a getHypertune.ts file in the feature-flags package which defines a getHypertune function that returns an initialized instance of the Hypertune SDK on the server:

ts title="packages/feature-flags/lib/getHypertune.ts"
import 'server-only';
import { auth } from '@repo/auth/server';
import { noStore } from 'next/cache';
import type { ReadonlyHeaders } from 'next/dist/server/web/spec-extension/adapters/headers';
import type { ReadonlyRequestCookies } from 'next/dist/server/web/spec-extension/adapters/request-cookies';
import { createSource } from '../generated/hypertune';
import { getVercelOverride } from '../generated/hypertune.vercel';
import { keys } from '../keys';

const hypertuneSource = createSource({
token: keys().NEXT_PUBLIC_HYPERTUNE_TOKEN,
});

export default async function getHypertune(params?: {
headers?: ReadonlyHeaders;
cookies?: ReadonlyRequestCookies;
}) {
noStore();
await hypertuneSource.initIfNeeded(); // Check for flag updates

const { userId, orgId, sessionId } = await auth();

// Respect flag overrides set by the Vercel Toolbar
hypertuneSource.setOverride(await getVercelOverride());

return hypertuneSource.root({
args: {
context: {
environment: process.env.NODE_ENV,
user: { id: userId ?? '', sessionId: sessionId ?? '' },
org: { id: orgId ?? '' },
},
},
});
}

text

7. Update index.ts

Hypertune automatically generates feature flag functions that use the flags package. To export them the same way as before, update the index.ts file to export everything from the generated/hypertune.vercel.ts file:

ts title="packages/feature-flags/index.ts"
export * from "./generated/hypertune.vercel.tsx"
text
Hypertune adds a Flag suffix to all these generated feature flag functions, so you will need to update flag usages with this, e.g. showBetaFeature => showBetaFeatureFlag.

8. Add more feature flags

To add more feature flags, create them in the Hypertune UI and then re-run code generation. They will be automatically added to your generated files.

---

Content/Docs/Migrations/Documentation/Fumadocs

---
title: Switch to Fumadocs
description: How to change the documentation provider to Fumadocs.
type: integration
summary: How to switch the documentation provider to Fumadocs.
prerequisites:
- /docs/apps/docs
---

Fumadocs is a beautiful & powerful docs framework powered by Next.js, allowing advanced customisations.

1. Create a Fumadocs App

Fumadocs is similar to a set of libraries built on Next.js App Router, which works very differently from a hosted solution like Mintlify, or other frameworks/static site generator that takes complete control over your app.

To begin, you can use a command to initialize a Fumadocs app quicky:

sh title="Terminal"
bunx create-fumadocs-app
text
Here we assume you have enabled Fumadocs MDX, Tailwind CSS, and without a default ESLint config.

What is a Content Source?

The input/source of your content, it can be a CMS, or local data layers like Content Collections and Fumadocs MDX (the official content source).

Fumadocs is designed carefully to allow a custom content source, there's also examples for Sanity if you are interested.

<Note>lib/source.ts is where you organize code for content sources.</Note>

Update your Tailwind CSS

Start the app with bun dev.

If some styles are missing, it could be due to your monorepo setup, you can change the content property in your Tailwind CSS config (tailwind.config.mjs) to ensure it works:

js title="tailwind.config.mjs"
export default {
content: [
// from
'./node_modules/fumadocs-ui/dist//*.js',
// to
'../../node_modules/fumadocs-ui/dist//*.js',

'./components//*.{ts,tsx}',
// ...
],
};

text
You can either keep the Tailwind config file isolated to the docs, or merge it with your existing config from the tailwind-config package.

2. Migrate MDX Files

Fumadocs, same as Mintlify, utilize MDX for content files. You can move the .mdx files from your Mintlify app to content/docs directory.

<Note>Fumadocs requires a title frontmatter property.</Note>

The MDX syntax of Fumadocs is almost identical to Mintlify, despite from having different components and usage for code blocks. Visit Markdown for supported Markdown syntax.

Code Block

Code block titles are formatted with title="Title".

#### Before

sh title="Mintlify"
``sh title="Name"
bun install
text

#### After

```sh title="Fumadocs"
`sh title="title="Name""
bun install

text

Code highlighting is done with an inline comment.

#### Before

```ts title="Mintlify"
`ts {1}
console.log('Highlighted');

text

#### After

```ts title="Fumadocs"

ts
console.log('Highlighted'); // [!code highlight]

text
In Fumadocs, you can also highlight specific words.
ts title="Fumadocs"
console.log('Highlighted'); // [!code word:Highlighted]
text

Code Groups

For code groups, you can use the Tabs component:

#### Before

tsx title="Mintlify"
<CodeGroup>

`ts title="Tab One"
console.log('Hello, world!');

text
ts title="Tab Two"
console.log('Hello, world!');
text
</CodeGroup>

#### After

```tsx title="Fumadocs"
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';

<Tabs items={["Tab 1", "Tab 2"]}>

`ts title="tab="Tab 1""
console.log('A');

text
ts title="tab="Tab 2""
console.log('B');
text
</Tabs>

Fumadocs also has a built-in integration for TypeScript Twoslash, check it out in the Setup Guide.

Callout

Fumadocs uses a generic Callout component for callouts, as opposed to Mintlify's specific ones.

#### Before

``tsx title="Mintlify"
<Note>Hello World</Note>
<Warning>Hello World</Warning>
<Info>Hello World</Info>
<Tip>Hello World</Tip>
<Check>Hello World</Check>

text
#### After
tsx title="Fumadocs"
<Callout title="Title" type="info">Hello World</Callout>
<Callout title="Title" type="warn">Hello World</Callout>
<Callout title="Title" type="error">Hello World</Callout>
text

Adding Components

To use components without import, add them to your MDX component.

tsx title="app/docs/[[...slug]]/page.tsx"
import { Tabs, Tab } from 'fumadocs-ui/components/tabs';

<MDX components={{ Tabs, Tab }} />;
text

3. Migrate mint.json File

Instead of a single file, you can configure Fumadocs using code.

The sidebar items are generated from your file system, Fumadocs takes meta.json as the configurations of a folder.
You don't need to hardcode the sidebar config manually.

For example, to customise the order of pages in content/docs/components folder, you can create a meta.json folder in the directory:

json title="meta.json"
{
"title": "Components", // optional
"pages": ["index", "apple"] // file names (without extension)
}
text
Fumadocs also support the rest operator (...) if you want to include the other pages.
json title="meta.json"
{
"title": "Components", // optional
"pages": ["index", "apple", "..."] // file names (without extension)
}
text
Visit the Pages Organization Guide for an overview of supported syntaxs.

Appearance

The overall theme can be customised using CSS variables and/or presets.

#### CSS variables

In your global CSS file:

css title="global.css"
:root {
/ hsl values, like hsl(239 37% 50%) but without
hsl() /
--background: 239 37% 50%;

/ Want a max width for docs layout? /
--fd-layout-width: 1400px;
}

.dark {
/ hsl values, like hsl(239 37% 50%) but without
hsl() /
--background: 239 37% 50%;
}

text
#### Tailwind Presets

In your Tailwind config, use the preset option.

js title="tailwind.config.mjs"
import { createPreset } from 'fumadocs-ui/tailwind-plugin';

/ @type {import('tailwindcss').Config} */
export default {
presets: [
createPreset({
preset: 'ocean',
}),
],
};
text
See all available presets.

Layout Styles

You can open app/layout.config.tsx, it contains the shared options for layouts.
Fumadocs offer a default Docs Layout for documentation pages, and Home Layout for other pages.

You can customise the layouts in layout.tsx.

app/api/search/route.ts contains the Route Handler for search, it is powered by Orama by default.

Navigation links are passed to layouts, you can also customise them in your Layout config.

tsx title="app/layout.config.tsx"
import { BookIcon } from 'lucide-react';
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';

export const baseOptions: BaseLayoutProps = {
links: [
{
icon: <BookIcon />,
text: 'Blog',
url: '/blog',
},
],
};
text
See all supported items.

Done

Now, you should be able to build and preview the docs.

Visit Fumadocs for details and additional features.

---

Content/Docs/Migrations/Database/Appwrite

---
title: Switch to Appwrite Databases
description: How to change the database provider to Appwrite Databases.
type: integration
summary: How to switch the database provider to Appwrite Databases.
prerequisites:
- /docs/packages/database
related:
- /docs/migrations/database/supabase
- /docs/migrations/authentication/appwrite
- /docs/migrations/storage/appwrite
---

Appwrite Databases is a document database service that's part of the Appwrite platform. It provides a flexible schema system with collections, documents, and built-in permissions.

next-forge uses Neon as the database provider with Prisma as the ORM. This guide will help you switch from Neon and Prisma to Appwrite Databases.

<Warning>
Appwrite uses a document database model, not SQL. There is no Prisma ORM equivalent — you'll use Appwrite's SDK and Query builder instead. Your data model will need to be restructured around collections and documents rather than relational tables.
</Warning>

1. Replace the database package dependencies

Uninstall the existing Neon and Prisma dependencies from the database package...

package-install
npm uninstall @neondatabase/serverless @prisma/adapter-neon @prisma/client prisma ws @types/ws --filter @repo/database
text
...and install the Appwrite dependency:
package-install
npm install node-appwrite --filter @repo/database
text

2. Update environment variables

Add the following environment variables to your .env.local file. You can find these values in your Appwrite project's Settings page:

bash title=".env.local"
NEXT_PUBLIC_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
NEXT_PUBLIC_APPWRITE_PROJECT_ID=your-project-id
APPWRITE_API_KEY=your-api-key
APPWRITE_DATABASE_ID=your-database-id
text
<Note>
You'll need to create a database in the Appwrite Console first. The
APPWRITE_DATABASE_ID is the ID of the database you create.
</Note>

3. Update the environment keys

Update the keys.ts file to validate the new environment variables:

ts title="packages/database/keys.ts"
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';

export const keys = () =>
createEnv({
server: {
APPWRITE_API_KEY: z.string().min(1),
APPWRITE_DATABASE_ID: z.string().min(1),
},
client: {
NEXT_PUBLIC_APPWRITE_ENDPOINT: z.string().url(),
NEXT_PUBLIC_APPWRITE_PROJECT_ID: z.string().min(1),
},
runtimeEnv: {
APPWRITE_API_KEY: process.env.APPWRITE_API_KEY,
APPWRITE_DATABASE_ID: process.env.APPWRITE_DATABASE_ID,
NEXT_PUBLIC_APPWRITE_ENDPOINT:
process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT,
NEXT_PUBLIC_APPWRITE_PROJECT_ID:
process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID,
},
});

text

4. Update the database package

Replace the contents of index.ts with a configured Appwrite Databases client:

ts title="packages/database/index.ts"
import 'server-only';
import { Client, Databases, Query, ID, Permission, Role } from 'node-appwrite';

const client = new Client()
.setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT!)
.setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID!)
.setKey(process.env.APPWRITE_API_KEY!);

export const database = new Databases(client);
export const databaseId = process.env.APPWRITE_DATABASE_ID!;

export { Query, ID, Permission, Role };

text

5. Remove Prisma files

Delete the Prisma-specific files that are no longer needed:

sh title="Terminal"
rm -rf packages/database/prisma packages/database/prisma.config.ts
text
Also remove any Prisma-related scripts from your package.json files (e.g., migrate, generate, studio).

6. Understanding Appwrite's data model

Appwrite organizes data differently from SQL databases:

| SQL (Prisma) | Appwrite |
| --- | --- |
| Database | Database |
| Table | Collection |
| Row | Document |
| Column | Attribute |
| Migration | Console / SDK |

Collections are defined with typed attributes (string, integer, boolean, float, email, enum, URL, IP, datetime, relationship) and can be created via the Appwrite Console or programmatically with the SDK.

7. Create collections

You can create collections through the Appwrite Console, or programmatically using the server SDK. Here's an example of creating a posts collection:

ts title="Example: Creating a collection"
import { database, databaseId, ID, Permission, Role } from '@repo/database';

await database.createCollection(
databaseId,
ID.unique(),
'posts',
[
Permission.read(Role.any()),
Permission.create(Role.users()),
Permission.update(Role.users()),
Permission.delete(Role.users()),
]
);

text
Then define its attributes:
ts title="Example: Defining attributes"
const collectionId = 'your-collection-id';

await database.createStringAttribute(
databaseId,
collectionId,
'title',
255,
true // required
);

await database.createStringAttribute(
databaseId,
collectionId,
'content',
10000,
false // optional
);

await database.createStringAttribute(
databaseId,
collectionId,
'authorId',
255,
true
);

await database.createDatetimeAttribute(
databaseId,
collectionId,
'publishedAt',
false
);

text
<Note>
For most projects, it's easier to create collections and attributes through the Appwrite Console UI rather than programmatically.
</Note>

8. Perform CRUD operations

Here's how to perform basic CRUD operations with the Appwrite SDK:

Create a document

ts
import { database, databaseId, ID } from '@repo/database';

const post = await database.createDocument(
databaseId,
'posts', // collection ID
ID.unique(),
{
title: 'Hello World',
content: 'This is my first post.',
authorId: 'user-123',
publishedAt: new Date().toISOString(),
}
);

text

Read documents

ts
import { database, databaseId, Query } from '@repo/database';

// Get a single document
const post = await database.getDocument(
databaseId,
'posts',
'document-id'
);

// List documents with filters
const posts = await database.listDocuments(
databaseId,
'posts',
[
Query.equal('authorId', 'user-123'),
Query.orderDesc('publishedAt'),
Query.limit(10),
]
);

text

Update a document

ts
import { database, databaseId } from '@repo/database';

const updated = await database.updateDocument(
databaseId,
'posts',
'document-id',
{
title: 'Updated Title',
}
);

text

Delete a document

ts
import { database, databaseId } from '@repo/database';

await database.deleteDocument(
databaseId,
'posts',
'document-id'
);

text

9. Permissions

Appwrite uses a document-level permissions system instead of SQL's Row Level Security. You can set permissions when creating or updating documents:

ts
import { database, databaseId, ID, Permission, Role } from '@repo/database';

const post = await database.createDocument(
databaseId,
'posts',
ID.unique(),
{
title: 'Private Post',
content: 'Only I can see this.',
authorId: 'user-123',
},
[
Permission.read(Role.user('user-123')),
Permission.update(Role.user('user-123')),
Permission.delete(Role.user('user-123')),
]
);

text
Common permission patterns:

- Role.any() — Anyone (including guests)
-
Role.users() — Any authenticated user
-
Role.user('userId') — A specific user
-
Role.team('teamId') — Members of a specific team
-
Role.team('teamId', 'admin') — Team members with a specific role

10. Update your apps

Replace Prisma queries throughout your application with Appwrite SDK calls:

tsx
// Before (Prisma)
import { database } from '@repo/database';
const posts = await database.post.findMany({
where: { authorId: userId },
orderBy: { createdAt: 'desc' },
});

// After (Appwrite)
import { database, databaseId, Query } from '@repo/database';
const { documents: posts } = await database.listDocuments(
databaseId,
'posts',
[
Query.equal('authorId', userId),
Query.orderDesc('$createdAt'),
Query.limit(25),
]
);

text

Additional features

Realtime subscriptions

Appwrite supports realtime subscriptions on the client side. You can listen for changes to documents:

ts
import { client } from '@repo/auth/client';

const unsubscribe = client.subscribe(
databases.${databaseId}.collections.posts.documents,
(response) => {
// Handle realtime event
console.log(response);
}
);

text

Relationships

Appwrite supports relationships between collections. You can create one-to-one, one-to-many, and many-to-many relationships via the Console or SDK:

ts
import { database, databaseId } from '@repo/database';

await database.createRelationshipAttribute(
databaseId,
'posts',
'comments',
'oneToMany',
false,
'postId',
'comments'
);

text

Indexes

For better query performance, create indexes on frequently queried attributes:

ts
import { database, databaseId } from '@repo/database';

await database.createIndex(
databaseId,
'posts',
'idx_authorId',
'key',
['authorId']
);

text
For more information, see the Appwrite Databases documentation.

---

Content/Docs/Migrations/Database/Convex

---
title: Switch to Convex
description: How to change the database provider to Convex.
type: integration
summary: How to switch the database provider to Convex.
prerequisites:
- /docs/packages/database
---

Convex is a reactive database platform with real-time sync, TypeScript-first backend functions, and fully managed infrastructure. Unlike traditional databases, Convex combines the database, server functions, and real-time subscriptions into a single platform.

next-forge uses Neon as the database provider with Prisma as the ORM. This guide will provide the steps you need to switch the database provider from Neon to Convex. Since Convex replaces both the database and ORM layers, this is a more significant change than switching between SQL databases.

Here's how to switch from Neon to Convex for your next-forge project.

1. Sign up to Convex

Create a free account at convex.dev. You can manage your projects through the Convex Dashboard.

2. Replace the dependencies

Uninstall the existing dependencies...

package-install
npm uninstall @neondatabase/serverless @prisma/adapter-neon @prisma/client prisma ws @types/ws --filter @repo/database
text
... and install Convex:
package-install
npm install convex --filter @repo/database
text

3. Initialize Convex

From the root of your project, run:

sh title="Terminal"
npx convex dev
text
This will prompt you to log in, create a new project, and generate a convex/ directory in your project root with the configuration files. It will also create a .env.local file with your CONVEX_DEPLOYMENT and NEXT_PUBLIC_CONVEX_URL variables.

4. Set up the Convex client provider

Create a client component to wrap your app with the Convex provider. Add this to your app:

tsx title="packages/database/provider.tsx"
'use client';

import type { ReactNode } from 'react';
import { ConvexProvider, ConvexReactClient } from 'convex/react';
import { keys } from './keys';

const convex = new ConvexReactClient(keys().NEXT_PUBLIC_CONVEX_URL);

export const ConvexClientProvider = ({ children }: { children: ReactNode }) => (
<ConvexProvider client={convex}>
{children}
</ConvexProvider>
);

text
Then wrap your app layout with the provider:
tsx title="apps/app/app/layout.tsx"
import { ConvexClientProvider } from '@repo/database/provider';

// ...

const RootLayout = ({ children }: { children: ReactNode }) => (
<html lang="en">
<body>
<ConvexClientProvider>
{children}
</ConvexClientProvider>
</body>
</html>
);

export default RootLayout;

text

5. Update the database package

Replace the contents of the database package's main export. Since Convex uses its own function system instead of a traditional client, the export changes significantly:

ts title="packages/database/index.ts"
export { ConvexClientProvider } from './provider';
text
Delete the prisma/ directory from @repo/database:
sh title="Terminal"
rm -rf packages/database/prisma
text
Update keys.ts to use the Convex environment variable:
ts title="packages/database/keys.ts"
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';

export const keys = () =>
createEnv({
client: {
NEXT_PUBLIC_CONVEX_URL: z.url(),
},
runtimeEnv: {
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
},
});

text

6. Define your schema

Create a schema file in the convex/ directory. Here's an example equivalent to the default Prisma Page model:

ts title="convex/schema.ts"
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';

export default defineSchema({
pages: defineTable({
title: v.string(),
content: v.optional(v.string()),
}),
});

text
Run npx convex dev to push your schema to Convex and generate types.

7. Write queries and mutations

Create server functions for your data access. Convex uses its own function system instead of raw SQL or an ORM:

ts title="convex/pages.ts"
import { query, mutation } from './_generated/server';
import { v } from 'convex/values';

export const list = query({
handler: async (ctx) => {
return await ctx.db.query('pages').collect();
},
});

export const create = mutation({
args: {
title: v.string(),
content: v.optional(v.string()),
},
handler: async (ctx, args) => {
return await ctx.db.insert('pages', args);
},
});

text

8. Update your app code

Convex uses React hooks for data fetching with automatic real-time updates. Update your components to use useQuery and useMutation:

tsx title="app/(authenticated)/components/pages-list.tsx"
'use client';

import { useQuery, useMutation } from 'convex/react';
import { api } from '@repo/convex/_generated/api';

export const PagesList = () => {
const pages = useQuery(api.pages.list);
const createPage = useMutation(api.pages.create);

return (
<div>
<button onClick={() => createPage({ title: 'New Page' })}>
Create Page
</button>
{pages?.map((page) => (
<div key={page._id}>{page.title}</div>
))}
</div>
);
};

text
<Note>
Convex queries are reactive by default — your UI will automatically update when the underlying data changes, without any additional configuration.
</Note>

For server-side data fetching (e.g. in Server Components), use the Convex HTTP client:

tsx title="app/(authenticated)/page.tsx"
import { ConvexHttpClient } from 'convex/browser';
import { api } from '@repo/convex/_generated/api';

const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!);

const App = async () => {
const pages = await convex.query(api.pages.list);

return (
<div>
{pages.map((page) => (
<div key={page._id}>{page.title}</div>
))}
</div>
);
};

export default App;

text

9. Replace Prisma Studio

Delete the now unused Prisma Studio app:

sh title="Terminal"
rm -rf apps/studio
text
To manage your data, use the Convex Dashboard which provides a data browser, function logs, and deployment management.

10. Deploy

When deploying your app, set the NEXT_PUBLIC_CONVEX_URL environment variable in your hosting provider (e.g. Vercel). You can find this URL in your Convex Dashboard under your project's settings.

To deploy your Convex functions to production, run:

sh title="Terminal"
npx convex deploy
text
This deploys your schema and server functions to your production Convex instance.

---

Content/Docs/Migrations/Database/Drizzle

---
title: Switch to Drizzle
description: How to change the ORM to Drizzle.
type: integration
summary: How to switch the ORM to Drizzle.
prerequisites:
- /docs/packages/database
related:
- /docs/migrations/database/prisma-postgres
---

Drizzle is a brilliant, type-safe ORM growing quickly in popularity. If you want to switch to Drizzle, you have two options:

1. Keep Prisma and add the Drizzle API to the Prisma client. Drizzle have a great guide on how to do this.
2. Go all-in and switch to Drizzle.

Here, we'll assume you have a working Neon database and cover the second option.

<Callout type="warn">
Before starting, make sure your Prisma schema has been pushed to your database (e.g. by running
npx prisma db push in packages/database). The drizzle-kit pull command in Step 4 introspects the live database — if no tables exist yet, it will generate an empty schema file.
</Callout>

1. Swap out the required dependencies in @repo/database

Uninstall the existing dependencies...

package-install
npm uninstall @prisma/adapter-neon @prisma/client prisma --filter @repo/database
text
...and install the new ones:
package-install
npm install drizzle-orm @neondatabase/serverless --filter @repo/database
npm install -D drizzle-kit --filter @repo/database
text

2. Update the database connection code

Delete everything in @repo/database/index.ts and replace it with the following:

ts title="packages/database/index.ts"
import 'server-only';

import { drizzle } from 'drizzle-orm/neon-http';
import { neon } from '@neondatabase/serverless';
import { env } from '@repo/env';

const client = neon(env.DATABASE_URL);

export const database = drizzle({ client });

text

3. Create a drizzle.config.ts file

Next we'll create a Drizzle configuration file, used by Drizzle Kit and contains all the information about your database connection, migration folder and schema files. Create a drizzle.config.ts file in the packages/database directory with the following contents:

ts title="packages/database/drizzle.config.ts"
import { defineConfig } from 'drizzle-kit';
import { env } from '@repo/env';

export default defineConfig({
schema: './schema.ts',
out: './',
dialect: 'postgresql',
dbCredentials: {
url: env.DATABASE_URL,
},
});

text

4. Generate the schema file

Drizzle uses a schema file to define your database tables. Rather than create one from scratch, you can generate it from the existing database. In the packages/database folder, run the following command to generate the schema file:

sh
npx drizzle-kit pull
text
This should pull the schema from the database, creating a schema.ts file containing the table definitions and some other files.

If the generated schema.ts is empty, your database likely has no tables yet. You can either push your Prisma schema first (npx prisma db push) and re-run the pull, or create the schema manually. For example, the default next-forge Page model translates to:

ts title="packages/database/schema.ts"
import { pgTable, serial, text } from 'drizzle-orm/pg-core';

export const page = pgTable('Page', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
});

text

5. Update your queries

Now you can update your queries to use the Drizzle ORM.

For example, here's how we can update the page query in app/(authenticated)/page.tsx:

ts title="apps/app/app/(authenticated)/page.tsx {2, 7}"
import { database } from '@repo/database';
import { page } from '@repo/database/schema';

// ...

const App = async () => {
const pages = await database.select().from(page);

// ...
};

export default App;

text

6. Remove Prisma Studio

You can also delete the now unused Prisma Studio app located at apps/studio:

sh title="Terminal"
rm -fr apps/studio
text

7. Update the migration script in the root package.json

Change the migration script in the root package.json from Prisma to Drizzle. Update the migrate script to use Drizzle commands:

json
"scripts": {
"db:migrate": "cd packages/database && npx drizzle-kit migrate"
"db:generate": "cd packages/database && npx drizzle-kit generate"
"db:pull": "cd packages/database && npx drizzle-kit pull"
}
text
---

Content/Docs/Migrations/Database/Edgedb

---
title: Switch to EdgeDB
description: How to change the database provider to EdgeDB.
type: integration
summary: How to switch the database provider to EdgeDB.
prerequisites:
- /docs/packages/database
---

EdgeDB is an open-source Postgres data layer designed to address major ergonomic SQL and relational schema modeling limitations while improving type safety and performance.

<Note>
EdgeDB rebranded to "Gel" in February 2025. The
edgedb npm packages and CLI commands still work via compatibility shims. See the Gel announcement for details.
</Note>

next-forge uses Neon as the database provider with Prisma as the ORM as well as Clerk for authentication. This guide will provide the steps you need to switch the database provider from Neon to EdgeDB.

<Note>
For authentication, another guide will be provided to switch to EdgeDB Auth with access policies, social
auth providers, and more.
</Note>

Here's how to switch from Neon to EdgeDB for your next-forge project.

1. Create a new EdgeDB database

Create an account at EdgeDB Cloud. Once done, create a new instance (you can use EdgeDB's free tier). We'll later connect to it through the EdgeDB CLI.

2. Swap out the required dependencies in @repo/database

Uninstall the existing dependencies...

package-install
npm uninstall @prisma/adapter-neon @prisma/client prisma --filter @repo/database
text
... and install the new dependencies:
package-install
npm install edgedb @edgedb/generate
text

3. Setup EdgeDB in @repo/database package

In the @repo/database directory, run:

sh title="Terminal"
npx edgedb project init --server-instance <org_name>/<instance_name> --non-interactive
text
<Note>
Replace
<org_name> and <instance_name> with the EdgeDB's organization and instance you've previously created in the EdgeDB Cloud.
</Note>

The init command creates a new subdirectory called dbschema, which contains everything related to EdgeDB:

sh
dbschema
├── default.esdl
└── migrations
text
This command also links your environment to the EdgeDB Cloud instance, allowing the EdgeDB client libraries to automatically connect to it without any additional configuration.

You can also delete prisma/ directory from the @repo/database:

sh title="Terminal"
rm -fr packages/database/prisma
text

4. Update the database connection code

Update the database connection code to use an EdgeDB client:

ts title="packages/database/index.ts"
import 'server-only';

import { createClient } from "edgedb";

export const database = createClient();

text

5. Update the schema file and generate types

Now, you can modify the database schema:

sql title="dbschema/default.esdl"
module default {
type Page {
email: str {
constraint exclusive;
}
name: str
}
}
text
And apply your changes by running:
sh title="Terminal"
npx edgedb migration create
npx edgedb migration apply
text
Once complete, you can also generate a TypeScript query builder and types from your database schema:
sh title="Terminal"
npx @edgedb/generate edgeql-js
npx @edgedb/generate interfaces
text
These commands introspect the schema of your database and generate code in the dbschema directory.

6. Update your queries

Now you can update your queries to use the EdgeDB client.

For example, here’s how we can update the page query in app/(authenticated)/page.tsx:

tsx title="app/(authenticated)/page.tsx {2,7-8}"
import { database } from '@repo/database';
import edgeql from '@repo/database/dbschema/edgeql-js';

// ...

const App = async () => {
const pagesQuery = edgeql.select(edgeql.Page, () => ({ ...edgeql.Page['*'] }));
const pages = await pagesQuery.run(database);

// ...
};

export default App;

text

7. Replace Prisma Studio with EdgeDB UI

You can also delete the now unused Prisma Studio app located at apps/studio:

sh title="Terminal"
rm -fr apps/studio
text
To manage your database and browse your data, you can run:
sh title="Terminal"
npx edgedb ui
text

8. Extract EdgeDB environment variables for deployment

When deploying your app, you need to provide the EDGEDB_SECRET_KEY and EDGEDB_INSTANCE environment variables in your app's cloud provider to connect to your EdgeDB Cloud instance.

You can generate a dedicated secret key for your instance with npx edgedb cloud secretkey create or via the web UI's "Secret Keys" pane in your instance dashboard.

---

Content/Docs/Migrations/Database/Planetscale

---
title: Switch to PlanetScale
description: How to change the database provider to PlanetScale.
type: integration
summary: How to switch the database provider to PlanetScale.
prerequisites:
- /docs/packages/database
---

Here's how to switch from Neon to PlanetScale.

<Warning>
PlanetScale removed their free/Hobby tier in March 2024. The minimum plan now starts at $39/month. Keep this in mind when evaluating PlanetScale for your project.
</Warning>

1. Create a new database on PlanetScale

Once you create a database on PlanetScale, you will get a connection string. It will look something like this:


mysql://<username>:<password>@<region>.aws.connect.psdb.cloud/<database>
text
Keep this connection string handy, you will need it in the next step.

2. Update your environment variables

Update your environment variables to use the new PlanetScale connection string:

js title="apps/database/.env"
DATABASE_URL="mysql://<username>:<password>@<region>.aws.connect.psdb.cloud/<database>"
text
js title="apps/app/.env.local"
DATABASE_URL="mysql://<username>:<password>@<region>.aws.connect.psdb.cloud/<database>"
text
Etcetera.

3. Swap out the required dependencies in @repo/database

Uninstall the existing dependencies...

package-install
npm uninstall @neondatabase/serverless @prisma/adapter-neon ws @types/ws --filter @repo/database
text
...and install the new ones:
package-install
npm install @planetscale/database @prisma/adapter-planetscale --filter @repo/database
text

4. Update the database connection code

Update the database connection code to use the new PlanetScale adapter:

ts title="packages/database/index.ts {3-4, 17-18}"
import 'server-only';

import { Client, connect } from '@planetscale/database';
import { PrismaPlanetScale } from '@prisma/adapter-planetscale';
import { PrismaClient } from '@prisma/client';
import { env } from '@repo/env';

declare global {
var cachedPrisma: PrismaClient | undefined;
}

const client = connect({ url: env.DATABASE_URL });
const adapter = new PrismaPlanetScale(client);

export const database = new PrismaClient({ adapter });

text

5. Update your Prisma schema

Update your Prisma schema to use the new database provider:

prisma title="packages/database/prisma/schema.prisma {10}"
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
provider = "prisma-client"
output = "../generated"
}

datasource db {
provider = "mysql"
relationMode = "prisma"
}

// This is a stub model.
// Delete it and add your own Prisma models.
model Page {
id Int @id @default(autoincrement())
email String @unique
name String?
}

text

6. Add a dev script

Add a dev script to your package.json:

json title="packages/database/package.json {3}"
{
"scripts": {
"dev": "pscale connect [database_name] [branch_name] --port 3309"
}
}
text
---

Content/Docs/Migrations/Database/Prisma Postgres

---
title: Switch to Prisma Postgres
description: How to change the database provider to Prisma Postgres.
type: integration
summary: How to switch the database provider to Prisma Postgres.
prerequisites:
- /docs/packages/database
related:
- /docs/migrations/database/drizzle
---

Here's how to switch from Neon to Prisma Postgres — a serverless database with zero cold starts and a generous free tier. You can learn more about its architecture that enables this here.

1. Create a new Prisma Postgres instance

Start by creating a new Prisma Postgres instance via the Prisma Data Platform and get your connection string. It will look something like this:


prisma+postgres://accelerate.prisma-data.net/?api_key=ey....
text

2. Update your environment variables

Update your environment variables to use the new Prisma Postgres connection string:

js title="apps/database/.env"
DATABASE_URL="prisma+postgres://accelerate.prisma-data.net/?api_key=ey...."
text

3. Swap out the required dependencies in @repo/database

Uninstall the existing dependencies...

package-install
npm uninstall @neondatabase/serverless @prisma/adapter-neon ws @types/ws
text
... and install the new dependencies:
package-install
npm install @prisma/extension-accelerate
text

4. Update the database connection code

Update the database connection code to use the new Prisma Postgres adapter:

ts title="packages/database/index.ts {4,7}"
import 'server-only';

import { env } from '@repo/env';
import { withAccelerate } from '@prisma/extension-accelerate';
import { PrismaClient } from '@prisma/client';

export const database = new PrismaClient().$extends(withAccelerate());

text
Your project is now configured to use your Prisma Postgres instance for migrations and queries.

5. Explore caching and real-time database events

Note that thanks to the first-class integration of other Prisma products, Prisma Postgres comes with additional features out-of-the-box that you may find useful:

- Prisma Accelerate: Enables connection pooling and global caching
- Prisma Pulse: Enables real-time streaming of database events

Caching

To cache a query with Prisma Client, you can add the swr and ttl options to any given query, for example:

ts title="page.tsx"
const pages = await prisma.page.findMany({
cacheStrategy: {
swr: 60, // 60 seconds
ttl: 60, // 60 seconds
},
});
text
Learn more in the Accelerate documentation.

Real-time database events

<Warning>
Prisma Pulse (
@prisma/extension-pulse) has been paused and may be deprecated. Check the Prisma Pulse documentation for the latest status before proceeding.
</Warning>

To stream database change events from your database, you first need to install the Pulse extension:

package-install
npm install @prisma/extension-pulse
text
Next, you need to add your Pulse API key as an environment variable:
ts title="apps/database/.env"
PULSE_API_KEY="ey...."
text
<Info>
You can find your Pulse API key in your Prisma Postgres connection string, it's the value of the
api_key argument and starts with ey.... Alternatively, you can find the API key in your Prisma Postgres Dashboard.
</Info>

Then, update the env package to include the new PULSE_API_KEY environment variable:

ts title="packages/env/index.ts {3,11}"
export const server = {
// ...
PULSE_API_KEY: z.string().min(1).startsWith('ey'),
};

export const env = createEnv({
client,
server,
runtimeEnv: {
// ...
PULSE_API_KEY: process.env.PULSE_API_KEY,
},
});

text
Finally, update the database connection code to include the Pulse extension:
ts title="packages/database/index.ts {2, 7-9}"
import 'server-only';
import { withPulse } from '@prisma/extension-pulse';
import { withAccelerate } from '@prisma/extension-accelerate';
import { PrismaClient } from '@prisma/client';
import { env } from '@repo/env';

export const database = new PrismaClient()
.$extends(withAccelerate())
.$extends(withPulse({ apiKey: env.PULSE_API_KEY })) ;

text
You can now stream any change events from your database using the following code:
ts title="page.tsx"
const stream = await prisma.page.stream();

console.log(Waiting for an event on the \Page\ table ... );

for await (const event of stream) {
console.log('Received an event:', event);
}

text
Learn more in the Pulse documentation.

---

Content/Docs/Migrations/Database/Supabase

---
title: Switch to Supabase
description: How to change the database provider to Supabase.
type: integration
summary: How to switch the database provider to Supabase.
prerequisites:
- /docs/packages/database
---

Supabase is an open source Firebase alternative providing a Postgres database, Authentication, instant APIs, Edge Functions, Realtime subscriptions, and Storage.

next-forge uses Neon as the database provider with Prisma as the ORM as well as Clerk for authentication. This guide will provide the steps you need to switch the database provider from Neon to Supabase. This guide is based on a few existing resources, including Supabase's guide and Prisma's guide.

<Note>
For authentication, see the Supabase Auth migration guide to switch from Clerk to Supabase Auth with organization management, user roles, and more.
</Note>

Here's how to switch from Neon to Supabase for your next-forge project.

1. Sign up to Supabase

Create a free account at supabase.com. You can manage your projects through the Dashboard or use the Supabase CLI.

_We'll be using both the Dashboard and CLI throughout this guide._

2. Create a Project

Create a new project from the Supabase Dashboard. Give it a name and choose your preferred region. Once created, you'll get access to your project's connection details. Head to the Settings page, then click on Database.

We'll need to keep track of the following for the next step:

- The Database URL in Transaction mode, with the port ending in 6543. We'll call this DATABASE_URL.
- The Database URL in
Session mode, with the port ending in 5432. We'll call this DIRECT_URL.

3. Update the environment variables

Update the .env file with the Supabase connection details. Make sure you add ?pgbouncer=true&connection_limit=1 to the end of the DATABASE_URL value.

js title=".env"
DATABASE_URL="postgres://postgres:[email protected]:54322/postgres?pgbouncer=true&connection_limit=1"
DIRECT_URL="postgres://postgres:[email protected]:54322/postgres"
text
<Note>
pgbouncer=true disables Prisma from generating prepared statements. This is required since our connection pooler does not support prepared statements in transaction mode yet. The connection_limit=1 parameter is only required if you are using Prisma from a serverless environment.
</Note>

4. Replace the dependencies

Prisma doesn't have a Supabase adapter yet, so we just need to remove the Neon adapter and connect to Supabase directly.

First, remove the Neon dependencies from the project...

package-install
npm uninstall @neondatabase/serverless @prisma/adapter-neon ws @types/ws --filter @repo/database
text
... and add the Supabase dependencies:
package-install
npm install -D supabase --filter @repo/database
text

5. Update the database package

Update the database package. We'll remove the Neon extensions and connect to Supabase directly, which should automatically use the environment variables we set earlier.

ts title="packages/database/index.ts"
import 'server-only';
import { PrismaClient } from '@prisma/client';

export const database = new PrismaClient();

export * from '@prisma/client';

text

6. Update the Prisma schema

Update the prisma/schema.prisma file so it contains the DIRECT_URL. This allows us to use the Prisma CLI to perform other actions on our database (e.g. migrations) by bypassing Supavisor.

js title="prisma/schema.prisma {4}"
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}
text
<Note>
You don't need
relationMode = "prisma" here — Supabase is PostgreSQL and supports foreign keys natively. Only add it if you specifically need Prisma to emulate relations (e.g., for compatibility with databases that don't support foreign keys).
</Note>

Now you can run the migration from the root of your next-forge project:

sh title="Terminal"
bun run migrate
text

7. Set up Row Level Security (RLS)

Row Level Security (RLS) is a powerful PostgreSQL feature that allows you to control access to database rows based on the authenticated user. This is essential for multi-tenant applications where users should only see their own data.

<Note>
RLS policies use
auth.uid() to get the authenticated user's ID from Supabase Auth. Make sure you've completed the Supabase Auth migration first.
</Note>

Enable RLS on your tables

First, enable RLS on the tables you want to protect. You can do this via the Supabase dashboard or by running SQL migrations:

sql title="Enable RLS"
-- Enable RLS on organizations table
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;

-- Enable RLS on organization_members table
ALTER TABLE organization_members ENABLE ROW LEVEL SECURITY;

-- Enable RLS on any other tables that need protection
ALTER TABLE your_table ENABLE ROW LEVEL SECURITY;

text

Create RLS policies

Once RLS is enabled, create policies that define who can access what data:

#### Organization policies

sql title="Organization RLS Policies"
-- Policy: Users can only view organizations they're members of
CREATE POLICY "Users can view their organizations"
ON organizations FOR SELECT
USING (
id IN (
SELECT organization_id
FROM organization_members
WHERE user_id = auth.uid()
)
);

-- Policy: Only organization owners can update organizations
CREATE POLICY "Owners can update their organizations"
ON organizations FOR UPDATE
USING (
id IN (
SELECT organization_id
FROM organization_members
WHERE user_id = auth.uid() AND role = 'owner'
)
);

-- Policy: Only organization owners can delete organizations
CREATE POLICY "Owners can delete their organizations"
ON organizations FOR DELETE
USING (
id IN (
SELECT organization_id
FROM organization_members
WHERE user_id = auth.uid() AND role = 'owner'
)
);

-- Policy: Any authenticated user can create an organization
CREATE POLICY "Authenticated users can create organizations"
ON organizations FOR INSERT
WITH CHECK (auth.uid() IS NOT NULL);

text
#### Organization member policies
sql title="Organization Member RLS Policies"
-- Policy: Users can view members of their organizations
CREATE POLICY "Users can view organization members"
ON organization_members FOR SELECT
USING (
organization_id IN (
SELECT organization_id
FROM organization_members
WHERE user_id = auth.uid()
)
);

-- Policy: Owners and admins can add members
CREATE POLICY "Owners and admins can add members"
ON organization_members FOR INSERT
WITH CHECK (
organization_id IN (
SELECT organization_id
FROM organization_members
WHERE user_id = auth.uid() AND role IN ('owner', 'admin')
)
);

-- Policy: Owners and admins can update member roles
CREATE POLICY "Owners and admins can update members"
ON organization_members FOR UPDATE
USING (
organization_id IN (
SELECT organization_id
FROM organization_members
WHERE user_id = auth.uid() AND role IN ('owner', 'admin')
)
);

-- Policy: Owners and admins can remove members
CREATE POLICY "Owners and admins can remove members"
ON organization_members FOR DELETE
USING (
organization_id IN (
SELECT organization_id
FROM organization_members
WHERE user_id = auth.uid() AND role IN ('owner', 'admin')
)
);

text

Testing RLS policies

You can test your RLS policies using the Supabase SQL Editor with the following pattern:

sql title="Test RLS as a specific user"
-- Set the user ID for testing
SELECT auth.uid(); -- This will be NULL initially

-- To test as a specific user, you would typically:
-- 1. Make requests through your application with that user's session
-- 2. Or use Supabase's testing tools in the dashboard

-- Check what organizations a user can see
SELECT * FROM organizations;

-- This query will automatically be filtered by your RLS policies

text

Common RLS patterns

#### User-owned data

For data that belongs directly to a user (like user profiles or settings):

sql
CREATE POLICY "Users can only access their own data"
ON user_data FOR ALL
USING (user_id = auth.uid());
text
#### Tenant isolation

For multi-tenant data where access is determined by an organization or tenant ID:

sql
CREATE POLICY "Users can only access their tenant's data"
ON tenant_data FOR ALL
USING (
tenant_id IN (
SELECT tenant_id FROM user_tenants WHERE user_id = auth.uid()
)
);
text
#### Public read, authenticated write

For data that everyone can read but only authenticated users can modify:

sql
-- Read policy (no authentication required)
CREATE POLICY "Anyone can read"
ON public_data FOR SELECT
USING (true);

-- Write policy (authentication required)
CREATE POLICY "Authenticated users can write"
ON public_data FOR INSERT
WITH CHECK (auth.uid() IS NOT NULL);

text
<Warning>
When RLS is enabled on a table, all access is denied by default. You must create policies to allow access. Make sure to test thoroughly to avoid accidentally blocking legitimate access.
</Warning>

For more information, see the Supabase Row Level Security guide.

---

Content/Docs/Migrations/Database/Turso

---
title: Switch to Turso
description: How to change the database provider to Turso.
type: integration
summary: How to switch the database provider to Turso.
prerequisites:
- /docs/packages/database
---

Turso is multi-tenant database platform built for all types of apps, including AI apps with on-device RAG, local-first vector search, offline writes, and privacy-focused data access with low latency.

Here's how to switch from Neon to Turso for your next-forge project.

1. Sign up to Turso

You can use the Dashboard, or the CLI to manage your account, database, and auth tokens.

_We'll be using the CLI throughout this guide._

2. Create a Database

Create a new database and give it a name using the Turso CLI:

sh title="Terminal"
turso db create <database-name>
text
You can now fetch the URL to the database:
sh title="Terminal"
turso db show <database-name> --url
text
It will look something like this:

libsql://<database-name>-<account-or-org-slug>.turso.io
text

3. Create a Database Auth Token

You will need to create an auth token to connect to your Turso database:

sh title="Terminal"
turso db tokens create <database-name>
text

4. Update your environment variables

Update your environment variables to use the new Turso connection string:

js title="apps/database/.env"
DATABASE_URL="libsql://<database-name>-<account-or-org-slug>.turso.io"
DATABASE_AUTH_TOKEN="..."
text
js title="apps/app/.env.local"
DATABASE_URL="libsql://<database-name>-<account-or-org-slug>.turso.io"
DATABASE_AUTH_TOKEN="..."
text
Etcetera.

Now inside packages/env/index.ts, add DATABASE_AUTH_TOKEN to the server and runtimeEnv objects:

ts title="{3,12}"
const server: Parameters<typeof createEnv>[0]["server"] = {
// ...
DATABASE_AUTH_TOKEN: z.string(),
// ...
};

export const env = createEnv({
client,
server,
runtimeEnv: {
// ...
DATABASE_AUTH_TOKEN: process.env.DATABASE_AUTH_TOKEN,
// ...
},
});

text

5. Install @libsql/client

The @libsql/client is used to connect to the hosted Turso database.

Uninstall the existing dependencies for Neon...

package-install
npm uninstall @neondatabase/serverless @prisma/adapter-neon ws @types/ws --filter @repo/database
text
... and install the new dependencies for Turso & libSQL:
package-install
npm install @libsql/client --filter @repo/database
text

6. Update the database connection code

Open packages/database/index.ts and make the following changes:

ts title="packages/database/index.ts"
import "server-only";

import { createClient } from "@libsql/client";
import { env } from "@repo/env";

const libsql = createClient({
url: env.DATABASE_URL,
authToken: env.DATABASE_AUTH_TOKEN,
});

export const database = libsql;

text

7. Apply schema changes

Now connect to the Turso database using the CLI:

sh title="Terminal"
turso db shell <database-name>
text
And apply the schema to the database:
sql
CREATE TABLE pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
name TEXT
);
text

8. Update application code

Now wherever you would usually call Prisma, use the libsql client instead:

ts title="packages/app/app/(authenticated)/page.tsx"
import { database } from "@repo/database";

type PageType = {
id: number;
email: string;
name?: string;
};

// ...

const { rows } = await database.execute(SELECT * FROM pages);

const pages = rows as unknown as Array<PageType>;

text
---

Content/Docs/Migrations/Cms/Content Collections

---
title: Switch to Content Collections
description: How to switch to Content Collections.
type: integration
summary: How to switch to Content Collections for the CMS.
prerequisites:
- /docs/packages/cms/overview
---

It's possible to switch to Content Collections to generate type-safe data collections from MDX files. This approach provides a structured way to manage blog posts while maintaining full type safety throughout your application.

1. Swap out the required dependencies

Remove the existing dependencies...

package-install
npm uninstall basehub --filter @repo/cms
text
... and install the new dependencies...
package-install
npm install @content-collections/mdx fumadocs-core --filter @repo/cms
npm install -D @content-collections/cli @content-collections/core @content-collections/next --filter @repo/cms
text

2. Update the .gitignore file

Add .content-collections to the root .gitignore file (in the root of your monorepo):

txt title=".gitignore"

content-collections


.content-collections
text

3. Modify the CMS package scripts

Now we need to modify the CMS package scripts to replace the basehub commands with content-collections.

json title="packages/cms/package.json {3-5}"
{
"scripts": {
"dev": "content-collections build",
"build": "content-collections build",
"analyze": "content-collections build"
},
}
text
<Note>
We're using the Content Collections CLI directly to generate the collections prior to Next.js processes. The files are cached and not rebuilt in the Next.js build process. This is a workaround for this issue.
</Note>

4. Modify the relevant CMS package files

<Note>
You may see TypeScript errors during this step. These will be resolved after you create your collections configuration and run the first build in step 6.
</Note>

Next.js Config (CMS Package)

Update the CMS package's Next.js config to export the Content Collections wrapper:

ts title="packages/cms/next-config.ts"
export { withContentCollections as withCMS } from '@content-collections/next';
text
This replaces the previous BaseHub configuration and maintains compatibility with your existing next.config.ts in the web app.

Collections

ts title="packages/cms/index.ts"
import { allPosts, allLegals } from 'content-collections';

export const blog = {
postsQuery: null,
latestPostQuery: null,
postQuery: (slug: string) => null,
getPosts: async () => allPosts,
getLatestPost: async () =>
allPosts.sort((a, b) => a.date.getTime() - b.date.getTime()).at(0),
getPost: async (slug: string) =>
allPosts.find(({ _meta }) => _meta.path === slug),
};

export const legal = {
postsQuery: null,
latestPostQuery: null,
postQuery: (slug: string) => null,
getPosts: async () => allLegals,
getLatestPost: async () =>
allLegals.sort((a, b) => a.date.getTime() - b.date.getTime()).at(0),
getPost: async (slug: string) =>
allLegals.find(({ _meta }) => _meta.path === slug),
};

text

Components

tsx title="packages/cms/components/body.tsx"
import { MDXContent } from '@content-collections/mdx/react';
import type { ComponentProps } from 'react';

type BodyProperties = Omit<ComponentProps<typeof MDXContent>, 'code'> & {
content: ComponentProps<typeof MDXContent>['code'];
};

export const Body = ({ content, ...props }: BodyProperties) => (
<MDXContent {...props} code={content} />
);

text

TypeScript Config

Update your tsconfig.json in the apps/web directory to add the path mapping:

json title="apps/web/tsconfig.json"
{
"compilerOptions": {
"paths": {
"content-collections": ["./.content-collections/generated"]
}
}
}
text
<Note>
Make sure to merge this with your existing
compilerOptions.paths if you have any.
</Note>

Toolbar

tsx title="packages/cms/components/toolbar.tsx"
export const Toolbar = () => null;
text

Table of Contents

tsx title="packages/cms/components/toc.tsx"
import { getTableOfContents } from 'fumadocs-core/content/toc';

type TableOfContentsProperties = {
data: string;
};

export const TableOfContents = async ({
data,
}: TableOfContentsProperties) => {
const toc = await getTableOfContents(data);

return (
<ul className="flex list-none flex-col gap-2 text-sm">
{toc.map((item) => (
<li
key={item.url}
style={{
paddingLeft:
${item.depth - 2}rem,
}}
>
<a
href={item.url}
className="line-clamp-3 flex rounded-sm text-foreground text-sm underline decoration-foreground/0 transition-colors hover:decoration-foreground/50"
>
{item.title}
</a>
</li>
))}
</ul>
);
};

text

5. Update the sitemap.ts file

Update the sitemap.ts file to scan the content directory for MDX files:

tsx title="apps/web/app/sitemap.ts"
// ...

const blogs = fs
.readdirSync('content/blog', { withFileTypes: true })
.filter((file) => !file.isDirectory())
.filter((file) => !file.name.startsWith('_'))
.filter((file) => !file.name.startsWith('('))
.map((file) => file.name.replace('.mdx', ''));

const legals = fs
.readdirSync('content/legal', { withFileTypes: true })
.filter((file) => !file.isDirectory())
.filter((file) => !file.name.startsWith('_'))
.filter((file) => !file.name.startsWith('('))
.map((file) => file.name.replace('.mdx', ''));

// ...

text

6. Create your collections

Create a new content collections configuration file in the cms package, then create a re-export file in the web app.

<Note>We're remapping the title field to _title and the _meta.path field to _slug to match the default next-forge CMS.</Note>

CMS Package

ts title="packages/cms/collections.ts"
import { defineCollection, defineConfig } from '@content-collections/core';
import { compileMDX } from '@content-collections/mdx';

const posts = defineCollection({
name: 'posts',
directory: 'content/blog', // relative to apps/web
include: '/*.mdx',
schema: (z) => ({
title: z.string(),
description: z.string(),
date: z.string(),
image: z.string(),
authors: z.array(z.string()),
tags: z.array(z.string()),
}),
transform: async ({ title, ...page }, context) => {
const body = await context.cache(page.content, async () =>
compileMDX(context, page)
);

return {
...page,
_title: title,
_slug: page._meta.path,
body,
};
},
});

const legals = defineCollection({
name: 'legals',
directory: 'content/legal', // relative to apps/web
include: '/*.mdx',
schema: (z) => ({
title: z.string(),
description: z.string(),
date: z.string(),
}),
transform: async ({ title, ...page }, context) => {
const body = await context.cache(page.content, async () =>
compileMDX(context, page)
);

return {
...page,
_title: title,
_slug: page._meta.path,
body,
};
},
});

export default defineConfig({
collections: [posts, legals],
});

text

Web App

Create a configuration file in the root of your web app:

ts title="apps/web/content-collections.ts"
export { default } from '@repo/cms/collections';
text
<Note>
After creating these files, you'll need to run
bun run build in the packages/cms directory to generate the types. TypeScript errors about missing content-collections module will resolve after the first build.
</Note>

7. Create your content

Create the content directories if they don't exist:
-
apps/web/content/blog for blog posts
-
apps/web/content/legal for legal pages

To create a new blog post, add a new MDX file to the apps/web/content/blog directory. The file name will be used as the slug for the blog post and the frontmatter will be used to generate the blog post page. For example:

mdx title="apps/web/content/blog/my-first-post.mdx"
---
title: 'My First Post'
description: 'This is my first blog post'
date: 2024-10-23
image: /blog/my-first-post.png
---
text
The same concept applies to the legal collection, which is used to generate the legal policy pages. Also, the image field is the path relative to the app's root public directory.

8. Remove the environment variables

Finally, remove all instances of BASEHUB_TOKEN from the @repo/env package.

9. Bonus features

Fumadocs MDX Plugins

You can use the Fumadocs MDX plugins to enhance your MDX content.

ts title="{1-6,8-13,20-23}"
import {
type RehypeCodeOptions,
rehypeCode,
remarkGfm,
remarkHeading,
} from 'fumadocs-core/mdx-plugins';

const rehypeCodeOptions: RehypeCodeOptions = {
themes: {
light: 'catppuccin-mocha',
dark: 'catppuccin-mocha',
},
};

const posts = defineCollection({
// ...
transform: async (page, context) => {
// ...
const body = await context.cache(page.content, async () =>
compileMDX(context, page, {
remarkPlugins: [remarkGfm, remarkHeading],
rehypePlugins: [[rehypeCode, rehypeCodeOptions]],
})
);

// ...
},
});

text

Reading Time

You can calculate reading time for your collection by adding a transform function.

ts title="{1,10}"
import readingTime from 'reading-time';

const posts = defineCollection({
// ...
transform: async (page, context) => {
// ...

return {
// ...
readingTime: readingTime(page.content).text,
};
},
});

text

Low-Quality Image Placeholder (LQIP)

You can generate a low-quality image placeholder for your collection by adding a transform function.

ts title="{1,8-19,23,24}"
import { sqip } from 'sqip';

const posts = defineCollection({
// ...
transform: async (page, context) => {
// ...

const blur = await context.cache(page._meta.path, async () =>
sqip({
input:
./public/${page.image},
plugins: [
'sqip-plugin-primitive',
'sqip-plugin-svgo',
'sqip-plugin-data-uri',
],
})
);

const result = Array.isArray(blur) ? blur[0] : blur;

return {
// ...
image: page.image,
imageBlur: result.metadata.dataURIBase64 as string,
};
},
});

text
---

Content/Docs/Migrations/Authentication/Appwrite

---
title: Switch to Appwrite Auth
description: How to change the authentication provider to Appwrite Auth.
type: integration
summary: How to switch the authentication provider to Appwrite Auth.
prerequisites:
- /docs/packages/authentication
related:
- /docs/migrations/authentication/authjs
- /docs/migrations/authentication/better-auth
- /docs/migrations/authentication/supabase
- /docs/migrations/database/appwrite
- /docs/migrations/storage/appwrite
---

Appwrite is an open-source backend-as-a-service platform that provides authentication, databases, storage, and more. Appwrite Auth supports email/password, OAuth, magic URL, phone, and anonymous authentication methods.

next-forge uses Clerk as the authentication provider. This guide will help you switch from Clerk to Appwrite Auth. Since Appwrite doesn't provide built-in organization management like Clerk, this guide includes a pattern to implement team/organization features using Appwrite's built-in Teams API.

1. Replace the auth package dependencies

Uninstall the existing Clerk dependencies from the auth package...

package-install
npm uninstall @clerk/nextjs @clerk/themes @clerk/types --filter @repo/auth
text
...and install the Appwrite dependencies:
package-install
npm install appwrite node-appwrite --filter @repo/auth
text

2. Update environment variables

Add the following environment variables to your .env.local file in each Next.js application (app, web, and api). You can find these values in your Appwrite project's Settings page:

bash title=".env.local"
NEXT_PUBLIC_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
NEXT_PUBLIC_APPWRITE_PROJECT_ID=your-project-id
APPWRITE_API_KEY=your-api-key
text
<Note>
The endpoint and project ID are safe to use in client-side code. The API key should only be used server-side.
</Note>

3. Update the environment keys

Update the keys.ts file to validate the new Appwrite environment variables:

ts title="packages/auth/keys.ts"
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';

export const keys = () =>
createEnv({
server: {
APPWRITE_API_KEY: z.string().min(1),
},
client: {
NEXT_PUBLIC_APPWRITE_ENDPOINT: z.string().url(),
NEXT_PUBLIC_APPWRITE_PROJECT_ID: z.string().min(1),
},
runtimeEnv: {
APPWRITE_API_KEY: process.env.APPWRITE_API_KEY,
NEXT_PUBLIC_APPWRITE_ENDPOINT:
process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT,
NEXT_PUBLIC_APPWRITE_PROJECT_ID:
process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID,
},
});

text

4. Create the server client

Create a server-side Appwrite client using node-appwrite with cookie-based session management:


/ Detailed source-code truncated for AI context efficiency. /
text

5. Create the browser client

Create a client-side Appwrite client:

ts title="packages/auth/client.ts"
'use client';

import { Client, Account, Teams } from 'appwrite';

const client = new Client()
.setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT!)
.setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT_ID!);

export const account = new Account(client);
export const teams = new Teams(client);
export { client };

text

6. Update the middleware

Replace proxy.ts with middleware.ts to handle session validation:

ts title="packages/auth/middleware.ts"
import 'server-only';
import { NextResponse, type NextRequest } from 'next/server';

export const authMiddleware = async (request: NextRequest) => {
const session = request.cookies.get('appwrite-session');

if (!session && request.nextUrl.pathname.startsWith('/dashboard')) {
const url = request.nextUrl.clone();
url.pathname = '/sign-in';
return NextResponse.redirect(url);
}

return NextResponse.next();
};

text
<Note>
Delete the old
proxy.ts file if it exists, as it was specific to Clerk's proxy functionality.
</Note>

7. Update the Provider file

Appwrite Auth doesn't require a Provider component, so replace it with a stub:

tsx title="packages/auth/provider.tsx"
import type { ReactNode } from 'react';

type AuthProviderProps = {
children: ReactNode;
};

export const AuthProvider = ({ children }: AuthProviderProps) => children;

text

8. Update the auth components

Update both the sign-in.tsx and sign-up.tsx components to use Appwrite Auth:

Sign In


/ Detailed source-code truncated for AI context efficiency. /
text

Sign Up


/ Detailed source-code truncated for AI context efficiency. /
text

9. Set up auth callback route for OAuth

Create a callback route to handle OAuth redirects:

ts title="apps/app/app/api/auth/callback/route.ts"
import { createAdminClient } from '@repo/auth/server';
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';

export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const userId = searchParams.get('userId');
const secret = searchParams.get('secret');
const next = searchParams.get('next') ?? '/dashboard';

if (userId && secret) {
const { account } = createAdminClient();
const session = await account.createSession(userId, secret);

const cookieStore = await cookies();
cookieStore.set('appwrite-session', session.$id, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: true,
maxAge: 86400,
});

return NextResponse.redirect(${origin}${next});
}

return NextResponse.redirect(${origin}/sign-in);
}

text

10. Implement organization management

Appwrite provides a built-in Teams API for managing groups of users. Create helper functions to manage organizations:

ts title="packages/auth/organizations.ts"
import 'server-only';
import { createSessionClient, createAdminClient } from './server';

export const createOrganization = async (name: string) => {
const { teams, account } = await createSessionClient();

const team = await teams.create('unique()', name);

// Set as active organization
await account.updatePrefs({
activeOrganizationId: team.$id,
});

return team;
};

export const getOrganizations = async () => {
const { teams } = await createSessionClient();
const result = await teams.list();
return result.teams;
};

export const switchOrganization = async (organizationId: string) => {
const { account } = await createSessionClient();
await account.updatePrefs({
activeOrganizationId: organizationId,
});
};

export const inviteToOrganization = async (
organizationId: string,
email: string,
roles: string[] = ['member']
) => {
const { teams } = await createSessionClient();
await teams.createMembership(
organizationId,
roles,
email
);
};

text

11. Update your apps

Replace any remaining Clerk implementations in your apps with Appwrite equivalents:

Server Components

tsx
// Before (Clerk)
const { userId, orgId } = await auth();
const user = await currentUser();

// After (Appwrite)
import { auth, currentUser } from '@repo/auth/server';

const { userId, orgId } = await auth();
const user = await currentUser();

text

Client Components

tsx
// Before (Clerk)
import { useUser } from '@clerk/nextjs';
const { user } = useUser();

// After (Appwrite)
'use client';
import { account } from '@repo/auth/client';
import { useEffect, useState } from 'react';
import type { Models } from 'appwrite';

const [user, setUser] = useState<Models.User<Models.Preferences> | null>(null);

useEffect(() => {
account.get().then(setUser).catch(() => setUser(null));
}, []);

text

Sign Out

tsx
// Before (Clerk)
import { SignOutButton } from '@clerk/nextjs';
<SignOutButton />

// After (Appwrite)
'use client';
import { account } from '@repo/auth/client';

const handleSignOut = async () => {
await account.deleteSession('current');
document.cookie = 'appwrite-session=; path=/; max-age=0';
router.push('/');
router.refresh();
};

<button onClick={handleSignOut}>Sign out</button>

text

Additional features

OAuth Authentication

To add OAuth providers, configure them in your Appwrite Console under Auth → Settings, then use:

ts
import { account } from '@repo/auth/client';
import { OAuthProvider } from 'appwrite';

account.createOAuth2Session(
OAuthProvider.Github, // or Google, Apple, etc.
${window.location.origin}/api/auth/callback,
${window.location.origin}/sign-in
);

text

Magic URL Authentication

ts
import { account } from '@repo/auth/client';
import { ID } from 'appwrite';

await account.createMagicURLToken(
ID.unique(),
email,
${window.location.origin}/api/auth/callback
);

text
<Note>
Appwrite uses a permissions system instead of Row Level Security. You can set document-level and collection-level permissions directly in the Appwrite Console or via the SDK. See the Appwrite Permissions documentation for details.
</Note>

For more information, see the Appwrite Auth documentation.

---

Content/Docs/Migrations/Authentication/Authjs

---
title: Switch to Auth.js
description: How to change the authentication provider to Auth.js.
type: integration
summary: How to switch the authentication provider to Auth.js.
prerequisites:
- /docs/packages/authentication
related:
- /docs/migrations/authentication/better-auth
- /docs/migrations/authentication/supabase
---

<Warning>
next-forge support for Auth.js is currently blocked by this issue.
</Warning>

Here's how to switch from Clerk to Auth.js.

1. Replace the dependencies

Uninstall the existing Clerk dependencies from the auth package...

package-install
npm uninstall @clerk/nextjs @clerk/themes @clerk/types --filter @repo/auth
text
... and install the Auth.js dependencies.
package-install
npm install next-auth@beta --filter @repo/auth
text

2. Generate an Auth.js secret

Auth.js requires a random value secret, used by the library to encrypt tokens and email verification hashes. In each of the relevant app directories, generate a secret with the following command:

sh title="Terminal"
cd apps/app && npx auth secret && cd -
cd apps/web && npx auth secret && cd -
cd apps/api && npx auth secret && cd -
text
This will automatically add an AUTH_SECRET environment variable to the .env.local file in each directory.

3. Replace the relevant files

Delete the existing client.ts and server.ts files in the auth package. Then, create the following file:

tsx title="packages/auth/index.ts"
import NextAuth from "next-auth";

export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [],
});
text

4. Update the middleware

Update the middleware.ts file in the auth package with the following content:

tsx title="packages/auth/middleware.ts"
import 'server-only';

export { auth as authMiddleware } from './';

text

5. Update the auth components

Auth.js has no concept of "sign up", so we'll use the signIn function to sign up users. Update both the sign-in.tsx and sign-up.tsx components in the auth package with the same content:

Sign In

tsx title="packages/auth/components/sign-in.tsx"
import { signIn } from '../';

export const SignIn = () => (
<form
action={async () => {
"use server";
await signIn();
}}
>
<button type="submit">Sign in</button>
</form>
);

text

Sign Up

tsx title="packages/auth/components/sign-up.tsx"
import { signIn } from '../';

export const SignUp = () => (
<form
action={async () => {
"use server";
await signIn();
}}
>
<button type="submit">Sign up</button>
</form>
);

text

6. Update the Provider file

Auth.js has no concept of a Provider as a higher-order component, so you can either remove it entirely or just replace it with a stub, like so:

tsx title="packages/auth/provider.tsx"
import type { ReactNode } from 'react';

type AuthProviderProps = {
children: ReactNode;
};

export const AuthProvider = ({ children }: AuthProviderProps) => children;

text

7. Create an auth route handler

In your app application, create an auth route handler file with the following content:

tsx title="apps/app/api/auth/[...nextauth]/route.ts"
import { handlers } from "@repo/auth"

export const { GET, POST } = handlers;

text

8. Update your apps

From here, you'll need to replace any remaining Clerk implementations in your apps with Auth.js references. This means swapping out references like:

tsx title="page.tsx"
const { orgId } = await auth();
const { redirectToSignIn } = await auth();
const user = await currentUser();
text
Etcetera. Keep in mind that you'll need to build your own "organization" logic as Auth.js doesn't have a concept of organizations.

---

Content/Docs/Migrations/Authentication/Better Auth

---
title: Switch to Better Auth
description: How to change the authentication provider to Better Auth.
type: integration
summary: How to switch the authentication provider to Better Auth.
prerequisites:
- /docs/packages/authentication
related:
- /docs/migrations/authentication/authjs
- /docs/migrations/authentication/supabase
---

Better Auth is a comprehensive, open-source authentication framework for TypeScript. It is designed to be framework agnostic, but integrates well with Next.js and provides a lot of features out of the box.

1. Swap out the auth package dependencies

Uninstall the existing Clerk dependencies from the auth package...

package-install
npm uninstall @clerk/nextjs @clerk/themes @clerk/types --filter @repo/auth
text
...and install the Better Auth dependencies:
package-install
npm install better-auth next --filter @repo/auth
text
Additionally, add @repo/database to the auth package dependencies.

2. Update your environment variables

Generate a secret with the following command to add it to the .env.local file in each Next.js application (app, web and api):

sh title="Terminal"
npx @better-auth/cli@latest secret
text
This will add a BETTER_AUTH_SECRET environment variable to the .env.local file. You should also add the BETTER_AUTH_URL environment variable, pointing to your app's base URL:
bash title=".env.local"
BETTER_AUTH_URL="http://localhost:3000"
text

3. Setup the server and client auth

Update the auth package files with the following code:

Server

ts title="packages/auth/server.ts"
import { betterAuth } from 'better-auth';
import { nextCookies } from "better-auth/next-js";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { database } from "@repo/database"

export const auth = betterAuth({
database: prismaAdapter(database, {
provider: 'postgresql',
}),
plugins: [
nextCookies()
// organization() // if you want to use organization plugin
],
//...add more options here
});

text

Client

ts title="packages/auth/client.ts"
import { createAuthClient } from 'better-auth/react';

export const { signIn, signOut, signUp, useSession } = createAuthClient();

text
Read more in the Better Auth installation guide.

4. Update the auth components

Update both the sign-in.tsx and sign-up.tsx components in the auth package to use the signIn and signUp functions from the client file.

Sign In

tsx title="packages/auth/components/sign-in.tsx"
"use client";

import { signIn } from '../client';
import { useState } from 'react';

export const SignIn = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");

return (
<form
onSubmit={async (e) => {
e.preventDefault();
await signIn.email({
email,
password,
})
}}
>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit">Sign in</button>
</form>
);
}

text

Sign Up

tsx title="packages/auth/components/sign-up.tsx"
"use client";

import { signUp } from '../client';
import { useState } from 'react';

export const SignUp = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");

return (
<form
onSubmit={async (e) => {
e.preventDefault();
await signUp.email({
email,
password,
name
})
}}
>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<button type="submit">Sign up</button>
</form>
);
}

text
You can use different sign-in methods like social providers, phone, username etc. Read more about Better Auth basic usage.

5. Generate Prisma Models

From the root folder, generate Prisma models for Better Auth by running the following command:

sh title="Terminal"
npx @better-auth/cli@latest generate --output ./packages/database/prisma/schema.prisma --config ./packages/auth/server.ts
text
<Warning>
You may have to comment out the
server-only directive in packages/database/index.ts temporarily. Ensure you have environment variables set.
</Warning>

6. Update the Provider file

Better Auth has no concept of a Provider as a higher-order component, so you can either remove it entirely or just replace it with a stub, like so:

tsx title="packages/auth/provider.tsx"
import type { ReactNode } from 'react';

type AuthProviderProps = {
children: ReactNode;
};

export const AuthProvider = ({ children }: AuthProviderProps) => children;

text

7. Change Middleware

Change the middleware in the auth package to the following. The middleware checks for a session cookie and redirects unauthenticated users to the sign-in page. The optional middlewareFn parameter allows you to add custom logic before the authentication check:

tsx title="packages/auth/middleware.ts"
import { getSessionCookie } from "better-auth/cookies";
import type { NextFetchEvent, NextRequest } from "next/server";
import { NextResponse } from "next/server";

export function authMiddleware(
middlewareFn?: (
_auth: { req: NextRequest; authorized: boolean },
request: NextRequest,
event: NextFetchEvent,
) => Promise<Response> | Response,
) {
return async function middleware(request: NextRequest, event: NextFetchEvent) {
const sessionCookie = getSessionCookie(request);
const authorized = Boolean(sessionCookie);

if (middlewareFn) {
const response = await middlewareFn(
{ req: request, authorized },
request,
event
);
if (response && response.headers.get("Location")) {
return response;
}
}

if (!sessionCookie) {
return NextResponse.redirect(new URL("/sign-in", request.url));
}

return NextResponse.next();
};
}

text

8. Define and add Next.js Handlers to your app

Unlike Clerk, you need to host auth handlers which will retrieve sessions, authenticate requests etc...
tsx title="packages/auth/handlers.ts"
import 'server-only';
import { toNextJsHandler } from 'better-auth/next-js';

import { auth } from './server';

export const { POST, GET } = toNextJsHandler(auth);

text
tsx title="apps/app/app/api/auth/[...all]/route.ts"
export { POST, GET } from '@repo/auth/handlers'
text

9. Update your apps

From here, you'll need to replace any remaining Clerk implementations in your apps with Better Auth.

Here is some inspiration:

tsx
const user = await currentUser();
const { redirectToSignIn } = await auth();

// to

const session = await auth.api.getSession({
headers: await headers(), // from next/headers
});
if (!session?.user) {
return redirect('/sign-in'); // from next/navigation
}
// do something with session.user

text
tsx
const { orgId } = await auth();

// to

const h = await headers(); // from next/headers
const session = await auth.api.getSession({
headers: h,
});
const orgId = session?.session.activeOrganizationId;
const fullOrganization = await auth.api.getFullOrganization({
headers: h,
query: { organizationId: orgId },
});

text
tsx title="webhooks/stripe/route.ts"
import { clerkClient } from '@repo/auth/server';

const clerk = await clerkClient();
const users = await clerk.users.getUserList();
const user = users.data.find(
(user) => user.privateMetadata.stripeCustomerId === customerId
);

// to

import { database } from '@repo/database';

const user = await database.user.findFirst({
where: {
privateMetadata: {
contains: { stripeCustomerId: customerId },
},
},
});

text
For using organization, check organization plugin and more from the Better Auth documentation.

---

Content/Docs/Migrations/Authentication/Supabase

---
title: Switch to Supabase Auth
description: How to change the authentication provider to Supabase Auth.
type: integration
summary: How to switch the authentication provider to Supabase Auth.
prerequisites:
- /docs/packages/authentication
related:
- /docs/migrations/authentication/authjs
- /docs/migrations/authentication/better-auth
---

Supabase Auth is a comprehensive authentication system that integrates seamlessly with Supabase's Postgres database. It provides email/password, magic link, OAuth, and phone authentication, along with user management and session handling.

next-forge uses Clerk as the authentication provider. This guide will help you switch from Clerk to Supabase Auth. Since Supabase doesn't provide built-in organization management like Clerk, this guide includes a multi-tenancy schema pattern to implement team/organization features with role-based access control.

<Note>
This guide assumes you've already migrated to Supabase for your database. If you haven't done so yet, complete that migration first.
</Note>

1. Replace the auth package dependencies

Uninstall the existing Clerk dependencies from the auth package...

package-install
npm uninstall @clerk/nextjs @clerk/themes @clerk/types --filter @repo/auth
text
...and install the Supabase Auth dependencies:
package-install
npm install @supabase/supabase-js @supabase/ssr --filter @repo/auth
text
Additionally, add @repo/database to the auth package dependencies to enable organization/team management.

2. Update environment variables

Add the following environment variables to your .env.local file in each Next.js application (app, web, and api). You can find these values in your Supabase project's Settings → API page:

bash title=".env.local"
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
text
<Note>
The anon key is safe to use in client-side code as it respects your Row Level Security (RLS) policies. Supabase is transitioning
NEXT_PUBLIC_SUPABASE_ANON_KEY to NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY — both work for now, but consider using the new name for new projects.
</Note>

3. Set up the database schema for organizations

Since Supabase doesn't provide built-in organization management, you'll need to create a multi-tenancy schema. Add the following to your Prisma schema:

prisma title="packages/database/prisma/schema.prisma"
model Organization {
id String @id @default(cuid())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

members OrganizationMember[]
@@map("organizations")
}

model OrganizationMember {
id String @id @default(cuid())
userId String
organizationId String
role String @default("member") // owner, admin, member
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)

@@unique([userId, organizationId])
@@index([userId])
@@index([organizationId])
@@map("organization_members")
}

text
Then run the migration:
sh title="Terminal"
bun run migrate
text

4. Create Supabase client utilities

Create utility functions to initialize Supabase clients for different contexts:

Server Client

ts title="packages/auth/server.ts"
import 'server-only';
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export const createClient = async () => {
const cookieStore = await cookies();

return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// The
setAll method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
},
}
);
};

// Helper function to get the current user
export const currentUser = async () => {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
return user;
};

// Helper function to get the current user's active organization
export const auth = async () => {
const user = await currentUser();

if (!user) {
return { userId: null, orgId: null };
}

// Get active organization from user metadata
const orgId = user.user_metadata?.activeOrganizationId as string | null;

return {
userId: user.id,
orgId,
};
};

text

Client Component Client

ts title="packages/auth/client.ts"
'use client';
import { createBrowserClient } from '@supabase/ssr';

export const createClient = () => {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
};

text

5. Update the middleware

Update the middleware.ts file to handle Supabase session refresh:

ts title="packages/auth/middleware.ts"
import 'server-only';
import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';

export const authMiddleware = async (request: NextRequest) => {
let supabaseResponse = NextResponse.next({
request,
});

const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
request.cookies.set(name, value)
);
supabaseResponse = NextResponse.next({
request,
});
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
);
},
},
}
);

// Refreshing the auth token
const { data: { user } } = await supabase.auth.getUser();

// Redirect to sign-in if accessing protected route without authentication
if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
const url = request.nextUrl.clone();
url.pathname = '/sign-in';
return NextResponse.redirect(url);
}

return supabaseResponse;
};

text

6. Update the auth components

Update both the sign-in.tsx and sign-up.tsx components to use Supabase Auth:

Sign In

tsx title="packages/auth/components/sign-in.tsx"
'use client';

import { createClient } from '../client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';

export const SignIn = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const supabase = createClient();

const handleSignIn = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);

const { error } = await supabase.auth.signInWithPassword({
email,
password,
});

if (error) {
setError(error.message);
setLoading(false);
} else {
router.push('/dashboard');
router.refresh();
}
};

return (
<form onSubmit={handleSignIn}>
{error && <div className="text-red-500">{error}</div>}
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
required
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
required
/>
<button type="submit" disabled={loading}>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
);
};

text

Sign Up


/ Detailed source-code truncated for AI context efficiency. /
text

7. Update the Provider file

Supabase Auth doesn't require a Provider component for basic functionality, so replace it with a stub:

tsx title="packages/auth/provider.tsx"
import type { ReactNode } from 'react';

type AuthProviderProps = {
children: ReactNode;
};

export const AuthProvider = ({ children }: AuthProviderProps) => children;

text

8. Implement organization management

Create helper functions to manage organizations in your application. Add these to a new file:

ts title="packages/auth/organizations.ts"
import 'server-only';
import { database } from '@repo/database';
import { createClient } from './server';

export const createOrganization = async (name: string, userId: string) => {
const organization = await database.organization.create({
data: {
name,
members: {
create: {
userId,
role: 'owner',
},
},
},
});

// Set as active organization
const supabase = await createClient();
await supabase.auth.updateUser({
data: { activeOrganizationId: organization.id },
});

return organization;
};

export const getOrganizations = async (userId: string) => {
return await database.organization.findMany({
where: {
members: {
some: {
userId,
},
},
},
include: {
members: true,
},
});
};

export const switchOrganization = async (organizationId: string) => {
const supabase = await createClient();
await supabase.auth.updateUser({
data: { activeOrganizationId: organizationId },
});
};

export const inviteToOrganization = async (
organizationId: string,
email: string,
role: string = 'member'
) => {
// Implement your invitation logic here
// This could involve creating an invitation record and sending an email
};

text

9. Set up auth callback route

Create a callback route to handle authentication redirects:

ts title="apps/app/app/api/auth/callback/route.ts"
import { createClient } from '@repo/auth/server';
import { NextResponse } from 'next/server';

export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get('code');
const next = searchParams.get('next') ?? '/dashboard';

if (code) {
const supabase = await createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);

if (!error) {
return NextResponse.redirect(
${origin}${next});
}
}

// Return the user to an error page with instructions
return NextResponse.redirect(
${origin}/auth/auth-code-error);
}

text

10. Update your apps

Replace any remaining Clerk implementations in your apps with Supabase Auth equivalents:

Server Components

tsx
// Before (Clerk)
const { userId, orgId } = await auth();
const user = await currentUser();

// After (Supabase)
import { auth, currentUser } from '@repo/auth/server';

const { userId, orgId } = await auth();
const user = await currentUser();

text

Client Components

tsx
// Before (Clerk)
import { useUser } from '@clerk/nextjs';
const { user } = useUser();

// After (Supabase)
'use client';
import { createClient } from '@repo/auth/client';
import { useEffect, useState } from 'react';

const supabase = createClient();
const [user, setUser] = useState(null);

useEffect(() => {
supabase.auth.getUser().then(({ data: { user } }) => setUser(user));

const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => setUser(session?.user ?? null)
);

return () => subscription.unsubscribe();
}, []);

text

Sign Out

tsx
// Before (Clerk)
import { SignOutButton } from '@clerk/nextjs';
<SignOutButton />

// After (Supabase)
'use client';
import { createClient } from '@repo/auth/client';

const handleSignOut = async () => {
const supabase = createClient();
await supabase.auth.signOut();
router.push('/');
router.refresh();
};

<button onClick={handleSignOut}>Sign out</button>

text

Additional features

Social Authentication

To add OAuth providers, configure them in your Supabase project settings, then use:

ts
const { error } = await supabase.auth.signInWithOAuth({
provider: 'github', // or 'google', 'apple', etc.
options: {
redirectTo:
${window.location.origin}/api/auth/callback,
},
});
text
ts
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo:
${window.location.origin}/api/auth/callback,
},
});
text
<Note>
To secure your database tables with Row Level Security (RLS) policies, see the Supabase database migration guide for detailed setup instructions.
</Note>

For more information, see the Supabase Auth documentation.

---

Content/Docs/Packages/Analytics/Product

---
title: Product Analytics
description: Captures product events and metrics.
type: reference
product: Analytics
summary: How product analytics captures events and metrics.
related:
- /docs/packages/analytics/web
---

next-forge has support for product analytics via PostHog — a single platform to analyze, test, observe, and deploy new features.

PostHog is an optional integration. If NEXT_PUBLIC_POSTHOG_KEY and NEXT_PUBLIC_POSTHOG_HOST are not set, the analytics export will be undefined and client-side initialization will be skipped.

Usage

To capture product events, you can use the analytics object exported from the @repo/analytics package. Since analytics is optional, use optional chaining when calling methods.

Start by importing the analytics object for the relevant environment:

tsx
// For server-side code
import { analytics } from '@repo/analytics/server';

// For client-side code
import { analytics } from '@repo/analytics';

text
Then, you can use the capture method to send events:
tsx
analytics?.capture({
event: 'Product Purchased',
distinctId: 'user_123',
});
text

Webhooks

To automatically capture authentication and payment events, we've combined PostHog's Node.js server-side library with Clerk and Stripe webhooks to wire it up as follows:

<Mermaid chart={
graph TD
A[User Action in App] -->|Triggers| B[Auth Webhook]
A -->|Triggers| E[Payments Webhook]
A -->|Client-Side Call| PostHog
B -->|Sends Data| C1[webhooks/auth]
E -->|Sends Data| C2[webhooks/payments]

subgraph API
C1
C2
end

subgraph PostHog
end

C1 -->|Auth Events| PostHog
C2 -->|Payments Events| PostHog
} />

Reverse Proxy

We've also setup Next.js rewrites to reverse proxy PostHog requests, meaning your client-side analytics events won't be blocked by ad blockers.

---

Content/Docs/Packages/Analytics/Web

---
title: Web Analytics
description: Captures pageviews, pageleave and custom events.
type: reference
product: Analytics
summary: How web analytics captures pageviews and custom events.
related:
- /docs/packages/analytics/product
---

next-forge comes with three web analytics libraries.

Vercel Web Analytics

Vercel's built-in analytics tool offers detailed insights into your website's visitors with new metrics like top pages, top referrers, and demographics. All you have to do to enable it is visit the Analytics tab in your Vercel project and click Enable from the dialog.

Read more about it here.

Google Analytics

Google Analytics tracks user behavior, page views, session duration, and other engagement metrics to provide insights into user activity and marketing effectiveness. GA tracking code is injected using @next/third-parties for performance reasons.

To enable it, simply add a NEXT_PUBLIC_GA_MEASUREMENT_ID environment variable to your project.

PostHog

PostHog is a single platform to analyze, test, observe, and deploy new features. It comes with lots of products, including a web analytics tool, event analytics, feature flagging, and more.

PostHog's web analytics tool is enabled by default and captures pageviews, pageleave and custom events.

Session Replay

PostHog's session replays let you see exactly what users do on your site. It records console logs and network errors, and captures performance data like resource timings and blocked requests. This is disabled by default, so make sure you enable it in your project settings.

---

Content/Docs/Packages/Cms/Components

---
title: Components
description: Components that come with the CMS package.
type: reference
product: CMS
summary: Components included with the CMS package.
related:
- /docs/packages/cms/overview
- /docs/packages/cms/metadata
---

The CMS package comes with a set of components that are designed to work with the CMS. At any point in time, you can extend these components to add your own custom functionality.

The Feed component

The Feed component is a wrapper around BaseHub's Pump component — a React Server Component that gets generated with the basehub SDK. It leverages RSC, Server Actions, and the existing BaseHub client to subscribe to changes in real time with minimal development effort.

It's also setup by default to use Next.js Draft Mode, allowing you to preview draft content in your app.

The Body component

The Body component is a wrapper around BaseHub's RichText component — BaseHub's rich text renderer which supports passing custom handlers for native html elements and BaseHub components.

The TableOfContents component

The TableOfContents component leverages the Body component to render the table of contents for the current page.

The Image component

The Image component is a wrapper around BaseHub's BaseHubImage component, which comes with built-in image resizing and optimization. BaseHub recommendeds using the BaseHubImage component instead of the standard Next.js Image component as it uses Image under the hood, but adds a custom loader to leverage BaseHub's image pipeline.

The Toolbar component

The Toolbar component is a wrapper around BaseHub's Toolbar component, which helps manage draft mode and switch branches in your site previews. It's automatically mounted on CMS pages.

---

Content/Docs/Packages/Cms/Metadata

---
title: Metadata
description: How the title, description, and Open Graph images are configured in the CMS.
type: reference
product: CMS
summary: How title, description, and Open Graph images are configured.
related:
- /docs/packages/cms/overview
- /docs/packages/seo/metadata
---

To generate metadata for a particular page or collection item, we can use the BaseHub SDK to query the metadata, then use Next.js' generateMetadata function to generate the metadata.

For example, here's how we've wired up the metadata for the blog post page, using the createMetadata function from the SEO package:

tsx apps/web/app/[locale]/blog/[slug]/page.tsx
import { blog } from '@repo/cms';

type BlogPostProperties = {
readonly params: Promise<{
slug: string;
}>;
};

export const generateMetadata = async ({
params,
}: BlogPostProperties): Promise<Metadata> => {
const { slug } = await params;
const post = await blog.getPost(slug);

if (!post) {
return {};
}

return createMetadata({
title: post._title,
description: post.description,
image: post.image.url,
});
};

text
blog.getPost is a function that abstracts the logic of fetching the blog post from the CMS. Under the hood, it uses the BaseHub SDK to fetch the blog post from the CMS:
tsx packages/cms/index.ts
import { basehub, fragmentOn } from 'basehub';

const postFragment = fragmentOn('PostsItem', {
_slug: true,
_title: true,
authors: {
_title: true,
avatar: imageFragment,
xUrl: true,
},
body: {
plainText: true,
json: {
content: true,
toc: true,
},
readingTime: true,
},
categories: {
_title: true,
},
date: true,
description: true,
image: imageFragment,
});

export const blog = {
// ...

postQuery: (slug: string) => ({
blog: {
posts: {
__args: {
filter: {
_sys_slug: { eq: slug },
},
},
item: postFragment,
},
},
}),

getPost: async (slug: string) => {
const query = blog.postQuery(slug);
const data = await basehub().query(query);

return data.blog.posts.item;
},
};

text
---

Content/Docs/Packages/Cms/Overview

---
title: Overview
description: How the CMS is configured in next-forge.
type: reference
product: CMS
summary: How the CMS is configured in next-forge.
related:
- /docs/packages/cms/components
- /docs/packages/cms/metadata
---

next-forge has a dedicated CMS package that can be used to generate type-safe data collections from your content. This approach provides a structured way to manage your content while maintaining full type safety throughout your application. By default, next-forge uses BaseHub as the CMS.

BaseHub is an optional integration. If BASEHUB_TOKEN is not set, CMS queries will return empty arrays or null instead of throwing errors.

Setup

Here's how to quickly get started with your new CMS.

1. Fork the basehub/next-forge template

You'll be forking a BaseHub repository which contains the next-forge compatible content schema.

Once you fork the repository, you'll need to get your Read Token from the "Connect to your App" page:


https://basehub.com/<team-slug>/<repo-slug>/dev/main/dev:connect
text
The token will look something like this:

bshb_pk_<password>
text
Keep this connection string handy, you will need it in the next step.

2. Update your environment variables

Update your environment variables to use the new BaseHub token. For example:

ts apps/web/.env
BASEHUB_TOKEN="<token>"
text

3. Start the dev server

When you run bun dev, the CMS package will generate the type-safe BaseHub SDK, and watch changes to your CMS's schema.

<Note>You might need to run Restart TS Server in your IDE for TypeScript to pick up the new types.</Note>

Querying Basics

The structure of the CMS should look something like this:

txt
- Blog
- Posts
- Authors
- Categories
- Legal Pages
text
So in order to get all posts, you'd write a query like this:
ts
{
blog: {
posts: {
items: {
_title: true,
_slug: true,
authors: { _title: true }, // references the authors collection
// ...
},
},
},
}
text
Starter queries are provided for you in the cms package, within the blog and legal objects. You can read more about the BaseHub SDK in their docs.

Revalidation

A key part of any good CMS integration is the ability to revalidate content when it changes. To do that, BaseHub comes with automatic on-demand revalidation.

---

Content/Docs/Packages/Design System/Colors

---
title: Colors
description: CSS variables and how they work
type: reference
product: Design System
summary: How CSS variables and colors work in the design system.
related:
- /docs/packages/design-system/dark-mode
- /docs/packages/design-system/typography
---

next-forge makes use of the CSS variables offered by shadcn/ui. They're a brilliant way of abstracting the scaling and maintenance difficulties associated with Dark Mode and whitelabelling.

These colors have also been applied to other tools, such as the AuthProvider, to ensure that third-party components align with the application design as closely as possible.

Usage

All default pages and components use these colors. You can also use them in your own components, like so:

tsx title="component.tsx"
export const MyComponent = () => (
<div className="bg-background text-foreground border rounded-4xl shadow">
<p>I'm using CSS Variables!</p>
</div>
);
text
You can also access colors in JavaScript through the tailwind utility exported from @repo/tailwind-config, like so:
tsx title="component.tsx"
import { tailwind } from '@repo/tailwind-config';

export const MyComponent = () => (
<div style={{
background: tailwind.theme.colors.background,
color: tailwind.theme.colors.muted.foreground,
}}>
<p>I'm using styles directly from the Tailwind config!</p>
</div>
);

text

Caveats

Currently, it's not possible to change the Clerk theme to match the exact theme of the app. This is because Clerk's Theme doesn't accept custom CSS variables. We'd like to be able to add the following in the future:

jsx title="packages/design-system/providers/clerk.tsx {4-15}"
const variables: Theme['variables'] = {
// ...

colorBackground: 'hsl(var(--background))',
colorPrimary: 'hsl(var(--primary))',
colorDanger: 'hsl(var(--destructive))',
colorInputBackground: 'hsl(var(--transparent))',
colorInputText: 'hsl(var(--text-foreground))',
colorNeutral: 'hsl(var(--neutral))',
colorShimmer: 'hsl(var(--primary) / 10%)',
colorSuccess: 'hsl(var(--success))',
colorText: 'hsl(var(--text-foreground))',
colorTextOnPrimaryBackground: 'hsl(var(--text-foreground))',
colorTextSecondary: 'hsl(var(--text-muted-foreground))',
colorWarning: 'hsl(var(--warning))',
};

text
---

Content/Docs/Packages/Design System/Components

---
title: Components
description: next-forge offers a default component library by shadcn/ui
type: reference
product: Design System
summary: How the shadcn/ui component library is configured.
related:
- /docs/packages/design-system/provider
- /docs/apps/storybook
---

next-forge contains a design system out of the box powered by shadcn/ui.

Default configuration

shadcn/ui has been configured by default to use the "New York" style, Tailwind's neutral color palette and CSS variables. You can customize the component configuration in @repo/design-system, specifically the components.json file. All components have been installed and are regularly updated.

Installing components

To install a new component, use the shadcn CLI from the root:

sh title="Terminal"
npx shadcn@latest add select -c packages/design-system
text
This will install the component into the Design System package.

Updating components

To update shadcn/ui, you can run the following command from the root:

sh title="Terminal"
npx shadcn@latest add --all --overwrite -c packages/design-system
text
We also have a dedicated command for this. Read more about updates.

Changing libraries

If you prefer a different component library, you can replace it at any time with something similar, such as Tailwind's Catalyst.

---

Content/Docs/Packages/Design System/Dark Mode

---
title: Dark Mode
description: How to use dark mode in the design system.
type: guide
product: Design System
summary: How to use dark mode in the design system.
related:
- /docs/packages/design-system/colors
- /docs/packages/design-system/provider
---

next-forge comes with built-in dark mode support through the combination of Tailwind CSS and next-themes.

Implementation

The dark mode implementation uses Tailwind's darkMode: 'class' strategy, which toggles dark mode by adding a dark class to the html element. This approach provides better control over dark mode and prevents flash of incorrect theme.

The next-themes provider is already configured in the application, handling theme persistence and system preference detection automatically. Third-party components like Clerk's Provider and Sonner have also been preconfigured to respect this setup.

Usage

By default, each application theme will default to the user's operating system preference.

To allow the user to change theme manually, you can use the ModeToggle component which is located in the Design System package. We've already added it to the app sidebar and web navbar, but you can import it anywhere:

tsx title="page.tsx"
import { ModeToggle } from '@repo/design-system/components/mode-toggle';

const MyPage = () => (
<ModeToggle />
);

text
You can check the theme by using the useTheme hook directly from next-themes. For example:
tsx title="page.tsx"
import { useTheme } from 'next-themes';

const MyPage = () => {
const { resolvedTheme } = useTheme();

return resolvedTheme === 'dark' ? 'Dark mode baby' : 'Light mode ftw';
}

text
---

Content/Docs/Packages/Design System/Provider

---
title: Provider
description: A single global provider to wrap your application
type: reference
product: Design System
summary: The global provider that wraps your application.
related:
- /docs/packages/design-system/components
---

The design system package also exports a DesignSystemProvider component which implements a number of contextual, functional and higher order components, including those for Tooltips, Toasts, Analytics, Dark Mode and more.

This provider is already added to the default apps. If you want to add a new app, make sure you add it to your root layout along with fonts and global CSS, like so:

tsx title="layout.tsx"
import '@repo/design-system/styles/globals.css';
import { fonts } from '@repo/design-system/lib/fonts';
import { DesignSystemProvider } from '@repo/design-system';
import type { ReactNode } from 'react';

type RootLayoutProperties = {
readonly children: ReactNode;
};

const RootLayout = ({ children }: RootLayoutProperties) => (
<html lang="en" className={fonts} suppressHydrationWarning>
<body>
<DesignSystemProvider>{children}</DesignSystemProvider>
</body>
</html>
);

export default RootLayout;
``

---