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
`` Follow the guide on Environment Variables to fill in your environment variables. Specifically, make sure you set an <Tip>Make sure you have some credits in your OpenAI account.</Tip> 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 - bash title="Terminal"
npx next-forge@latest init ai-chatbotThis will create a new project with the name ai-chatbot and install the necessary dependencies.OPENAI_API_KEY2. Configure your environment variables
environment variable to your apps/app/.env.local file.Chatbot3. Create the chatbot UI
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.
'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>
);
};
chat4. Create the chatbot API route
Create a new file called
in theapp/apidirectory. This Next.js route handler will handle the chatbot's responses.streamTextWe're going to use the
function from our AI package to stream the chatbot's responses to the client. We'll also use theproviderfunction from our AI package to get the OpenAI provider, and thelogfunction from our Observability package to log the chatbot's responses.
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();
};
app/page.tsx5. Update the app
Finally, we'll update the
file to be a simple entry point that renders the chatbot UI.
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;
6. Run the app
Run the app development server and you should be able to see the chatbot UI at http://localhost:3000.
bun dev --filter app
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.packages/next-config/index.ts---
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
, add theoutputproperty:
export const config: NextConfig = {
output: "standalone",
// ... rest of your config
};
DockerfileCreate a Dockerfile
Create a
in the root of your repository. This uses a multi-stage build to keep the final image small:
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"]
<Callout type="info">CMD
Theabove 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..dockerignore
</Callout>Create a .dockerignore
Add a
to speed up builds and keep secrets out of the image:
node_modules
.next
.git
.env
.env.*
APP_NAMEBuild and run
Build and run the image for a specific app by passing the
build argument:
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
Repeat forwebandapiif you want to deploy all three.docker-compose.ymlUsing Docker Compose
If you'd prefer to run all apps together, create a
:
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
Then run everything with:docker compose up --build
--env-fileEnvironment variables
When deploying with Docker, pass your environment variables at runtime using
or-eflags. Do not bake secrets into the image. Learn more about how environment variables work in next-forge.DATABASE_URLIf certain variables are needed at build time (e.g.
for Prisma), uncomment the relevantARGandENVlines in the builder stage.app---
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
,apiandwebapps. 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.appThen, 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
,apiandwebapps. 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.appThen, 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
:api<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
application runs on port 3002. We recommend deploying it toapi.{yourdomain}.com.</Tip>apps/apinext-forge exports the API from the
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.apps/api/app/cronOverview
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
directory. Each cron job is a.tsfile that exports a route handler.apps/api/app/webhooks
- Webhooks: The API is used to run inbound webhooks. These are defined in thedirectory. Each webhook is a.tsfile that exports a route handler.appConnecting 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
,web, or external clients like mobile apps.appWhen you need the API
In many cases, you don't need to call the API app at all. Since
andwebare 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.apps/api/appThe 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 appsAdding an endpoint
Create a new route handler in
. For example, to create a/usersendpoint:
import { database } from '@repo/database';
export const GET = async () => {
const users = await database.user.findMany();
return Response.json(users);
};
NEXT_PUBLIC_API_URLCalling the API from another app
Each app has a
environment variable pre-configured in its.env.examplefile, pointing tohttp://localhost:3002for local development. Use this variable when making requests to the API.From a Server Component or Server Action:
'use server';
import { env } from '@/env';
export const getUsers = async () => {
const response = await fetch(${env.NEXT_PUBLIC_API_URL}/users);
return response.json();
};
From a client component:'use client';
const Users = () => {
const fetchUsers = async () => {
const response = await fetch(
${process.env.NEXT_PUBLIC_API_URL}/users
);
return response.json();
};
// ...
};
http://localhost:3002Preview deployments
In local development, the API URL defaults to
. In production, you setNEXT_PUBLIC_API_URLto your API's production URL (e.g.https://api.yourdomain.com).appFor preview deployments on Vercel, each project gets a unique URL. Since the
orwebpreview can't automatically discover theapipreview URL, you have a few options:NEXT_PUBLIC_API_URL1. Point previews at the production API. Set
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.api-git-my-branch-yourteam.vercel.app
2. Use Vercel's branch-based URLs. Vercel generates deterministic URLs based on the branch name (e.g.). You can construct the API URL from theVERCEL_GIT_COMMIT_REFenvironment variable if all apps share the same repository and branch.NEXT_PUBLIC_API_URL
3. Set the URL manually per preview. For full isolation, overridein the Vercel deployment settings for each preview deployment.app---
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
application runs on port 3000. We recommend deploying it toapp.{yourdomain}.com.</Tip>apps/appnext-forge exports the main app from the
directory. It is designed to be run on a subdomain of your choice, and is used to run the main user-facing application.appOverview
The
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.docsFeatures
- 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
application runs on port 3004. We recommend deploying it todocs.{yourdomain}.com.</Tip>.mdxnext-forge uses Mintlify to generate beautiful docs. Each page is a
file, written in Markdown, with built-in UI components and API playground.apps/docsCreating a new page
To create a new documentation page, add a new MDX file to the
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:
---
title: 'Quickstart'
description: 'Start building modern documentation in under five minutes.'
---
Learn more supported meta tags.mint.jsonAdding a page to the navigation
To add a page to the sidebar, you'll need to define it in the
file in theapps/docsdirectory. From the previous example, here's how you can add it to the sidebar:
"navigation": [
{
"group": "Getting Started",
"pages": ["hello-world"]
},
{
// ...
}
]
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
application runs on port 3003.</Tip>react.emailbuilt in, allowing you to create and send beautiful emails using React and TypeScript.react.emailhas a preview server, so you can preview the emails templates in the browser.emailTo preview the emails templates, simply run the
app:
bun dev --filter email
---storybookContent/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
application runs on port 6006.</Tip>bun devnext-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
. You can also start it independently withbun dev --filter storybook. The preview will be available at localhost:6006.apps/storybook/storiesAdding stories
You can add your own components to the workshop by adding them to the
directory. Each component should have its own.stories.tsxfile.studio---
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
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:
bun dev --filter studio
---webContent/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
application runs on port 3001. We recommend deploying it towww.{yourdomain}.com.</Tip>apps/webnext-forge comes with a default website application, which is located in the
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
Installation
Install the c15t Next.js package in the app(s) that need consent management:
npm install @c15t/nextjs
Setup
1. Create the provider
'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>
);
}
<Tip>mode: 'offline'
For local development or prototyping, you can useinstead ofmode: 'c15t'to store consent in cookies without a backend.ConsentManager
</Tip>2. Add to your root layout
Wrap your app with the
in your root layout:
import { ConsentManager } from '@/components/consent-manager';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<ConsentManager>
{children}
</ConsentManager>
</body>
</html>
);
}
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.
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;
scriptsManaging scripts
c15t can conditionally load third-party scripts based on user consent. Pass a
array to the provider options:
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.
Creating a link
Once you've signed up, you can create a link by clicking the "Create Link" button in the top right corner.
Adding link tracking to your app
From here, simply replace all href values with the Dub link!
<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:
npm install dubFor 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:
npm install fuse.jsUsage
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);
fuse.jsBenefits
-
is easy to use and has a simple API.fuse.js
- Performant:is performant and has zero dependencies.fuse.jsGitHub repo.joyful---
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
, simply run the following command:
npm install joyful
joyfulUsage
Here is an example of how to use
for generating friendly words:
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"
joyfulBenefits
- Easy to Use:
is easy to use and generates friendly words with a simple API.joyful
- Customizable: You can customize the number of segments and the separator.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:
docker run -d --name metabase -p 3000:3000 metabase/metabase
For full installation instructions:hostname
- 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
of the server where your database livesport
- Thethe database server usesdatabase name
- Theusername
- Theyou use for the databasepassword
- Theyou use for the databaseDATABASE_URLYou can find these details in your
:
DATABASE_URL="postgresql://[username]:[password]@[hostname]:[port]/[database_name]?sslmode=require"
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:
npm install motion
Usage
Here is an example of how to use Motion to animate a component:
import { motion } from 'motion';
function MyComponent() {
return (
<motion.div animate={{ x: 100 }}>This is a component that is animated.</motion.div>
);
}
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:
npm install next-safe-action zod --filter app
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
"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;
});
Client Component
"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>
);
}
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:
npm install nuqs
Usage
Here is an example of how to use NUQS for URL search parameter state management:
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>
);
}
In this example, theuseQueryStatehook from nuqs is used to manage a single URL search parameter with type-safe parsing. ThesetQueryfunction updates thequeryURL parameter whenever the input value changes.react-wrap-balancerBenefits
- 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
, simply run the following command:
npm install react-wrap-balancer
react-wrap-balancerUsage
Here is an example of how to use
to make titles more readable:
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>
);
}
react-wrap-balancerBenefits
- Improved Readability:
makes titles more readable by automatically wrapping them at the appropriate breakpoints.react-wrap-balancer
- Easy Integration:is easy to integrate into your existing React application, providing a seamless installation experience.trunk-merge/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
branches to test PR combinations. Your CI workflows need to run on these branches:
on:
pull_request:
branches: [main]
push:
branches:
- main
- 'trunk-merge/'
Apply the same change to any other workflows that must pass before merging.mainConfigure branch protection
In your GitHub repository settings under Branches > Branch protection rules for
:trunk-io- Allow the
bot to push to your protected branchtrunk-temp/
- Disable "Require branches to be up to date before merging"
- Ensureandtrunk-merge/branches are not blocked by wildcard protection rulesmainGuard main-only steps
Steps that should only run on actual merges to
(not queue test branches) need a condition:
- name: Create Release
if: github.ref == 'refs/heads/main'
run: npx auto shipit
/trunk mergeUsage
Submit PRs to the queue by either:
- Checking the box in the Trunk bot's PR comment
- Commentingon the PRapps/app/vitest.config.mtsFor 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
andapps/api/vitest.config.mts:
export default defineConfig({
// ...existing config
test: {
environment: "jsdom",
reporters: [
"default",
["junit", { outputFile: "./junit.xml", addFileAttribute: true }],
],
},
});
<Tip>if: always()
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
so results are uploaded even when tests fail:
- 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 }}
TRUNK_API_TOKENRequired secrets
Add the following to your GitHub repository:
-
— API token from Trunk organization settingsTRUNK_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:
npm install zustand
Usage
Here is an example of how to use Zustand for state management:
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>
);
}
In this example, thecreatefunction from Zustand is used to create a store with acountstate andincrementanddecrementactions. TheuseStorehook is then used in theCountercomponent to access the state and actions.next-forgeBenefits
- 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.
uses Vercel Blob as the default storage provider. This guide will help you switch from Vercel Blob to Appwrite Storage.storage1. Replace the
package dependenciesUninstall the existing Vercel Blob dependency from the storage package...
npm uninstall @vercel/blob --filter @repo/storage
...and install the Appwrite dependencies:npm install node-appwrite appwrite --filter @repo/storage
.env.local2. Update environment variables
Add the following environment variables to your
file:
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
<Note>keys.ts
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
file to validate the new environment variables:
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,
},
});
index.ts4. Update the server storage file
Replace the contents of
with a configured Appwrite Storage client:
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 };
client.ts5. Update the client storage file
Update
with client-side Appwrite Storage helpers:
'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 };
uploads6. 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.,)APPWRITE_BUCKET_ID
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 asYou can also create a bucket programmatically:
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
);
7. File operations
Upload a file (server-side)
import { storage, bucketId, ID, InputFile } from '@repo/storage';
const file = await storage.createFile(
bucketId,
ID.unique(),
InputFile.fromBuffer(buffer, 'image.png')
);
Upload a file (client-side)
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]
);
Download a file
import { storage, bucketId } from '@repo/storage';
const fileData = await storage.getFileDownload(bucketId, 'file-id');
Delete a file
import { storage, bucketId } from '@repo/storage';
await storage.deleteFile(bucketId, 'file-id');
Get a file preview URL
Appwrite provides built-in image transformations through the preview endpoint:
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
);
Get file metadata
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
8. Update your apps
Replace Vercel Blob usage throughout your application:
// 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);
9. File permissions
Appwrite supports fine-grained file-level permissions. You can set permissions when creating files:
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')),
]
);
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...
npm uninstall @vercel/blob --filter @repo/storage
... and install the new dependencies...npm install uploadthing @uploadthing/react --filter @repo/storage
2. Update the environment variables
Next, update the environment variables across the project, for example:
// Remove this:
BLOB_READ_WRITE_TOKEN=""
// Add this:
UPLOADTHING_TOKEN=""
Additionally, replace all instances ofBLOB_READ_WRITE_TOKENwithUPLOADTHING_TOKENin thepackages/env/index.tsfile.index.ts3. Update the existing storage files
Update the
andclient.tsto use the newuploadthingpackages:Storage
import { createUploadthing } from 'uploadthing/next';
export { type FileRouter, createRouteHandler } from 'uploadthing/next';
export { UploadThingError as UploadError, extractRouterConfig } from 'uploadthing/server';
export const storage = createUploadthing();
Client
export * from '@uploadthing/react';
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.
export { NextSSRPlugin as StorageSSRPlugin } from '@uploadthing/react/next-ssr-plugin';
lib5. Create a file router in your app
Create a new file in your app's
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.
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 }),
};
api6. Create a route handler
Create a new route handler in your app's
directory to handle the file routes.
import { router } from '@/app/lib/upload';
import { createRouteHandler } from '@repo/storage';
export const { GET, POST } = createRouteHandler({ router });
StorageSSRPlugin7. Update your root layout
Update your root layout to include the
. This will add SSR hydration and avoid a loading state on your upload button.
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;
globals.css8. Update your Tailwind CSS
Update your design system's
file to include the following:
@import "uploadthing/tw/v4";
@source "../node_modules/@uploadthing/react/dist";
generateUploadButton9. Create your upload button
Create a new component for your upload button. This will use the
function to create a button that will upload files to theimageUploaderendpoint.
'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 = () => ( uploadthing is a powerful platform that offers a lot of advanced configuration options. You can learn more about them in the uploadthing documentation. --- --- 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> First, uninstall the existing dependencies from the Payments package... Next, update the environment variables across the project, for example: <Note> Initialize the payments client in the
<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});
}}
/>
);Now you can import this component into your app and use it as a regular component.10. Advanced configuration
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 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
npm uninstall stripe --filter @repo/payments... and install the new dependencies...
npm install @lemonsqueezy/lemonsqueezy.js --filter @repo/payments2. Update the environment variables
LEMON_SQUEEZY_API_KEY=""Additionally, replace all instances of STRIPE_SECRET_KEY with LEMON_SQUEEZY_API_KEY in the packages/env/index.ts file.NEXT_PUBLIC_
The API key should be a server-side environment variable (without the prefix), as it should not be exposed to the client.packages/payments/index.ts
</Note>3. Update the payments client
file with the new API key. Then, export the lemonSqueezySetup function from the file.
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';
4. Update the payments webhook handler
Update the webhook handler for Lemon Squeezy:
import { NextResponse } from 'next/server';
export const POST = async (request: Request) => {
return NextResponse.json({ message: 'Hello World' });
};
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.
import { getStore } from '@repo/payments';
const Page = async () => {
const store = await getStore(123456);
return (
<pre>{JSON.stringify(store, null, 2)}</pre>
);
};
---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...
npm uninstall stripe --filter @repo/payments
... and install the new dependencies...npm install @paddle/paddle-node-sdk --filter @repo/payments
packages/payments/keys.ts2. Update the Payment keys
Update the required Payment keys in the
file:
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,
},
});
3. Update the environment variables
Next, update the environment variables across the project, replacing the existing Stripe keys with the new Paddle keys:
Server
PADDLE_SECRET_KEY=""
PADDLE_WEBHOOK_SECRET=""
PADDLE_ENV="sandbox"
Client
NEXT_PUBLIC_PADDLE_ENV="sandbox"
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN="test_"
packages/payments/index.ts4. Update the payments client
Initialize the payments client in the
file with the new API key.
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';
5. Update the payments webhook handler
Update the webhook handler for Paddle:
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 });
}
};
There's quite a lot you can do with Paddle, so check out the following resources for more information:checkout- Webhooks Overview
- Signature Verification
- Simulate Webhooks6. Create a Checkout hook
Create a new file for
and installpaddle-js:
npm install @paddle/paddle-js --filter @repo/payments
Then, create a new hook to initialize Paddle in thepackages/payments/checkout.tsxfile:
'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;
}
7. Use the Checkout hook
Finally, open a checkout on your pricing page:
'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;
---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...
npm uninstall @knocklabs/node @knocklabs/react --filter @repo/notifications
... and install the new dependencies...npm install @novu/api @novu/react --filter @repo/notifications
packages/notifications/keys.ts2. Update the Notification keys
Update the required Notification keys in the
file:
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,
},
});
3. Update the environment variables
Next, update the environment variables across the project, replacing the existing Knock keys with the new Novu keys:
NOVU_SECRET_KEY=""
NEXT_PUBLIC_NOVU_APP_ID=""
packages/notifications/index.ts4. Update the notifications client
Initialize the notifications client in the
file with the new API key:
import { Novu } from '@novu/api';
import { keys } from './keys';
const key = keys().NOVU_SECRET_KEY;
export const notifications = new Novu({ secretKey: key });
NovuProvider5. Update the notifications provider
Replace the Knock provider with Novu's
inpackages/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>
);
};
Inbox6. Update the notifications trigger
Replace the Knock notification components with Novu's
inpackages/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 />;
};
You can also remove thepackages/notifications/styles.cssfile, as Novu'sInboxcomponent handles its own styling.apps/app/app/(authenticated)/components/notifications-provider.tsx7. Update the app-level notifications provider
Update the wrapper in
. The existing wrapper should work as-is since theNotificationsProvideralready accepts athemeprop. If the types differ, update accordingly:
'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>
);
};
8. Triggering notifications
To trigger a notification from the server, use the Novu API:
import { notifications } from '@repo/notifications';
await notifications.trigger({
workflowId: 'your-workflow-id',
to: {
subscriberId: 'user-123',
},
payload: {
message: 'Hello from Novu!',
},
});
There's quite a lot you can do with Novu, so check out the following resources for more information:package.json- 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
file...
npm uninstall @biomejs/biome ultracite
...and install the new ones:npm install -D eslint @next/eslint-plugin-next eslint-plugin-react eslint-plugin-react-hooks typescript-eslint
biome.json2. Configure ESLint
Delete the existing
file in the root of the project, and create a neweslint.config.mjsfile:
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',
},
},
]
.vscode/settings.json3. 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
file.vscode/settings.jsonAdd the following to your
file to match the following:
{
"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"
}
lint5. Re-enable the
scriptlintAs Next.js uses ESLint for linting, we can re-enable the
script in the rootpackage.jsonfiles. In each of the Next.js apps, update thepackage.jsonfile to include the following:
{
"scripts": {
"lint": "bun --bun next lint"
}
}
---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:
// Add this:
NEXT_PUBLIC_HYPERTUNE_TOKEN=""
Add a.envfile to thefeature-flagspackage with the following contents:
NEXT_PUBLIC_HYPERTUNE_TOKEN=""
HYPERTUNE_FRAMEWORK=nextApp
HYPERTUNE_OUTPUT_DIRECTORY_PATH=generated
HYPERTUNE_PLATFORM=vercel
HYPERTUNE_GET_HYPERTUNE_IMPORT_PATH=../lib/getHypertune
keys.ts3. Update the
file in thefeature-flagspackagefeature-flagspackage')" title="Copy chapter prompt for LLMs"> Copy ChapterNEXT_PUBLIC_HYPERTUNE_TOKENUse the
environment variable in the call tocreateEnv:
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,
},
});
create-flag.ts4. Swap out the required dependencies
First, delete the
file.feature-flagsThen, uninstall the existing dependencies from the
package:
npm uninstall @repo/analytics --filter @repo/feature-flags
Then, install the new dependencies:npm install hypertune server-only --filter @repo/feature-flags
analyze5. Set up Hypertune code generation
Add
andbuildscripts to thepackage.jsonfile for thefeature-flagspackage, which both execute thehypertunecommand:
{
"scripts": {
"analyze": "hypertune",
"build": "hypertune"
}
}
Then run code generation with the following command:bun run build --filter @repo/feature-flags
This will generate the following files:packages/feature-flags/generated/hypertune.ts
packages/feature-flags/generated/hypertune.react.tsx
packages/feature-flags/generated/hypertune.vercel.tsx
getHypertune.ts6. Set up Hypertune client instance
Add a
file in thefeature-flagspackage which defines agetHypertunefunction that returns an initialized instance of the Hypertune SDK on the server:
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 ?? '' },
},
},
});
}
index.ts7. Update
flagsHypertune automatically generates feature flag functions that use the
package. To export them the same way as before, update theindex.tsfile to export everything from thegenerated/hypertune.vercel.tsfile:
export * from "./generated/hypertune.vercel.tsx"
Hypertune adds aFlagsuffix 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:
bunx create-fumadocs-app
Here we assume you have enabled Fumadocs MDX, Tailwind CSS, and without a default ESLint config.lib/source.tsWhat 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>
is where you organize code for content sources.</Note>bun devUpdate your Tailwind CSS
Start the app with
.contentIf some styles are missing, it could be due to your monorepo setup, you can change the
property in your Tailwind CSS config (tailwind.config.mjs) to ensure it works:
export default {
content: [
// from
'./node_modules/fumadocs-ui/dist//*.js',
// to
'../../node_modules/fumadocs-ui/dist//*.js',
'./components//*.{ts,tsx}',
// ...
],
};
You can either keep the Tailwind config file isolated to the docs, or merge it with your existing config from thetailwind-configpackage..mdx2. Migrate MDX Files
Fumadocs, same as Mintlify, utilize MDX for content files. You can move the
files from your Mintlify app tocontent/docsdirectory.title<Note>Fumadocs requires a
frontmatter property.</Note>title="Title"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
.#### Before
``
sh title="Name"
bun install
text
#### After
```sh title="Fumadocs"`sh title="title="Name""
bun install
Code highlighting is done with an inline comment.
#### Before
```ts title="Mintlify"`ts {1}
console.log('Highlighted');
#### After
```ts title="Fumadocs"console.log('Highlighted'); // [!code highlight]
In Fumadocs, you can also highlight specific words.ts title="Fumadocs"
console.log('Highlighted'); // [!code word:Highlighted]
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');
console.log('B');</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
`` To use components without import, add them to your MDX component. Instead of a single file, you can configure Fumadocs using code. The sidebar items are generated from your file system, Fumadocs takes For example, to customise the order of pages in The overall theme can be customised using CSS variables and/or presets. #### CSS variables In your global CSS file:tsx title="Mintlify"hsl()
<Note>Hello World</Note>
<Warning>Hello World</Warning>
<Info>Hello World</Info>
<Tip>Hello World</Tip>
<Check>Hello World</Check>#### After
<Callout title="Title" type="info">Hello World</Callout>
<Callout title="Title" type="warn">Hello World</Callout>
<Callout title="Title" type="error">Hello World</Callout>Adding Components
import { Tabs, Tab } from 'fumadocs-ui/components/tabs';
<MDX components={{ Tabs, Tab }} />;mint.json3. Migrate
File meta.jsonSidebar Items
as the configurations of a folder.content/docs/components
You don't need to hardcode the sidebar config manually. folder, you can create a meta.json folder in the directory:
{
"title": "Components", // optional
"pages": ["index", "apple"] // file names (without extension)
}Fumadocs also support the rest operator (...) if you want to include the other pages.
{
"title": "Components", // optional
"pages": ["index", "apple", "..."] // file names (without extension)
}Visit the Pages Organization Guide for an overview of supported syntaxs.Appearance
:root {
/ hsl values, like hsl(239 37% 50%) but without /
--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%;
}
#### Tailwind PresetspresetIn your Tailwind config, use the
option.
import { createPreset } from 'fumadocs-ui/tailwind-plugin';
/ @type {import('tailwindcss').Config} */
export default {
presets: [
createPreset({
preset: 'ocean',
}),
],
};
See all available presets.app/layout.config.tsxLayout Styles
You can open
, it contains the shared options for layouts.layout.tsx
Fumadocs offer a default Docs Layout for documentation pages, and Home Layout for other pages.You can customise the layouts in
.app/api/search/route.tsSearch
contains the Route Handler for search, it is powered by Orama by default.Navigation Links
Navigation links are passed to layouts, you can also customise them in your Layout config.
import { BookIcon } from 'lucide-react';
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
export const baseOptions: BaseLayoutProps = {
links: [
{
icon: <BookIcon />,
text: 'Blog',
url: '/blog',
},
],
};
See all supported items.next-forgeDone
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.
uses Neon as the database provider with Prisma as the ORM. This guide will help you switch from Neon and Prisma to Appwrite Databases.database<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
package dependenciesdatabaseUninstall the existing Neon and Prisma dependencies from the
package...
npm uninstall @neondatabase/serverless @prisma/adapter-neon @prisma/client prisma ws @types/ws --filter @repo/database
...and install the Appwrite dependency:npm install node-appwrite --filter @repo/database
.env.local2. Update environment variables
Add the following environment variables to your
file. You can find these values in your Appwrite project's Settings page:
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
<Note>APPWRITE_DATABASE_ID
You'll need to create a database in the Appwrite Console first. Theis the ID of the database you create.keys.ts
</Note>3. Update the environment keys
Update the
file to validate the new environment variables:
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,
},
});
index.ts4. Update the database package
Replace the contents of
with a configured Appwrite Databases client:
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 };
5. Remove Prisma files
Delete the Prisma-specific files that are no longer needed:
rm -rf packages/database/prisma packages/database/prisma.config.ts
Also remove any Prisma-related scripts from yourpackage.jsonfiles (e.g.,migrate,generate,studio).posts6. 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
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()),
]
);
Then define its 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
);
<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
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(),
}
);
Read documents
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),
]
);
Update a document
import { database, databaseId } from '@repo/database';
const updated = await database.updateDocument(
databaseId,
'posts',
'document-id',
{
title: 'Updated Title',
}
);
Delete a document
import { database, databaseId } from '@repo/database';
await database.deleteDocument(
databaseId,
'posts',
'document-id'
);
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:
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')),
]
);
Common permission patterns:Role.any()-
— Anyone (including guests)Role.users()
-— Any authenticated userRole.user('userId')
-— A specific userRole.team('teamId')
-— Members of a specific teamRole.team('teamId', 'admin')
-— Team members with a specific role10. Update your apps
Replace Prisma queries throughout your application with Appwrite SDK calls:
// 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),
]
);
Additional features
Realtime subscriptions
Appwrite supports realtime subscriptions on the client side. You can listen for changes to documents:
import { client } from '@repo/auth/client';
const unsubscribe = client.subscribe( Appwrite supports relationships between collections. You can create one-to-one, one-to-many, and many-to-many relationships via the Console or SDK:
databases.${databaseId}.collections.posts.documents,
(response) => {
// Handle realtime event
console.log(response);
}
);Relationships
import { database, databaseId } from '@repo/database';
await database.createRelationshipAttribute(
databaseId,
'posts',
'comments',
'oneToMany',
false,
'postId',
'comments'
);
Indexes
For better query performance, create indexes on frequently queried attributes:
import { database, databaseId } from '@repo/database';
await database.createIndex(
databaseId,
'posts',
'idx_authorId',
'key',
['authorId']
);
For more information, see the Appwrite Databases documentation.next-forge---
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.
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.next-forgeHere's how to switch from Neon to Convex for your
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...
npm uninstall @neondatabase/serverless @prisma/adapter-neon @prisma/client prisma ws @types/ws --filter @repo/database
... and install Convex:npm install convex --filter @repo/database
3. Initialize Convex
From the root of your project, run:
npx convex dev
This will prompt you to log in, create a new project, and generate aconvex/directory in your project root with the configuration files. It will also create a.env.localfile with yourCONVEX_DEPLOYMENTandNEXT_PUBLIC_CONVEX_URLvariables.4. Set up the Convex client provider
Create a client component to wrap your app with the Convex provider. Add this to your app:
'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>
);
Then wrap your app layout with the provider:import { ConvexClientProvider } from '@repo/database/provider';
// ...
const RootLayout = ({ children }: { children: ReactNode }) => (
<html lang="en">
<body>
<ConvexClientProvider>
{children}
</ConvexClientProvider>
</body>
</html>
);
export default RootLayout;
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:
export { ConvexClientProvider } from './provider';
Delete theprisma/directory from@repo/database:
rm -rf packages/database/prisma
Updatekeys.tsto use the Convex environment variable:
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,
},
});
convex/6. Define your schema
Create a schema file in the
directory. Here's an example equivalent to the default PrismaPagemodel:
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';
export default defineSchema({
pages: defineTable({
title: v.string(),
content: v.optional(v.string()),
}),
});
Runnpx convex devto 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:
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);
},
});
useQuery8. Update your app code
Convex uses React hooks for data fetching with automatic real-time updates. Update your components to use
anduseMutation:
'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>
);
};
<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:
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;
9. Replace Prisma Studio
Delete the now unused Prisma Studio app:
rm -rf apps/studio
To manage your data, use the Convex Dashboard which provides a data browser, function logs, and deployment management.NEXT_PUBLIC_CONVEX_URL10. Deploy
When deploying your app, set the
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:
npx convex deploy
This deploys your schema and server functions to your production Convex instance.npx prisma db push---
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 runninginpackages/database). Thedrizzle-kit pullcommand in Step 4 introspects the live database — if no tables exist yet, it will generate an empty schema file.@repo/database
</Callout>1. Swap out the required dependencies in
Uninstall the existing dependencies...
npm uninstall @prisma/adapter-neon @prisma/client prisma --filter @repo/database
...and install the new ones:npm install drizzle-orm @neondatabase/serverless --filter @repo/database
npm install -D drizzle-kit --filter @repo/database
@repo/database/index.ts2. Update the database connection code
Delete everything in
and replace it with the following:
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 });
drizzle.config.ts3. Create a
filedrizzle.config.tsNext 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
file in thepackages/databasedirectory with the following contents:
import { defineConfig } from 'drizzle-kit';
import { env } from '@repo/env';
export default defineConfig({
schema: './schema.ts',
out: './',
dialect: 'postgresql',
dbCredentials: {
url: env.DATABASE_URL,
},
});
packages/database4. 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
folder, run the following command to generate the schema file:
npx drizzle-kit pull
This should pull the schema from the database, creating aschema.tsfile containing the table definitions and some other files.schema.tsIf the generated
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-forgePagemodel translates to:
import { pgTable, serial, text } from 'drizzle-orm/pg-core';
export const page = pgTable('Page', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
});
page5. Update your queries
Now you can update your queries to use the Drizzle ORM.
For example, here's how we can update the
query inapp/(authenticated)/page.tsx:
import { database } from '@repo/database';
import { page } from '@repo/database/schema';
// ...
const App = async () => {
const pages = await database.select().from(page);
// ...
};
export default App;
apps/studio6. Remove Prisma Studio
You can also delete the now unused Prisma Studio app located at
:
rm -fr apps/studio
package.json7. Update the migration script in the root
package.jsonChange the migration script in the root
from Prisma to Drizzle. Update themigratescript to use Drizzle commands:
"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"
}
---edgedbContent/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. Thenpm packages and CLI commands still work via compatibility shims. See the Gel announcement for details.next-forge
</Note>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.next-forge<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
project.@repo/database1. 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
Uninstall the existing dependencies...
npm uninstall @prisma/adapter-neon @prisma/client prisma --filter @repo/database
... and install the new dependencies:npm install edgedb @edgedb/generate
@repo/database3. Setup EdgeDB in
package@repo/databaseIn the
directory, run:
npx edgedb project init --server-instance <org_name>/<instance_name> --non-interactive
<Note><org_name>
Replaceand<instance_name>with the EdgeDB's organization and instance you've previously created in the EdgeDB Cloud.init
</Note>The
command creates a new subdirectory calleddbschema, which contains everything related to EdgeDB:
dbschema
├── default.esdl
└── migrations
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.prisma/You can also delete
directory from the@repo/database:
rm -fr packages/database/prisma
4. Update the database connection code
Update the database connection code to use an EdgeDB client:
import 'server-only';
import { createClient } from "edgedb";
export const database = createClient();
5. Update the schema file and generate types
Now, you can modify the database schema:
module default {
type Page {
email: str {
constraint exclusive;
}
name: str
}
}
And apply your changes by running:npx edgedb migration create
npx edgedb migration apply
Once complete, you can also generate a TypeScript query builder and types from your database schema:npx @edgedb/generate edgeql-js
npx @edgedb/generate interfaces
These commands introspect the schema of your database and generate code in thedbschemadirectory.page6. Update your queries
Now you can update your queries to use the EdgeDB client.
For example, here’s how we can update the
query inapp/(authenticated)/page.tsx:
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;
apps/studio7. Replace Prisma Studio with EdgeDB UI
You can also delete the now unused Prisma Studio app located at
:
rm -fr apps/studio
To manage your database and browse your data, you can run:npx edgedb ui
EDGEDB_SECRET_KEY8. Extract EdgeDB environment variables for deployment
When deploying your app, you need to provide the
andEDGEDB_INSTANCEenvironment variables in your app's cloud provider to connect to your EdgeDB Cloud instance.npx edgedb cloud secretkey createYou can generate a dedicated secret key for your instance with
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>
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:
DATABASE_URL="mysql://<username>:<password>@<region>.aws.connect.psdb.cloud/<database>"
DATABASE_URL="mysql://<username>:<password>@<region>.aws.connect.psdb.cloud/<database>"
Etcetera.@repo/database3. Swap out the required dependencies in
Uninstall the existing dependencies...
npm uninstall @neondatabase/serverless @prisma/adapter-neon ws @types/ws --filter @repo/database
...and install the new ones:npm install @planetscale/database @prisma/adapter-planetscale --filter @repo/database
4. Update the database connection code
Update the database connection code to use the new PlanetScale adapter:
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 });
5. Update your Prisma schema
Update your Prisma schema to use the new database provider:
// 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?
}
dev6. Add a
scriptdevAdd a
script to yourpackage.json:
{
"scripts": {
"dev": "pscale connect [database_name] [branch_name] --port 3309"
}
}
---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....
2. Update your environment variables
Update your environment variables to use the new Prisma Postgres connection string:
DATABASE_URL="prisma+postgres://accelerate.prisma-data.net/?api_key=ey...."
@repo/database3. Swap out the required dependencies in
Uninstall the existing dependencies...
npm uninstall @neondatabase/serverless @prisma/adapter-neon ws @types/ws
... and install the new dependencies:npm install @prisma/extension-accelerate
4. Update the database connection code
Update the database connection code to use the new Prisma Postgres adapter:
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());
Your project is now configured to use your Prisma Postgres instance for migrations and queries.swr5. 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 eventsCaching
andttloptions to any given query, for example:
const pages = await prisma.page.findMany({
cacheStrategy: {
swr: 60, // 60 seconds
ttl: 60, // 60 seconds
},
});
Learn more in the Accelerate documentation.@prisma/extension-pulseReal-time database events
<Warning>
Prisma 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:
npm install @prisma/extension-pulse
Next, you need to add your Pulse API key as an environment variable:PULSE_API_KEY="ey...."
<Info>api_key
You can find your Pulse API key in your Prisma Postgres connection string, it's the value of theargument and starts withey.... Alternatively, you can find the API key in your Prisma Postgres Dashboard.env
</Info>Then, update the
package to include the newPULSE_API_KEYenvironment variable:
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,
},
});
Finally, update the database connection code to include the Pulse extension: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 })) ;
You can now stream any change events from your database using the following code: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);
}
Learn more in the Pulse documentation.next-forge---
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.
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.next-forge<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
project.Transaction1. 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
mode, with the port ending in6543. We'll call thisDATABASE_URL.Session
- The Database URL inmode, with the port ending in5432. We'll call thisDIRECT_URL..env3. Update the environment variables
Update the
file with the Supabase connection details. Make sure you add?pgbouncer=true&connection_limit=1to the end of theDATABASE_URLvalue.
DATABASE_URL="postgres://postgres:[email protected]:54322/postgres?pgbouncer=true&connection_limit=1"
DIRECT_URL="postgres://postgres:[email protected]:54322/postgres"
<Note>pgbouncer=truedisables Prisma from generating prepared statements. This is required since our connection pooler does not support prepared statements in transaction mode yet. Theconnection_limit=1parameter 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...
npm uninstall @neondatabase/serverless @prisma/adapter-neon ws @types/ws --filter @repo/database
... and add the Supabase dependencies:npm install -D supabase --filter @repo/database
database5. Update the database package
Update the
package. We'll remove the Neon extensions and connect to Supabase directly, which should automatically use the environment variables we set earlier.
import 'server-only';
import { PrismaClient } from '@prisma/client';
export const database = new PrismaClient();
export * from '@prisma/client';
prisma/schema.prisma6. Update the Prisma schema
Update the
file so it contains theDIRECT_URL. This allows us to use the Prisma CLI to perform other actions on our database (e.g. migrations) by bypassing Supavisor.
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}
<Note>relationMode = "prisma"
You don't needhere — 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).next-forge
</Note>Now you can run the migration from the root of your
project:
bun run migrate
auth.uid()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 useto 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:
-- 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;
Create RLS policies
Once RLS is enabled, create policies that define who can access what data:
#### Organization 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);
#### Organization member 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')
)
);
Testing RLS policies
You can test your RLS policies using the Supabase SQL Editor with the following pattern:
-- 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
Common RLS patterns
#### User-owned data
For data that belongs directly to a user (like user profiles or settings):
CREATE POLICY "Users can only access their own data"
ON user_data FOR ALL
USING (user_id = auth.uid());
#### Tenant isolationFor multi-tenant data where access is determined by an organization or tenant ID:
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()
)
);
#### Public read, authenticated writeFor data that everyone can read but only authenticated users can modify:
-- 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);
<Warning>next-forge
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
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:
turso db create <database-name>
You can now fetch the URL to the database:turso db show <database-name> --url
It will look something like this:libsql://<database-name>-<account-or-org-slug>.turso.io
3. Create a Database Auth Token
You will need to create an auth token to connect to your Turso database:
turso db tokens create <database-name>
4. Update your environment variables
Update your environment variables to use the new Turso connection string:
DATABASE_URL="libsql://<database-name>-<account-or-org-slug>.turso.io"
DATABASE_AUTH_TOKEN="..."
DATABASE_URL="libsql://<database-name>-<account-or-org-slug>.turso.io"
DATABASE_AUTH_TOKEN="..."
Etcetera.packages/env/index.tsNow inside
, addDATABASE_AUTH_TOKENto theserverandruntimeEnvobjects:
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,
// ...
},
});
@libsql/client5. Install @libsql/client
is used to connect to the hosted Turso database.Uninstall the existing dependencies for Neon...
npm uninstall @neondatabase/serverless @prisma/adapter-neon ws @types/ws --filter @repo/database
... and install the new dependencies for Turso & libSQL:npm install @libsql/client --filter @repo/database
packages/database/index.ts6. Update the database connection code
Open
and make the following changes:
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;
7. Apply schema changes
Now connect to the Turso database using the CLI:
turso db shell <database-name>
And apply the schema to the database:CREATE TABLE pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
name TEXT
);
libsql8. Update application code
Now wherever you would usually call Prisma, use the
client instead:
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>;
---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...
npm uninstall basehub --filter @repo/cms
... and install the new dependencies...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
.gitignore2. Update the
file.content-collectionsAdd
to the root.gitignorefile (in the root of your monorepo):
content-collections
.content-collections
basehub3. Modify the CMS package scripts
Now we need to modify the CMS package scripts to replace the
commands withcontent-collections.
{
"scripts": {
"dev": "content-collections build",
"build": "content-collections build",
"analyze": "content-collections build"
},
}
<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:
export { withContentCollections as withCMS } from '@content-collections/next';
This replaces the previous BaseHub configuration and maintains compatibility with your existingnext.config.tsin the web app.Collections
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),
};
Components
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} />
);
tsconfig.jsonTypeScript Config
Update your
in theapps/webdirectory to add the path mapping:
{
"compilerOptions": {
"paths": {
"content-collections": ["./.content-collections/generated"]
}
}
}
<Note>compilerOptions.paths
Make sure to merge this with your existingif you have any.
</Note>Toolbar
export const Toolbar = () => null;
Table of Contents
import { getTableOfContents } from 'fumadocs-core/content/toc';
type TableOfContentsProperties = {
data: string;
};
export const TableOfContents = async ({
data,
}: TableOfContentsProperties) => {
const toc = await getTableOfContents(data);
return ( Update the
<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>
);
};sitemap.ts5. Update the
file sitemap.ts file to scan the content directory for MDX files:
// ...
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', ''));
// ...
cms6. Create your collections
Create a new content collections configuration file in the
package, then create a re-export file in thewebapp.title<Note>We're remapping the
field to_titleand the_meta.pathfield to_slugto match the default next-forge CMS.</Note>CMS Package
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],
});
webWeb App
Create a configuration file in the root of your
app:
export { default } from '@repo/cms/collections';
<Note>bun run build
After creating these files, you'll need to runin thepackages/cmsdirectory to generate the types. TypeScript errors about missingcontent-collectionsmodule will resolve after the first build.apps/web/content/blog
</Note>7. Create your content
Create the content directories if they don't exist:
-for blog postsapps/web/content/legal
-for legal pagesapps/web/content/blogTo create a new blog post, add a new MDX file to the
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:
---
title: 'My First Post'
description: 'This is my first blog post'
date: 2024-10-23
image: /blog/my-first-post.png
---
The same concept applies to thelegalcollection, which is used to generate the legal policy pages. Also, theimagefield is the path relative to the app's rootpublicdirectory.BASEHUB_TOKEN8. Remove the environment variables
Finally, remove all instances of
from the@repo/envpackage.9. Bonus features
Fumadocs MDX Plugins
You can use the Fumadocs MDX plugins to enhance your MDX content.
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]],
})
);
// ...
},
});
Reading Time
You can calculate reading time for your collection by adding a transform function.
import readingTime from 'reading-time';
const posts = defineCollection({
// ...
transform: async (page, context) => {
// ...
return {
// ...
readingTime: readingTime(page.content).text,
};
},
});
Low-Quality Image Placeholder (LQIP)
You can generate a low-quality image placeholder for your collection by adding a transform function.
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,
};
},
});
---next-forgeContent/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.
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.auth1. Replace the
package dependenciesauthUninstall the existing Clerk dependencies from the
package...
npm uninstall @clerk/nextjs @clerk/themes @clerk/types --filter @repo/auth
...and install the Appwrite dependencies:npm install appwrite node-appwrite --filter @repo/auth
.env.local2. Update environment variables
Add the following environment variables to your
file in each Next.js application (app,web, andapi). You can find these values in your Appwrite project's Settings page:
NEXT_PUBLIC_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
NEXT_PUBLIC_APPWRITE_PROJECT_ID=your-project-id
APPWRITE_API_KEY=your-api-key
<Note>keys.ts
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
file to validate the new Appwrite environment variables:
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,
},
});
node-appwrite4. Create the server client
Create a server-side Appwrite client using
with cookie-based session management:
/ Detailed source-code truncated for AI context efficiency. /
5. Create the browser client
Create a client-side Appwrite client:
'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 };
proxy.ts6. Update the middleware
Replace
withmiddleware.tsto handle session validation:
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();
};
<Note>proxy.ts
Delete the oldfile 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:
import type { ReactNode } from 'react';
type AuthProviderProps = {
children: ReactNode;
};
export const AuthProvider = ({ children }: AuthProviderProps) => children;
sign-in.tsx8. Update the auth components
Update both the
andsign-up.tsxcomponents to use Appwrite Auth:Sign In
/ Detailed source-code truncated for AI context efficiency. /
Sign Up
/ Detailed source-code truncated for AI context efficiency. /
9. Set up auth callback route for OAuth
Create a callback route to handle OAuth redirects:
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 Appwrite provides a built-in Teams API for managing groups of users. Create helper functions to manage organizations:);
}10. Implement organization management
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
);
};
11. Update your apps
Replace any remaining Clerk implementations in your apps with Appwrite equivalents:
Server Components
// 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();
Client Components
// 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));
}, []);
Sign Out
// 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>
Additional features
OAuth Authentication
To add OAuth providers, configure them in your Appwrite Console under Auth → Settings, then use:
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
);Magic URL Authentication
import { account } from '@repo/auth/client';
import { ID } from 'appwrite';
await account.createMagicURLToken( For more information, see the Appwrite Auth documentation. --- --- <Warning> Here's how to switch from Clerk to Auth.js. Uninstall the existing Clerk dependencies from the 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: Delete the existing Update the
ID.unique(),
email,
${window.location.origin}/api/auth/callback
);<Note>auth
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>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
---
next-forge support for Auth.js is currently blocked by this issue.
</Warning>1. Replace the dependencies
package...
npm uninstall @clerk/nextjs @clerk/themes @clerk/types --filter @repo/auth... and install the Auth.js dependencies.
npm install next-auth@beta --filter @repo/auth2. Generate an Auth.js secret
cd apps/app && npx auth secret && cd -
cd apps/web && npx auth secret && cd -
cd apps/api && npx auth secret && cd -This will automatically add an AUTH_SECRET environment variable to the .env.local file in each directory.client.ts3. Replace the relevant files
and server.ts files in the auth package. Then, create the following file:
import NextAuth from "next-auth";
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [],
});middleware.ts4. Update the middleware
file in the auth package with the following content:
import 'server-only';
export { auth as authMiddleware } from './';
signIn5. Update the auth components
Auth.js has no concept of "sign up", so we'll use the
function to sign up users. Update both thesign-in.tsxandsign-up.tsxcomponents in theauthpackage with the same content:Sign In
import { signIn } from '../';
export const SignIn = () => (
<form
action={async () => {
"use server";
await signIn();
}}
>
<button type="submit">Sign in</button>
</form>
);
Sign Up
import { signIn } from '../';
export const SignUp = () => (
<form
action={async () => {
"use server";
await signIn();
}}
>
<button type="submit">Sign up</button>
</form>
);
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:
import type { ReactNode } from 'react';
type AuthProviderProps = {
children: ReactNode;
};
export const AuthProvider = ({ children }: AuthProviderProps) => children;
app7. Create an auth route handler
In your
application, create an auth route handler file with the following content:
import { handlers } from "@repo/auth"
export const { GET, POST } = handlers;
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:
const { orgId } = await auth();
const { redirectToSignIn } = await auth();
const user = await currentUser();
Etcetera. Keep in mind that you'll need to build your own "organization" logic as Auth.js doesn't have a concept of organizations.auth---
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
package dependenciesauthUninstall the existing Clerk dependencies from the
package...
npm uninstall @clerk/nextjs @clerk/themes @clerk/types --filter @repo/auth
...and install the Better Auth dependencies:npm install better-auth next --filter @repo/auth
Additionally, add@repo/databaseto theauthpackage dependencies..env.local2. Update your environment variables
Generate a secret with the following command to add it to the
file in each Next.js application (app,webandapi):
npx @better-auth/cli@latest secret
This will add aBETTER_AUTH_SECRETenvironment variable to the.env.localfile. You should also add theBETTER_AUTH_URLenvironment variable, pointing to your app's base URL:
BETTER_AUTH_URL="http://localhost:3000"
auth3. Setup the server and client auth
Update the
package files with the following code:Server
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
});
Client
import { createAuthClient } from 'better-auth/react';
export const { signIn, signOut, signUp, useSession } = createAuthClient();
Read more in the Better Auth installation guide.sign-in.tsx4. Update the auth components
Update both the
andsign-up.tsxcomponents in theauthpackage to use thesignInandsignUpfunctions from theclientfile.Sign In
"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>
);
}
Sign Up
"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>
);
}
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:
npx @better-auth/cli@latest generate --output ./packages/database/prisma/schema.prisma --config ./packages/auth/server.ts
<Warning>server-only
You may have to comment out thedirective inpackages/database/index.tstemporarily. 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:
import type { ReactNode } from 'react';
type AuthProviderProps = {
children: ReactNode;
};
export const AuthProvider = ({ children }: AuthProviderProps) => children;
auth7. Change Middleware
Change the middleware in the
package to the following. The middleware checks for a session cookie and redirects unauthenticated users to the sign-in page. The optionalmiddlewareFnparameter allows you to add custom logic before the authentication check:
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();
};
}
Clerk8. Define and add Next.js Handlers to your app
Unlike, you need to host auth handlers which will retrieve sessions, authenticate requests etc...
import 'server-only';
import { toNextJsHandler } from 'better-auth/next-js';
import { auth } from './server';
export const { POST, GET } = toNextJsHandler(auth);
export { POST, GET } from '@repo/auth/handlers'
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:
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
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 },
});
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 },
},
},
});
For using organization, check organization plugin and more from the Better Auth documentation.next-forge---
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.
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.auth<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
package dependenciesauthUninstall the existing Clerk dependencies from the
package...
npm uninstall @clerk/nextjs @clerk/themes @clerk/types --filter @repo/auth
...and install the Supabase Auth dependencies:npm install @supabase/supabase-js @supabase/ssr --filter @repo/auth
Additionally, add@repo/databaseto theauthpackage dependencies to enable organization/team management..env.local2. Update environment variables
Add the following environment variables to your
file in each Next.js application (app,web, andapi). You can find these values in your Supabase project's Settings → API page:
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
<Note>NEXT_PUBLIC_SUPABASE_ANON_KEY
The anon key is safe to use in client-side code as it respects your Row Level Security (RLS) policies. Supabase is transitioningtoNEXT_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:
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")
}
Then run the migration:bun run migrate
4. Create Supabase client utilities
Create utility functions to initialize Supabase clients for different contexts:
Server Client
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,
};
};
Client Component Client
'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!
);
};
middleware.ts5. Update the middleware
Update the
file to handle Supabase session refresh:
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;
};
sign-in.tsx6. Update the auth components
Update both the
andsign-up.tsxcomponents to use Supabase Auth:Sign In
'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>
);
};
Sign Up
/ Detailed source-code truncated for AI context efficiency. /
7. Update the Provider file
Supabase Auth doesn't require a Provider component for basic functionality, so replace it with a stub:
import type { ReactNode } from 'react';
type AuthProviderProps = {
children: ReactNode;
};
export const AuthProvider = ({ children }: AuthProviderProps) => children;
8. Implement organization management
Create helper functions to manage organizations in your application. Add these to a new file:
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
};
9. Set up auth callback route
Create a callback route to handle authentication redirects:
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 Replace any remaining Clerk implementations in your apps with Supabase Auth equivalents:
return NextResponse.redirect(${origin}/auth/auth-code-error);
}10. Update your apps
Server Components
// 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();
Client Components
// 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();
}, []);
Sign Out
// 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>
Additional features
Social Authentication
To add OAuth providers, configure them in your Supabase project settings, then use:
const { error } = await supabase.auth.signInWithOAuth({
provider: 'github', // or 'google', 'apple', etc.
options: {
redirectTo: ${window.location.origin}/api/auth/callback
,
},
});
textMagic Link Authentication
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';
textThen, you can use the capture method to send events:
tsx
analytics?.capture({
event: 'Product Purchased',
distinctId: 'user_123',
});
textWebhooks
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,
});
};
textblog.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 basehub/next-forge template')" title="Copy section prompt for LLMs"> Copy SectionYou'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
textThe token will look something like this:
bshb_pk_<password>
textKeep 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>"
text3. 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
textSo 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
// ...
},
},
},
}
textStarter 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>
);
textYou 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>
);
textCaveats
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
textThis 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
textWe 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 />
);
textYou 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;
``---