DaySync: Foundation, Auth + Community

Build a full-stack Next.js app with OAuth, magic link auth, and invite-based communities.

Introduction

30 Second Summary

Every dev community needs a home base. A place where people can sign in, see who else is around, and coordinate without scrolling through endless chat messages.

In this project, you will build the foundation of DaySync, a real-time coordination platform for developer communities. You'll set up authentication, design a database schema, and create an invite-link system that lets people create and join communities.

What You'll Build

DaySync is a full-stack Next.js application with three authentication methods, a PostgreSQL database, and community management features, deployed to Vercel.

By the end of this project, you'll have:

  • A deployed Next.js + TypeScript application on Vercel with three authentication methods (GitHub, Google, email magic link) powered by Better Auth.
  • A PostgreSQL database on Neon with a Prisma-managed schema for users, communities, and memberships, validated at every API boundary with Zod.
  • A working invite-link community system where users can create a community, generate a shareable link, and others can join via that link.
  • Secret Mission: Add role-based access control so only community admins can remove members and regenerate invite links.

Are there any prerequisites?

You should be familiar with JavaScript, HTML/CSS, and Git basics. You'll also need a GitHub account and a Google account (for OAuth setup).

No prior experience with Next.js, Prisma, or authentication systems is required. This project walks you through each piece from scratch.

Scaffold the Project

DaySync is a coordination platform for dev communities. Before you can build authentication and community features, you need a solid foundation: a scaffolded project, validated environment variables, and a connected database.

In this step, you'll set up that foundation. You'll scaffold a Next.js project with TypeScript and Tailwind CSS, add environment validation so the app crashes loudly if something is missing, and connect Prisma to a Neon PostgreSQL database.

In this step, get ready to:
  • Scaffold a Next.js + TypeScript + Tailwind project and install dependencies.
  • Add Zod-based environment validation that fails fast on startup.
  • Connect Prisma to a Neon PostgreSQL database.
Scaffold Next.js + TypeScript + Tailwind

The create-next-app CLI generates a complete Next.js project with all the configuration you need. You'll customize the prompts to use App Router and a src/ directory, which keeps application code separated from config files at the root.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold the project by running this command:
npx create-next-app@latest daysync --src-dir --app --use-npm

What does this command do?

  • npx create-next-app@latest runs the Next.js project scaffolder.
  • daysync is the project name and folder it creates.
  • --src-dir places app code inside a src/ directory, keeping config files at the root.
  • --app enables the App Router (server-first rendering by default).
  • --use-npm ensures npm is used as the package manager.

TypeScript and Tailwind CSS are included by default. The CLI will prompt you for a few remaining options (ESLint, React Compiler, import alias, AGENTS.md). Accept the defaults for each.

  • Once the scaffold completes, move into the project folder:
cd daysync
  • Install the additional dependencies DaySync needs by running:
npm install better-auth @better-auth/prisma-adapter prisma @prisma/client zod

What are these packages?

  • better-auth is the authentication framework (OAuth, magic links, session management).
  • @better-auth/prisma-adapter connects Better Auth to your Prisma database.
  • prisma and @prisma/client are the ORM and its generated query client.
  • zod handles runtime validation (environment variables, API inputs).
  • Now clear the boilerplate from the home page. Open src/app/page.tsx in Neovim:
nvim src/app/page.tsx
  • Replace the entire file contents with this minimal component:
export default function Home() {
  return <main></main>;
}
  • Save the file and exit (:wq).
  • Confirm the dev server starts by running:
npm run dev
  • Open http://localhost:3000 in your browser. You should see a blank white page.
  • Stop the dev server with Ctrl+C.

Seeing an error on startup?

  • Make sure you ran cd daysync before npm run dev. The command must run from inside the project folder.
  • If you see a port conflict, another process is using port 3000. Stop it or run npm run dev -- -p 3001 to use a different port.

Why isn't my Next.js dev server starting?

Add environment validation with Zod

DaySync depends on nine environment variables (database URLs, auth secrets, OAuth credentials). If any are missing, the app will fail in confusing ways at runtime. Zod validation at import time means the app crashes immediately with a clear error naming the missing variable.

  • Create the src/lib directory:
mkdir -p src/lib
  • Create the environment validation file:
nvim src/lib/env.ts
  • Add the following Zod schema that validates every required environment variable:
import { z } from 'zod';

const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  DIRECT_URL: z.string().url(),
  BETTER_AUTH_SECRET: z.string().min(1),
  BETTER_AUTH_URL: z.string().url(),
  GITHUB_CLIENT_ID: z.string().min(1),
  GITHUB_CLIENT_SECRET: z.string().min(1),
  GOOGLE_CLIENT_ID: z.string().min(1),
  GOOGLE_CLIENT_SECRET: z.string().min(1),
  RESEND_API_KEY: z.string().min(1),
});

export const env = envSchema.parse(process.env);

What does this code do?

The Zod schema defines the shape of your environment. When envSchema.parse(process.env) runs, it checks that every key exists and matches its type constraint (.url() for connection strings, .min(1) for non-empty strings).

If anything is missing or malformed, the app throws immediately with a Zod error naming the exact variable. No silent failures.

  • Save and exit (:wq).
  • Create .env.example so other developers know which variables are needed:
nvim .env.example
  • Add all the required keys with empty values:
DATABASE_URL=
DIRECT_URL=
BETTER_AUTH_SECRET=
BETTER_AUTH_URL=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
RESEND_API_KEY=

Why an .env.example file?

This file gets committed to git so anyone who forks the project instantly sees which variables they need. The actual values live in .env.local which is gitignored.

  • Save and exit.
  • Create .env.local with placeholder values so the app can start:
nvim .env.local
  • Add these placeholder values (you'll replace them with real values as you set up each service):
DATABASE_URL=postgresql://placeholder:placeholder@localhost:5432/daysync
DIRECT_URL=postgresql://placeholder:placeholder@localhost:5432/daysync
BETTER_AUTH_SECRET=placeholder-secret-at-least-32-characters-long
BETTER_AUTH_URL=http://localhost:3000
GITHUB_CLIENT_ID=placeholder
GITHUB_CLIENT_SECRET=placeholder
GOOGLE_CLIENT_ID=placeholder
GOOGLE_CLIENT_SECRET=placeholder
RESEND_API_KEY=placeholder
  • Save and exit.
  • Now wire the validation into the app by importing env.ts in the root layout. Open src/app/layout.tsx:
nvim src/app/layout.tsx
  • Replace the entire file with:
import '@/lib/env';
import type { Metadata } from 'next';
import './globals.css';

export const metadata: Metadata = {
  title: 'DaySync',
  description: 'Coordination platform for dev communities',
};

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

What does this code do?

The import '@/lib/env' line at the top triggers Zod validation the moment the layout loads. Since the root layout wraps every page, this guarantees the check runs before any route renders.

If any variable is missing, the server crashes on startup with a clear error. You will never get a confusing runtime failure buried deep in a request handler.

  • Save and exit.
  • Verify the validation works by removing a variable from .env.local. Comment out the RESEND_API_KEY line and run npm run dev. You should see a Zod error naming the missing key.
  • Restore the line (uncomment it), then stop the server with Ctrl+C.

No error even after removing a variable?

  • Make sure import '@/lib/env' is the first import in src/app/layout.tsx. If it appears after other imports, Next.js may cache a prior build.
  • Delete .next and restart: rm -rf .next && npm run dev.

Why isn't my Zod env validation firing?

Connect Prisma to Neon

Neon provides serverless PostgreSQL with two connection strings: a pooled one (via PgBouncer) for runtime queries and a direct one for schema changes. Prisma needs both.

  • Open the Neon console in your browser at https://console.neon.tech.
  • Click New Project.
  • Name the project daysync and click Create Project.
  • On the connection details page, find the Connection string dropdown.
  • Copy the pooled connection string (the hostname contains -pooler).
  • Open .env.local and replace the DATABASE_URL placeholder with this pooled string.
  • Back in Neon, switch the dropdown to the direct (non-pooled) connection string and copy it.
  • Replace the DIRECT_URL placeholder in .env.local with this direct string.

Why two connection strings?

Serverless functions (like Next.js API routes on Vercel) can spawn many instances. Each opens a database connection. The pooled string routes through PgBouncer which multiplexes thousands of connections into a few real ones.

The direct string bypasses pooling. Prisma needs this for schema operations (db push, migrations) because PgBouncer doesn't support the transaction modes those require.

  • Initialize Prisma in your project by running:
npx prisma init --datasource-provider postgresql
  • Open the generated prisma/schema.prisma file:
nvim prisma/schema.prisma
  • Replace the entire file with this schema that includes both the pooled and direct URLs:
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DIRECT_URL")
}

What does this code do?

  • generator client tells Prisma to generate a TypeScript client you import in your code.
  • url points to the pooled connection for runtime queries.
  • directUrl points to the direct connection for schema operations like db push.
  • Save and exit.
  • Create the Prisma client singleton. Open a new file:
nvim src/lib/prisma.ts
  • Add this singleton pattern that prevents connection exhaustion during hot reloads:
import { PrismaClient } from '@prisma/client';

const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };

export const prisma = globalForPrisma.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

What does this code do?

In development, Next.js hot-reloads your code on every save. Without a singleton, each reload creates a new PrismaClient instance with its own database connection. After enough saves, you exhaust the connection pool.

This pattern stores the client on globalThis so hot reloads reuse the same instance. In production, only one instance ever exists anyway.

  • Save and exit.
  • Push the (currently empty) schema to Neon to verify your connection works:
npx prisma db push
  • You should see a success message indicating the database is in sync. If you also see a prompt to generate the Prisma Client, accept it.

Connection refused or authentication error?

  • Double-check that DATABASE_URL in .env.local is the pooled string (hostname contains -pooler) and DIRECT_URL is the direct string.
  • Make sure the strings don't have trailing spaces or quotes.
  • Prisma uses DIRECT_URL for db push. If your direct string is wrong, you'll get an authentication error even if DATABASE_URL is correct.

Why can't Prisma connect to my Neon database?

  • Initialize your git repository and make your first commit:
git init
git add .
git commit -m "scaffold: next.js + prisma + env validation"
  • Verify that .env.local is NOT included in the commit. Run:
git status
  • You should see a clean working tree. The .env.local file is automatically gitignored by Next.js.

✔️ Awesome, I've got everything!

Great. Double check you've saved all files and your git commit is clean.

ⓧ I'd like to double check the full code

Here are all the files you should have at this point.

DATABASE_URL=
DIRECT_URL=
BETTER_AUTH_SECRET=
BETTER_AUTH_URL=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
RESEND_API_KEY=
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DIRECT_URL")
}
import '@/lib/env';
import type { Metadata } from 'next';
import './globals.css';

export const metadata: Metadata = {
  title: 'DaySync',
  description: 'Coordination platform for dev communities',
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}
export default function Home() {
  return <main></main>;
}
import { z } from 'zod';

const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  DIRECT_URL: z.string().url(),
  BETTER_AUTH_SECRET: z.string().min(1),
  BETTER_AUTH_URL: z.string().url(),
  GITHUB_CLIENT_ID: z.string().min(1),
  GITHUB_CLIENT_SECRET: z.string().min(1),
  GOOGLE_CLIENT_ID: z.string().min(1),
  GOOGLE_CLIENT_SECRET: z.string().min(1),
  RESEND_API_KEY: z.string().min(1),
});

export const env = envSchema.parse(process.env);
import { PrismaClient } from '@prisma/client';

const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };

export const prisma = globalForPrisma.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

Your foundation is solid. You have a scaffolded Next.js app, environment validation that catches mistakes immediately, and a connected PostgreSQL database. Next up, you'll build the authentication system with Better Auth so users can sign in with GitHub, Google, or a magic email link.

Set Up Authentication

Your project's data layer is ready. Now you need a way for users to prove who they are before they can interact with communities.

In this step, you'll configure Better Auth with three sign-in methods: GitHub OAuth, Google OAuth, and email magic links. By the end, users will be able to sign in through any of these methods and get a database-backed session.

In this step, get ready to:
  • Configure Better Auth with Prisma, OAuth providers, and a magic link plugin.
  • Set up GitHub OAuth, Google OAuth, and Resend for email delivery.
  • Build a sign-in page with buttons for all three auth methods.
Configure Better Auth on the server

Better Auth needs a central configuration file that defines how authentication works: which database adapter to use, which providers are available, and which plugins are active.

  • Create a new file at src/lib/auth.ts and add the following code:
import { betterAuth } from 'better-auth';
import { prismaAdapter } from '@better-auth/prisma-adapter';
import { magicLink } from 'better-auth/plugins';
import { prisma } from './prisma';
import { env } from './env';

export const auth = betterAuth({
  // Connect to your Neon database via Prisma
  database: prismaAdapter(prisma, { provider: 'postgresql' }),
  // Secret for signing tokens
  secret: env.BETTER_AUTH_SECRET,
  // Base URL for auth callbacks
  baseURL: env.BETTER_AUTH_URL,
  socialProviders: {
    github: {
      clientId: env.GITHUB_CLIENT_ID,
      clientSecret: env.GITHUB_CLIENT_SECRET,
    },
    google: {
      clientId: env.GOOGLE_CLIENT_ID,
      clientSecret: env.GOOGLE_CLIENT_SECRET,
    },
  },
  plugins: [
    magicLink({
      sendMagicLink: async ({ email, url }) => {
        // Dynamically import Resend and send the magic link email
        const { Resend } = await import('resend');
        const resend = new Resend(env.RESEND_API_KEY);
        await resend.emails.send({
          from: 'DaySync <noreply@yourdomain.com>',
          to: email,
          subject: 'Sign in to DaySync',
          html: `<a href="${url}">Click here to sign in</a>`,
        });
      },
    }),
  ],
  // Store sessions in the database so they can be revoked immediately
  session: { strategy: 'database' },
});

What does this code do?

This file is the single source of truth for your auth system. It tells Better Auth to use your Prisma client for database operations, enables GitHub and Google as social sign-in providers, and adds the magic link plugin for passwordless email auth.

The session: { strategy: 'database' } setting stores sessions in PostgreSQL instead of JWTs. This means you can revoke a session instantly (e.g. ban a user and they lose access immediately, not when a token expires).

  • Save src/lib/auth.ts.
Create the auth client and API route

The server config handles the logic, but your React components need a client-side helper to trigger sign-ins. You also need an API route that Better Auth can use to handle callbacks from OAuth providers.

  • Create src/lib/auth-client.ts with the following code:
import { createAuthClient } from 'better-auth/react';
import { magicLinkClient } from 'better-auth/client/plugins';

export const authClient = createAuthClient({
  plugins: [magicLinkClient()],
});

What does this code do?

This creates a client-side auth helper for React. The magicLinkClient() plugin adds the signIn.magicLink() method so your components can trigger magic link emails. Since the app runs on the same domain, you don't need to pass a baseURL.

  • Create the directory src/app/api/auth/[...all]/ and add a route.ts file inside it with this code:
import { auth } from '@/lib/auth';
import { toNextJsHandler } from 'better-auth/next-js';

export const { GET, POST } = toNextJsHandler(auth);

What does this code do?

This catch-all route handler forwards every request to /api/auth/* to Better Auth. It handles OAuth callbacks, session management, and magic link verification automatically.

  • Save both files.

Now generate the database tables Better Auth needs for users, sessions, and accounts.

  • Run the Better Auth CLI to generate the Prisma schema models:
npx auth@latest generate --yes

What does this command do?

The Better Auth CLI reads your src/lib/auth.ts config (it searches src/lib/ by default) and appends the required User, Session, Account, and Verification models to your prisma/schema.prisma file.

  • Push the new schema to your Neon database by running:
npx prisma db push
  • Regenerate the Prisma client so TypeScript knows about the new models:
npx prisma generate
  • Verify the tables were created by running:
npx prisma studio

You should see four new tables in Prisma Studio: User, Session, Account, and Verification. Close Prisma Studio when you're done.

Prisma Studio won't connect?

Make sure your DATABASE_URL in .env.local is correct and that your Neon project is active. Neon scales to zero after 5 minutes of inactivity but resumes automatically.

If you see a connection timeout, wait a moment and try again. Help me connect Prisma Studio to my Neon database.

Set up OAuth providers and Resend

Better Auth is configured to expect credentials from GitHub, Google, and Resend. You now need to create those credentials and add them to your environment.

Start with GitHub:

  • Go to github.com/settings/developers and click **OAuth Apps**, then **New OAuth App**.
  • Fill in the following fields:
  • Click **Register application**.
  • Copy the **Client ID** value.
  • Click **Generate a new client secret** and copy the secret value.
  • Add both to your .env.local file as GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET.

Next, set up Google:

  • Go to the Google Cloud Console.
  • Create a new project if you don't have one, then navigate to **APIs & Services**, then **Credentials**.
  • Click **Create Credentials**, then **OAuth client ID**.
  • Select **Web application** as the application type.
  • Under **Authorized redirect URIs**, add: http://localhost:3000/api/auth/callback/google.
  • Click **Create** and copy the **Client ID** and **Client secret**.
  • Add both to .env.local as GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET.

What about the OAuth consent screen?

Google requires you to configure a consent screen before creating credentials. If prompted, select **External** user type and fill in the required fields (app name, user support email). You can leave most fields blank for development.

Finally, set up Resend for magic link emails:

  • Go to resend.com and create a free account.
  • In your Resend dashboard, navigate to **API Keys** and click **Create API Key**.
  • Copy the key and add it to .env.local as RESEND_API_KEY.

You also need two more env vars that Better Auth itself requires. Add these to .env.local:

  • Set BETTER_AUTH_SECRET to a random string of at least 32 characters. You can generate one by running:
openssl rand -base64 32
  • Set BETTER_AUTH_URL to http://localhost:3000.
  • Save .env.local.

App crashing with a Zod error?

Your env.ts validates all environment variables at import time. If any value is missing or malformed, the app crashes on purpose and names the missing variable. Double-check every key in .env.local matches the schema in src/lib/env.ts.

Help me fix my Zod environment validation error.

Build the sign-in page

With the backend ready, you need a UI where users can choose how to sign in. This is a Client Component because it uses browser-side interactivity (button clicks, form state).

  • Create src/app/auth/signin/page.tsx with the following code:
'use client';
import { authClient } from '@/lib/auth-client';
import { useState } from 'react';

export default function SignInPage() {
  const [email, setEmail] = useState('');
  const [sent, setSent] = useState(false);

  return (
    <main className="flex min-h-screen flex-col items-center justify-center gap-4 p-8">
      <h1 className="text-2xl font-bold">Sign in to DaySync</h1>
      <button
        onClick={() => authClient.signIn.social({ provider: 'github', callbackURL: '/' })}
        className="w-64 rounded bg-gray-900 px-4 py-2 text-white"
      >
        Sign in with GitHub
      </button>
      <button
        onClick={() => authClient.signIn.social({ provider: 'google', callbackURL: '/' })}
        className="w-64 rounded bg-blue-600 px-4 py-2 text-white"
      >
        Sign in with Google
      </button>
      <div className="flex w-64 flex-col gap-2">
        <input
          type="email"
          placeholder="you@example.com"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          className="rounded border px-3 py-2"
        />
        <button
          onClick={async () => {
            await authClient.signIn.magicLink({ email, callbackURL: '/' });
            setSent(true);
          }}
          className="rounded bg-purple-600 px-4 py-2 text-white"
        >
          Send Magic Link
        </button>
        {sent && <p className="text-sm text-green-600">Check your email!</p>}
      </div>
    </main>
  );
}

What does this page do?

The 'use client' directive marks this as a Client Component so it can use React state and event handlers. Each button calls a different method on authClient: signIn.social() for OAuth redirects, and signIn.magicLink() for email-based passwordless auth.

The callbackURL: '/' tells Better Auth where to redirect after successful authentication.

  • Save src/app/auth/signin/page.tsx.
  • Start your dev server if it's not already running:
npm run dev
  • Open http://localhost:3000/auth/signin in your browser.

You should see three sign-in options: a GitHub button, a Google button, and an email input with a "Send Magic Link" button.

  • Test the GitHub sign-in by clicking **Sign in with GitHub**. You'll be redirected to GitHub, and after authorizing, you'll land back on your app's home page.
  • Verify the session was created by opening Prisma Studio:
npx prisma studio

You should see a row in the User table and a row in the Session table. This confirms authentication is working end-to-end.

GitHub sign-in redirects to an error?

Check that your callback URL in the GitHub OAuth App settings is exactly http://localhost:3000/api/auth/callback/github. Even a trailing slash mismatch will cause a failure.

Also confirm GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET in .env.local match what GitHub shows.

Help me debug my GitHub OAuth sign-in error.

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files and that Prisma Studio shows your user and session data.

ⓧ I'd like to double check the full code

import { betterAuth } from 'better-auth';
import { prismaAdapter } from '@better-auth/prisma-adapter';
import { magicLink } from 'better-auth/plugins';
import { prisma } from './prisma';
import { env } from './env';

export const auth = betterAuth({
  database: prismaAdapter(prisma, { provider: 'postgresql' }),
  secret: env.BETTER_AUTH_SECRET,
  baseURL: env.BETTER_AUTH_URL,
  socialProviders: {
    github: {
      clientId: env.GITHUB_CLIENT_ID,
      clientSecret: env.GITHUB_CLIENT_SECRET,
    },
    google: {
      clientId: env.GOOGLE_CLIENT_ID,
      clientSecret: env.GOOGLE_CLIENT_SECRET,
    },
  },
  plugins: [
    magicLink({
      sendMagicLink: async ({ email, url }) => {
        const { Resend } = await import('resend');
        const resend = new Resend(env.RESEND_API_KEY);
        await resend.emails.send({
          from: 'DaySync <noreply@yourdomain.com>',
          to: email,
          subject: 'Sign in to DaySync',
          html: `<a href="${url}">Click here to sign in</a>`,
        });
      },
    }),
  ],
  session: { strategy: 'database' },
});
import { createAuthClient } from 'better-auth/react';
import { magicLinkClient } from 'better-auth/client/plugins';

export const authClient = createAuthClient({
  plugins: [magicLinkClient()],
});
import { auth } from '@/lib/auth';
import { toNextJsHandler } from 'better-auth/next-js';

export const { GET, POST } = toNextJsHandler(auth);
'use client';
import { authClient } from '@/lib/auth-client';
import { useState } from 'react';

export default function SignInPage() {
  const [email, setEmail] = useState('');
  const [sent, setSent] = useState(false);

  return (
    <main className="flex min-h-screen flex-col items-center justify-center gap-4 p-8">
      <h1 className="text-2xl font-bold">Sign in to DaySync</h1>
      <button
        onClick={() => authClient.signIn.social({ provider: 'github', callbackURL: '/' })}
        className="w-64 rounded bg-gray-900 px-4 py-2 text-white"
      >
        Sign in with GitHub
      </button>
      <button
        onClick={() => authClient.signIn.social({ provider: 'google', callbackURL: '/' })}
        className="w-64 rounded bg-blue-600 px-4 py-2 text-white"
      >
        Sign in with Google
      </button>
      <div className="flex w-64 flex-col gap-2">
        <input
          type="email"
          placeholder="you@example.com"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          className="rounded border px-3 py-2"
        />
        <button
          onClick={async () => {
            await authClient.signIn.magicLink({ email, callbackURL: '/' });
            setSent(true);
          }}
          className="rounded bg-purple-600 px-4 py-2 text-white"
        >
          Send Magic Link
        </button>
        {sent && <p className="text-sm text-green-600">Check your email!</p>}
      </div>
    </main>
  );
}
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DIRECT_URL")
}

model User {
  id            String    @id
  name          String
  email         String    @unique
  emailVerified Boolean
  image         String?
  createdAt     DateTime
  updatedAt     DateTime
  sessions      Session[]
  accounts      Account[]
}

model Session {
  id        String   @id
  expiresAt DateTime
  token     String   @unique
  createdAt DateTime
  updatedAt DateTime
  ipAddress String?
  userAgent String?
  userId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model Account {
  id                    String    @id
  accountId             String
  providerId            String
  userId                String
  user                  User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  accessToken           String?
  refreshToken          String?
  idToken               String?
  accessTokenExpiresAt  DateTime?
  refreshTokenExpiresAt DateTime?
  scope                 String?
  password              String?
  createdAt             DateTime
  updatedAt             DateTime
}

model Verification {
  id         String    @id
  identifier String
  value      String
  expiresAt  DateTime
  createdAt  DateTime?
  updatedAt  DateTime?
}

Authentication is locked in. Next up, you'll design the data model for communities and memberships, then build the API routes that let users create and join communities.

Build the Data Model

Authentication is working. Users can sign in, and their sessions are stored in the database. Now you need to give those users something to do.

In this step, you'll design the data structures for communities and memberships, add runtime validation with Zod, and build API routes that follow a clean separation of concerns.

In this step, get ready to:
  • Add Community and Membership models to the Prisma schema.
  • Create Zod validators for community operations.
  • Build a service layer and API routes with proper separation of concerns.
Update the Prisma schema

A community needs to track who created it, a unique invite code for sharing, and a list of members. A membership is the relationship between a user and a community, storing their role (admin or member).

  • Open prisma/schema.prisma in your editor.
  • Inside the User model, find the line that says accounts Account[] and add these two relation fields below it:
  memberships   Membership[]
  communities   Community[]
  • Below the Verification model (after its closing }), add the Community and Membership models:
model Community {
  id          String       @id @default(cuid())
  name        String
  description String?
  inviteCode  String       @unique @default(cuid())
  createdById String
  createdBy   User         @relation(fields: [createdById], references: [id], onDelete: Cascade)
  createdAt   DateTime     @default(now())
  memberships Membership[]
}

model Membership {
  id          String    @id @default(cuid())
  userId      String
  communityId String
  role        String    @default("member")
  joinedAt    DateTime  @default(now())
  user        User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  community   Community @relation(fields: [communityId], references: [id], onDelete: Cascade)

  @@unique([userId, communityId])
}

What does this schema do?

The Community model stores a name, optional description, and a unique inviteCode generated automatically with cuid(). CUIDs are URL-safe, non-sequential, and collision-resistant.

The Membership model is a join table connecting users to communities. The @@unique([userId, communityId]) constraint prevents a user from joining the same community twice. The role field defaults to "member" and gets set to "admin" for the community creator.

Why a join table instead of a JSON array?

Storing members as a JSON array on the Community model would make queries like "which communities is user X in?" expensive and error-prone. A join table lets you query from either direction efficiently and attach per-membership data (role, join date) without parsing JSON.

  • Save prisma/schema.prisma.
  • Push the updated schema to your Neon database by running:
npx prisma db push
  • Regenerate the Prisma client so TypeScript picks up the new models by running:
npx prisma generate

Why db push instead of migrate?

The prisma db push command syncs your schema directly to the database without creating migration files. It is designed for prototyping when your schema is still changing frequently. Once the schema stabilizes across all DaySync projects, you can switch to prisma migrate for versioned, trackable migrations.

  • Confirm the tables were created by running:
npx prisma studio

You should see Community and Membership listed alongside the existing auth tables. Close Prisma Studio when you are done checking.

Seeing an error about schema validation?

Check that the User model has both memberships Membership[] and communities Community[] fields. Prisma requires both sides of a relation to be defined.

Also verify the @@unique([userId, communityId]) line is inside the Membership model's closing brace, not outside it.

Still stuck? Help me fix my Prisma schema validation error

✔️ Awesome, I've got everything!

Great. Double check you have saved prisma/schema.prisma and that npx prisma generate ran without errors.

ⓧ I'd like to double check the full code

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DIRECT_URL")
}

model User {
  id            String       @id
  name          String
  email         String       @unique
  emailVerified Boolean
  image         String?
  createdAt     DateTime
  updatedAt     DateTime
  sessions      Session[]
  accounts      Account[]
  memberships   Membership[]
  communities   Community[]
}

model Session {
  id        String   @id
  expiresAt DateTime
  token     String   @unique
  createdAt DateTime
  updatedAt DateTime
  ipAddress String?
  userAgent String?
  userId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model Account {
  id                    String    @id
  accountId             String
  providerId            String
  userId                String
  user                  User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  accessToken           String?
  refreshToken          String?
  idToken               String?
  accessTokenExpiresAt  DateTime?
  refreshTokenExpiresAt DateTime?
  scope                 String?
  password              String?
  createdAt             DateTime
  updatedAt             DateTime
}

model Verification {
  id         String    @id
  identifier String
  value      String
  expiresAt  DateTime
  createdAt  DateTime?
  updatedAt  DateTime?
}

model Community {
  id          String       @id @default(cuid())
  name        String
  description String?
  inviteCode  String       @unique @default(cuid())
  createdById String
  createdBy   User         @relation(fields: [createdById], references: [id], onDelete: Cascade)
  createdAt   DateTime     @default(now())
  memberships Membership[]
}

model Membership {
  id          String    @id @default(cuid())
  userId      String
  communityId String
  role        String    @default("member")
  joinedAt    DateTime  @default(now())
  user        User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  community   Community @relation(fields: [communityId], references: [id], onDelete: Cascade)

  @@unique([userId, communityId])
}
Create Zod validators

Every piece of data entering your API needs runtime validation. Zod schemas catch invalid input before it reaches the database and provide helpful error messages back to the client.

  • Create the directory src/lib/validators and a new file called community.ts inside it by running:
mkdir -p src/lib/validators && touch src/lib/validators/community.ts
  • Open src/lib/validators/community.ts and add the following validation schemas:
import { z } from 'zod';

// Validates the body of a "create community" request
export const createCommunitySchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters').max(50),
  description: z.string().max(500).optional(),
});

// Validates the body of a "join community" request
export const joinCommunitySchema = z.object({
  inviteCode: z.string().min(1),
});

// Inferred types for use in service functions
export type CreateCommunityInput = z.infer<typeof createCommunitySchema>;
export type JoinCommunityInput = z.infer<typeof joinCommunitySchema>;

What does this code do?

The createCommunitySchema requires a name of at least 2 characters and allows an optional description. The joinCommunitySchema requires a non-empty inviteCode string.

Exporting the inferred types (CreateCommunityInput, JoinCommunityInput) means your service functions get type safety from the same schema definitions. One source of truth for validation and types.

  • Save src/lib/validators/community.ts.

Import errors in your editor?

If your editor shows a red underline on import { z } from 'zod', make sure you installed zod back in Step 1. Run npm list zod to verify it is in your dependencies.

Need help? Why is my Zod import not resolving?

Build the service layer and API routes

Your API routes need to validate input, check authentication, perform business logic, and return responses. Putting all of that in one file gets messy fast.

Instead, you will extract the business logic into a service layer. Routes stay thin: they validate, call a service function, and return JSON. Services are independently testable and reusable by CLI tools or future frontends.

  • Create the directory src/lib/services and a new file called community.ts inside it by running:
mkdir -p src/lib/services && touch src/lib/services/community.ts
  • Open src/lib/services/community.ts and add the community service functions:
import { prisma } from '../prisma';
import type { CreateCommunityInput } from '../validators/community';

// Creates a community and adds the creator as an admin member
export async function createCommunity(input: CreateCommunityInput, userId: string) {
  const community = await prisma.community.create({
    data: {
      name: input.name,
      description: input.description,
      createdById: userId,
      memberships: {
        create: { userId, role: 'admin' },
      },
    },
  });
  return community;
}

// Joins a community via invite code, returning error objects for invalid states
export async function joinCommunity(inviteCode: string, userId: string) {
  const community = await prisma.community.findUnique({ where: { inviteCode } });
  if (!community) return { error: 'not_found' as const };

  const existing = await prisma.membership.findUnique({
    where: { userId_communityId: { userId, communityId: community.id } },
  });
  if (existing) return { error: 'already_member' as const };

  const membership = await prisma.membership.create({
    data: { userId, communityId: community.id, role: 'member' },
  });
  return { membership, community };
}

// Fetches a community with all its members for the dashboard
export async function getCommunityWithMembers(communityId: string) {
  return prisma.community.findUnique({
    where: { id: communityId },
    include: {
      memberships: {
        include: { user: { select: { id: true, name: true, image: true } } },
        orderBy: { joinedAt: 'asc' },
      },
    },
  });
}

What do these service functions do?

The createCommunity function creates a community AND a membership in one transaction. The creator automatically becomes an admin.

The joinCommunity function returns typed error objects ('not_found' or 'already_member') so the route can map them to HTTP status codes. No exceptions for expected failures.

The getCommunityWithMembers function uses Prisma's include to load all members with their profile info in a single query. This powers the community dashboard in the next step.

  • Save src/lib/services/community.ts.

Type error on userId_communityId?

If TypeScript does not recognize userId_communityId as a valid where clause, you likely forgot to run npx prisma generate after updating the schema. Run it now and restart your editor's TypeScript server.

Need help? Why is Prisma not recognizing my compound unique key?

Now wire up the API routes that call these service functions.

  • Create the API route directory and file for community creation by running:
mkdir -p src/app/api/community && touch src/app/api/community/route.ts
  • Open src/app/api/community/route.ts and add the POST handler:
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { createCommunitySchema } from '@/lib/validators/community';
import { createCommunity } from '@/lib/services/community';
import { headers } from 'next/headers';

export async function POST(req: NextRequest) {
  // Check if the user is authenticated
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  // Validate the request body with Zod
  const body = await req.json();
  const result = createCommunitySchema.safeParse(body);
  if (!result.success) {
    return NextResponse.json({ error: result.error.flatten() }, { status: 400 });
  }

  // Delegate to the service layer
  const community = await createCommunity(result.data, session.user.id);
  return NextResponse.json(community, { status: 201 });
}

What does this route do?

This route follows a clear pattern: authenticate, validate, delegate, respond. The auth.api.getSession() call from Better Auth checks for a valid session using the incoming request headers. If validation fails, safeParse returns structured errors without throwing. The actual database work lives in the service.

  • Save src/app/api/community/route.ts.
  • Create the join route directory and file by running:
mkdir -p src/app/api/community/join && touch src/app/api/community/join/route.ts
  • Open src/app/api/community/join/route.ts and add the POST handler:
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { joinCommunitySchema } from '@/lib/validators/community';
import { joinCommunity } from '@/lib/services/community';
import { headers } from 'next/headers';

export async function POST(req: NextRequest) {
  // Check if the user is authenticated
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  // Validate the request body
  const body = await req.json();
  const result = joinCommunitySchema.safeParse(body);
  if (!result.success) {
    return NextResponse.json({ error: result.error.flatten() }, { status: 400 });
  }

  // Delegate to the service and map error types to HTTP status codes
  const outcome = await joinCommunity(result.data.inviteCode, session.user.id);
  if ('error' in outcome) {
    if (outcome.error === 'not_found') return NextResponse.json({ error: 'Invite code not found' }, { status: 404 });
    if (outcome.error === 'already_member') return NextResponse.json({ error: 'Already a member' }, { status: 409 });
  }
  return NextResponse.json(outcome, { status: 200 });
}

What does the join route do?

The join route uses the same authenticate-validate-delegate pattern. The service returns typed error objects, and the route maps them to the correct HTTP status codes: 404 for invalid invite codes, 409 for duplicate memberships.

  • Save src/app/api/community/join/route.ts.
  • Test the create endpoint by running this command (make sure your dev server is running with npm run dev in another terminal):
curl -X POST http://localhost:3000/api/community \
  -H "Content-Type: application/json" \
  -d '{"name": "X"}'

You should see a 400 response with a validation error mentioning that the name must be at least 2 characters.

Getting a 500 error instead of 400 or 401?

Check that src/lib/services/community.ts imports prisma from '../prisma' (two dots, relative path). A common mistake is writing '@/lib/prisma' which works for routes but not for service files depending on your tsconfig paths.

Also verify you ran npx prisma generate after pushing the schema. The Prisma client needs to be regenerated before the service can query the new tables.

Still stuck? Help me debug my community API route returning 500

✔️ Awesome, I've got everything!

Great. Make sure all three new files are saved: src/lib/validators/community.ts, src/lib/services/community.ts, src/app/api/community/route.ts, and src/app/api/community/join/route.ts.

ⓧ I'd like to double check the full code

import { z } from 'zod';

export const createCommunitySchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters').max(50),
  description: z.string().max(500).optional(),
});

export const joinCommunitySchema = z.object({
  inviteCode: z.string().min(1),
});

export type CreateCommunityInput = z.infer<typeof createCommunitySchema>;
export type JoinCommunityInput = z.infer<typeof joinCommunitySchema>;
import { prisma } from '../prisma';
import type { CreateCommunityInput } from '../validators/community';

export async function createCommunity(input: CreateCommunityInput, userId: string) {
  const community = await prisma.community.create({
    data: {
      name: input.name,
      description: input.description,
      createdById: userId,
      memberships: {
        create: { userId, role: 'admin' },
      },
    },
  });
  return community;
}

export async function joinCommunity(inviteCode: string, userId: string) {
  const community = await prisma.community.findUnique({ where: { inviteCode } });
  if (!community) return { error: 'not_found' as const };

  const existing = await prisma.membership.findUnique({
    where: { userId_communityId: { userId, communityId: community.id } },
  });
  if (existing) return { error: 'already_member' as const };

  const membership = await prisma.membership.create({
    data: { userId, communityId: community.id, role: 'member' },
  });
  return { membership, community };
}

export async function getCommunityWithMembers(communityId: string) {
  return prisma.community.findUnique({
    where: { id: communityId },
    include: {
      memberships: {
        include: { user: { select: { id: true, name: true, image: true } } },
        orderBy: { joinedAt: 'asc' },
      },
    },
  });
}
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { createCommunitySchema } from '@/lib/validators/community';
import { createCommunity } from '@/lib/services/community';
import { headers } from 'next/headers';

export async function POST(req: NextRequest) {
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  const body = await req.json();
  const result = createCommunitySchema.safeParse(body);
  if (!result.success) {
    return NextResponse.json({ error: result.error.flatten() }, { status: 400 });
  }

  const community = await createCommunity(result.data, session.user.id);
  return NextResponse.json(community, { status: 201 });
}
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { joinCommunitySchema } from '@/lib/validators/community';
import { joinCommunity } from '@/lib/services/community';
import { headers } from 'next/headers';

export async function POST(req: NextRequest) {
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  const body = await req.json();
  const result = joinCommunitySchema.safeParse(body);
  if (!result.success) {
    return NextResponse.json({ error: result.error.flatten() }, { status: 400 });
  }

  const outcome = await joinCommunity(result.data.inviteCode, session.user.id);
  if ('error' in outcome) {
    if (outcome.error === 'not_found') return NextResponse.json({ error: 'Invite code not found' }, { status: 404 });
    if (outcome.error === 'already_member') return NextResponse.json({ error: 'Already a member' }, { status: 409 });
  }
  return NextResponse.json(outcome, { status: 200 });
}

Your data model and API backend are solid. Next up, you will build the community UI so users can actually create and join communities through the browser.

Build the Community UI

Your API routes and services are battle-tested with Zod validation, auth checks, and proper error codes. The backend is solid.

Now you need pages that let real users interact with those APIs. In this step, you'll build three pages: a form to create communities, an invite page for joining, and a dashboard that shows members. Each page uses a different rendering strategy depending on what it needs.

In this step, get ready to:
  • Build a Create Community form with instant client-side validation.
  • Create an invite page that handles unauthenticated visitors gracefully.
  • Build a community dashboard that displays members and a shareable invite link.
Create the community form page

The create page is a Client Component because it manages form state and needs to respond to user input instantly. It shares the same Zod schema your API route uses, so validation rules stay in sync between the frontend and backend.

  • Create the file src/app/community/create/page.tsx and add the following code:
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { createCommunitySchema } from '@/lib/validators/community';

export default function CreateCommunityPage() {
  const router = useRouter();
  const [name, setName] = useState('');
  const [description, setDescription] = useState('');
  const [errors, setErrors] = useState<Record<string, string[]>>({});
  const [loading, setLoading] = useState(false);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    // Validate on client using the same Zod schema as the API route
    const result = createCommunitySchema.safeParse({ name, description });
    if (!result.success) {
      setErrors(result.error.flatten().fieldErrors);
      return;
    }
    setErrors({});
    setLoading(true);
    // Send validated data to the API route
    const res = await fetch('/api/community', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name, description }),
    });
    setLoading(false);
    if (res.ok) {
      const community = await res.json();
      router.push(`/community/${community.id}`);
    }
  }

  return (
    <main className="flex min-h-screen flex-col items-center justify-center gap-4 p-8">
      <h1 className="text-2xl font-bold">Create a Community</h1>
      <form onSubmit={handleSubmit} className="flex w-80 flex-col gap-3">
        <div>
          <input
            placeholder="Community name"
            value={name}
            onChange={(e) => setName(e.target.value)}
            className="w-full rounded border px-3 py-2"
          />
          {errors.name && <p className="text-sm text-red-500">{errors.name[0]}</p>}
        </div>
        <textarea
          placeholder="Description (optional)"
          value={description}
          onChange={(e) => setDescription(e.target.value)}
          className="rounded border px-3 py-2"
        />
        <button type="submit" disabled={loading} className="rounded bg-blue-600 px-4 py-2 text-white">
          {loading ? 'Creating...' : 'Create Community'}
        </button>
      </form>
    </main>
  );
}

What does this code do?

This page uses the same createCommunitySchema from your validators to check input before it ever reaches the server. If validation fails, errors display instantly without a network request.

On success, fetch() sends the data to POST /api/community and the router redirects to the new community's dashboard. The loading state disables the button during submission to prevent double-clicks.

Why share the Zod schema on client and server?

Client validation is for UX. Server validation is for security. By importing the same schema on both sides, the rules can never drift apart. If you change the minimum name length in one place, both sides update together.

  • Save src/app/community/create/page.tsx.
  • In your browser, navigate to http://localhost:3000/community/create. You should see a form with a name input, a description textarea, and a blue Create Community button.
  • Type a single character (like X) in the name field and click Create Community. You should see a red error message appear instantly without the page reloading.

Form not showing up?

  • Make sure your file is at src/app/community/create/page.tsx (not src/app/community/page.tsx). The create folder is required for the route to map correctly.
  • Check that 'use client' is the very first line of the file. Without it, React hooks like useState will throw an error.
  • Still stuck? Help me debug why my create community page isn't rendering.
Build the invite page

The invite page is a Server Component. It fetches the community from the database before the page even reaches the browser, so visitors see the community name instantly. If they aren't signed in, it redirects them to auth with a callback URL that brings them back.

  • Create the file src/app/invite/[code]/page.tsx and add the following code:
import { prisma } from '@/lib/prisma';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';

export default async function InvitePage({ params }: { params: { code: string } }) {
  // Look up the community by its invite code
  const community = await prisma.community.findUnique({ where: { inviteCode: params.code } });
  if (!community) return <main><p>Invite link not found.</p></main>;

  // Redirect unauthenticated visitors to sign in, then back here
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) redirect(`/auth/signin?callbackURL=/invite/${params.code}`);

  return (
    <main className="flex min-h-screen flex-col items-center justify-center gap-4 p-8">
      <h1 className="text-2xl font-bold">Join {community.name}</h1>
      <form action={async () => {
        'use server';
        const s = await auth.api.getSession({ headers: await headers() });
        if (!s) redirect('/auth/signin');
        await fetch(`${process.env.BETTER_AUTH_URL}/api/community/join`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ inviteCode: params.code }),
        });
        redirect(`/community/${community.id}`);
      }}>
        <button type="submit" className="rounded bg-green-600 px-6 py-2 text-white">Join Community</button>
      </form>
    </main>
  );
}

What does this code do?

  • The page queries Prisma directly (no API call needed) because Server Components run on the server.
  • If the invite code doesn't match any community, visitors see a "not found" message immediately.
  • The callbackURL query parameter ensures that after signing in, the user lands back on this invite page rather than the home page.
  • The form uses a Server Action ('use server') to call the join endpoint and redirect to the community dashboard on success.
  • Save src/app/invite/[code]/page.tsx.
  • To test this, you need an invite code from your database. Open Prisma Studio by running:
npx prisma studio
  • In Prisma Studio, open the Community table and copy the inviteCode value from a community you created earlier.
  • Navigate to http://localhost:3000/invite/YOUR_INVITE_CODE (replacing with your actual code). You should see the community name and a green Join Community button.
  • Try navigating to http://localhost:3000/invite/fake-code-123. You should see "Invite link not found." displayed.

Seeing a 404 instead of your invite page?

  • The folder must be named exactly [code] with square brackets. This tells Next.js to treat it as a dynamic route segment.
  • Make sure the full path is src/app/invite/[code]/page.tsx. Missing the invite parent folder will break the route.
  • Need help? Help me debug why my invite page isn't loading.
Build the community dashboard

The dashboard is where the App Router shines. Because it's a Server Component, it calls your getCommunityWithMembers service directly. No API route needed for reads. The page IS your API.

  • Create the file src/app/community/[id]/page.tsx and add the following code:
import { getCommunityWithMembers } from '@/lib/services/community';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { env } from '@/lib/env';

export default async function CommunityDashboard({ params }: { params: { id: string } }) {
  // Check authentication
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) redirect('/auth/signin');

  // Fetch community data directly from the service layer
  const community = await getCommunityWithMembers(params.id);
  if (!community) redirect('/');

  // Only members can view the dashboard
  const membership = community.memberships.find((m) => m.userId === session.user.id);
  if (!membership) redirect('/');

  // Build the shareable invite link using the app's base URL
  const inviteLink = `${env.BETTER_AUTH_URL}/invite/${community.inviteCode}`;

  return (
    <main className="flex min-h-screen flex-col gap-6 p-8">
      <h1 className="text-3xl font-bold">{community.name}</h1>
      {community.description && <p className="text-gray-600">{community.description}</p>}
      <div>
        <h2 className="text-xl font-semibold">Invite Link</h2>
        <p className="font-mono text-sm">{inviteLink}</p>
      </div>
      <div>
        <h2 className="text-xl font-semibold">Members</h2>
        <ul className="flex flex-col gap-2">
          {community.memberships.map((m) => (
            <li key={m.id} className="flex items-center gap-3">
              {m.user.image && <img src={m.user.image} alt="" className="h-8 w-8 rounded-full" />}
              <span>{m.user.name}</span>
              <span className="text-sm text-gray-500">{m.role}</span>
            </li>
          ))}
        </ul>
      </div>
    </main>
  );
}

What does this code do?

  • Three guards run in sequence: is the user signed in? Does the community exist? Is the user a member? If any check fails, the page redirects away.
  • getCommunityWithMembers is called directly. No fetch() needed because Server Components run on the server where the service already lives.
  • The invite link is built from env.BETTER_AUTH_URL so it points to your production URL after deployment, not localhost.

Why call the service directly instead of an API route?

API routes exist for external clients (forms, mobile apps, third-party services) that need an HTTP boundary. A Server Component already runs on the server, so calling an API route would just add an unnecessary network hop. Import the service, call the function, render the result.

  • Save src/app/community/[id]/page.tsx.
  • Return to http://localhost:3000/community/create in your browser.
  • Enter a community name (at least 2 characters) and click Create Community. You should be redirected to the dashboard showing the community name, your invite link, and yourself listed as an admin member.

Redirected to the home page after creating?

  • Make sure you are signed in. The POST /api/community route returns 401 if there's no session, and the create page doesn't handle that error visibly.
  • Check that the dashboard file is at src/app/community/[id]/page.tsx with square brackets around id. Without the dynamic segment, the redirect from the create page lands on a 404.
  • Need help? Help me figure out why my community dashboard redirects me away.

✔️ Awesome, I've got everything!

Great. Make sure all three files are saved before moving on.

ⓧ I'd like to double check the full code

Here are all three files for this step. Compare them against your code:

'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { createCommunitySchema } from '@/lib/validators/community';

export default function CreateCommunityPage() {
  const router = useRouter();
  const [name, setName] = useState('');
  const [description, setDescription] = useState('');
  const [errors, setErrors] = useState<Record<string, string[]>>({});
  const [loading, setLoading] = useState(false);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const result = createCommunitySchema.safeParse({ name, description });
    if (!result.success) {
      setErrors(result.error.flatten().fieldErrors);
      return;
    }
    setErrors({});
    setLoading(true);
    const res = await fetch('/api/community', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name, description }),
    });
    setLoading(false);
    if (res.ok) {
      const community = await res.json();
      router.push(`/community/${community.id}`);
    }
  }

  return (
    <main className="flex min-h-screen flex-col items-center justify-center gap-4 p-8">
      <h1 className="text-2xl font-bold">Create a Community</h1>
      <form onSubmit={handleSubmit} className="flex w-80 flex-col gap-3">
        <div>
          <input
            placeholder="Community name"
            value={name}
            onChange={(e) => setName(e.target.value)}
            className="w-full rounded border px-3 py-2"
          />
          {errors.name && <p className="text-sm text-red-500">{errors.name[0]}</p>}
        </div>
        <textarea
          placeholder="Description (optional)"
          value={description}
          onChange={(e) => setDescription(e.target.value)}
          className="rounded border px-3 py-2"
        />
        <button type="submit" disabled={loading} className="rounded bg-blue-600 px-4 py-2 text-white">
          {loading ? 'Creating...' : 'Create Community'}
        </button>
      </form>
    </main>
  );
}
import { prisma } from '@/lib/prisma';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';

export default async function InvitePage({ params }: { params: { code: string } }) {
  const community = await prisma.community.findUnique({ where: { inviteCode: params.code } });
  if (!community) return <main><p>Invite link not found.</p></main>;

  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) redirect(`/auth/signin?callbackURL=/invite/${params.code}`);

  return (
    <main className="flex min-h-screen flex-col items-center justify-center gap-4 p-8">
      <h1 className="text-2xl font-bold">Join {community.name}</h1>
      <form action={async () => {
        'use server';
        const s = await auth.api.getSession({ headers: await headers() });
        if (!s) redirect('/auth/signin');
        await fetch(`${process.env.BETTER_AUTH_URL}/api/community/join`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ inviteCode: params.code }),
        });
        redirect(`/community/${community.id}`);
      }}>
        <button type="submit" className="rounded bg-green-600 px-6 py-2 text-white">Join Community</button>
      </form>
    </main>
  );
}
import { getCommunityWithMembers } from '@/lib/services/community';
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { env } from '@/lib/env';

export default async function CommunityDashboard({ params }: { params: { id: string } }) {
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) redirect('/auth/signin');

  const community = await getCommunityWithMembers(params.id);
  if (!community) redirect('/');

  const membership = community.memberships.find((m) => m.userId === session.user.id);
  if (!membership) redirect('/');

  const inviteLink = `${env.BETTER_AUTH_URL}/invite/${community.inviteCode}`;

  return (
    <main className="flex min-h-screen flex-col gap-6 p-8">
      <h1 className="text-3xl font-bold">{community.name}</h1>
      {community.description && <p className="text-gray-600">{community.description}</p>}
      <div>
        <h2 className="text-xl font-semibold">Invite Link</h2>
        <p className="font-mono text-sm">{inviteLink}</p>
      </div>
      <div>
        <h2 className="text-xl font-semibold">Members</h2>
        <ul className="flex flex-col gap-2">
          {community.memberships.map((m) => (
            <li key={m.id} className="flex items-center gap-3">
              {m.user.image && <img src={m.user.image} alt="" className="h-8 w-8 rounded-full" />}
              <span>{m.user.name}</span>
              <span className="text-sm text-gray-500">{m.role}</span>
            </li>
          ))}
        </ul>
      </div>
    </main>
  );
}

Your community system is fully functional. Users can create communities, share invite links, and view their members. Next up, you'll deploy the whole thing to Vercel so other people can actually use those invite links from their own devices.

Deploy to Vercel

Your app works locally. Auth is configured, communities can be created and joined, and the database is live on Neon.

Now it is time to put it on the internet. Vercel provides zero-config deployment for Next.js apps. You will push your code to GitHub, import the repo into Vercel, and have a live URL in minutes.

In this step, get ready to:
  • Push your code to a GitHub repository.
  • Deploy your app to Vercel and configure environment variables.
  • Update your OAuth callback URLs for the live domain.
Push your code to GitHub

Vercel deploys from a Git repository. You need your code on GitHub so Vercel can pull it and build your app automatically on every push.

  • Open GitHub in your browser and sign in.
  • Click the + icon in the top right corner, then click New repository.
  • Name your repository daysync (or any name you prefer).
  • Leave Initialize this repository with a README unchecked. Your local project already has files.
  • Click Create repository.
  • Copy the HTTPS URL shown on the Quick Setup page (it looks like https://github.com/your-username/daysync.git).
  • Back in your terminal, link your local repo to GitHub and push your code by running these commands:
git remote add origin [[GITHUB_REPO_URL="https://github.com/your-username/daysync.git"]]
git push -u origin main

What do these commands do?

  • git remote add origin connects your local repo to the GitHub repo you just created.
  • git push -u origin main uploads all your commits to GitHub. The -u flag sets origin main as the default so future pushes only need git push.
  • Refresh the GitHub page in your browser. You should see all your project files listed.

Seeing "remote origin already exists"?

If you already added a remote earlier, replace the git remote add command with:

git remote set-url origin [[GITHUB_REPO_URL="https://github.com/your-username/daysync.git"]]

Then run git push -u origin main again.

Help me troubleshoot pushing to GitHub

Deploy on Vercel and add environment variables

Vercel can import your GitHub repo and deploy it automatically. During the import process, you will add all the environment variables your app needs.

  • Open vercel.com in your browser.
  • Click Sign Up (or Log In if you already have an account). Sign in with your GitHub account for the smoothest experience.
  • On the dashboard, click Add New... then Project.
  • Find your daysync repository in the list and click Import.

Before deploying, expand the Environment Variables section on the configuration page. You need to add all 9 variables from your .env.local file.

  • For each variable below, enter the Name and Value, then click Add:
  • DATABASE_URL – your Neon pooled connection string. The hostname must contain -pooler (e.g. ep-cool-name-123456-pooler.us-east-2.aws.neon.tech). This is the same value from your local .env.local.
  • DIRECT_URL – your Neon direct connection string (no -pooler in the hostname). Same value from .env.local.
  • BETTER_AUTH_SECRET – same value from your local .env.local.
  • BETTER_AUTH_URL – set this to https:// followed by your Vercel project URL (visible at the top of the import page, e.g. https://daysync-abc123.vercel.app). You will update this after your first deploy if the URL is not shown yet.
  • GITHUB_CLIENT_ID – same value from .env.local.
  • GITHUB_CLIENT_SECRET – same value from .env.local.
  • GOOGLE_CLIENT_ID – same value from .env.local.
  • GOOGLE_CLIENT_SECRET – same value from .env.local.
  • RESEND_API_KEY – same value from .env.local.

Why does DATABASE_URL need -pooler?

Vercel runs your app as serverless functions. Each request can spin up a new function instance. Without connection pooling, each instance opens its own database connection and you quickly exhaust Neon's connection limit.

The pooled connection string routes through PgBouncer, which shares a small number of real connections across many function instances. DIRECT_URL bypasses the pooler and is only used by Prisma for migrations and introspection.

  • After adding all 9 variables, click Deploy.
  • Wait for the build to complete. Vercel will show a success screen with your live URL (e.g. https://daysync-abc123.vercel.app).

Note your Vercel URL

Copy your live URL now. You will need it in the next substep to update your OAuth callback URLs. If BETTER_AUTH_URL was not set correctly before deploy, go to your Vercel project's Settings tab, then Environment Variables, update it to your live URL, and redeploy.

Build failed?

The most common cause is a missing or misspelled environment variable. Vercel's build log will show a Zod validation error naming the missing variable. Double-check each variable name matches your .env.example exactly.

Help me debug my Vercel build failure

Update OAuth callback URLs for production

Your GitHub and Google OAuth apps still point their callback URLs to localhost:3000. OAuth providers only redirect to registered URLs, so sign-in will fail on your live domain until you update them.

  • Open GitHub in your browser.
  • Click your profile photo in the top right, then click Settings.
  • In the left sidebar, scroll down and click Developer settings.
  • Click OAuth Apps, then click the name of your DaySync OAuth app.
  • Update the Authorization callback URL field to:
https://[[VERCEL_APP_URL="your-app.vercel.app"]]/api/auth/callback/github
  • Click Update application.

Now update the Google OAuth client.

  • Open the Google Cloud Console.
  • Navigate to APIs & Services, then Credentials.
  • Click the OAuth 2.0 Client ID you created for DaySync.
  • Under Authorized redirect URIs, add a new URI:
https://[[VERCEL_APP_URL="your-app.vercel.app"]]/api/auth/callback/google
  • You can keep the localhost entry for local development or remove it.
  • Click Save.

Why keep the localhost callback?

Google allows multiple redirect URIs on a single OAuth client. Keeping http://localhost:3000/api/auth/callback/google means local development still works without switching between clients.

GitHub only allows one callback URL per OAuth app, so local dev will need you to temporarily swap it back if you sign in locally.

Test that everything works on your live URL.

  • Open your Vercel URL in a new browser tab (or incognito window).
  • Navigate to /auth/signin.
  • Click Sign in with GitHub. You should be redirected to GitHub, then back to your app.
  • Sign out (or open another incognito window) and try Sign in with Google.
  • Try the magic link flow by entering your email. Check your inbox for the sign-in email from Resend.
  • Create a community, then copy the invite link and open it in a different browser or device to confirm it works.

OAuth redirects back to localhost?

This means the callback URL was not updated correctly. Double-check that the URL in your GitHub OAuth App or Google OAuth Client matches your Vercel domain exactly, including https:// (not http://).

Also verify that BETTER_AUTH_URL in your Vercel environment variables is set to the full https:// URL of your deployment. If you update it, you must redeploy for the change to take effect.

Help me fix my OAuth callback configuration

Your app is live. Anyone with the invite link can sign up and join your community from anywhere in the world.

Secret mission

Add Role-Based Access Control

Your community system lets anyone see everything. In a real app, only admins should be able to remove members and regenerate invite links. Can you add server-enforced authorization that never trusts the client?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely on free tiers, so there are no ongoing costs.

Resources you used:

  • Neon PostgreSQL database (free tier, 0.5 GB storage, scales to zero when idle).
  • Vercel deployment (free tier, auto-deploys on git push).
  • GitHub OAuth App (free).
  • Google OAuth Client (free).
  • Resend account (free tier, 100 emails/day).
  • Local project folder daysync on your Desktop.

Keep everything running

No action needed. Choose this if you're still actively building or plan to continue with Project 2. Everything is free tier.

  • Neon scales your database to zero automatically after 5 minutes of inactivity. It resumes in a few hundred milliseconds when the next request arrives.
  • Vercel keeps your deployment live at your .vercel.app URL. It redeploys automatically when you push to GitHub.
  • GitHub OAuth App, Google OAuth Client, and Resend account remain configured and ready.
  • Your local daysync folder stays on your Desktop.

Pause - I'll come back to this later

Free up local resources while keeping cloud services intact. Since everything runs on free tiers, there is nothing to pause on the cloud side.

  • Neon auto-suspends your compute after 5 minutes of inactivity. No manual action needed.
  • Vercel keeps your deployment live. No manual action needed.
  • If your local dev server is still running, press Ctrl+C in the terminal to stop it.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below to fully clean up.

  • Delete the Vercel project: open the Vercel dashboard, select your DaySync project, click Settings in the sidebar, scroll to the bottom of the General page, and click Delete in the Delete Project section. Confirm by entering the project name, then click Continue.
  • Delete the Neon database: open the Neon Console, select your DaySync project, click Settings, then click Delete. This permanently removes your database and all data.
  • Delete the GitHub OAuth App: go to GitHub, click your profile picture, then Settings. In the left sidebar, click Developer settings, then OAuth apps. Select your DaySync app, click Advanced in the left sidebar, then click Delete application. Confirm by clicking Delete this OAuth Application.
  • Delete the Google OAuth Client: open the Google Cloud Console, go to APIs & Services then Credentials. Find your DaySync OAuth Client ID, click the delete icon next to it, and confirm.
  • Delete your Resend API key: log in to Resend, go to API Keys, and revoke or delete the key you created for DaySync.
  • Delete the local project folder by running:
cd ~/Desktop
rm -rf daysync

Windows users

If you are on Windows, use this command instead:

cd ~\Desktop
Remove-Item -Recurse -Force daysync

That's a Wrap!

That's a Wrap!

Nice work. You just built DaySync, a deployed, authenticated community platform with proper full-stack architecture from day one.

You've learned how to:

  • Deploy a full-stack Next.js + TypeScript application to Vercel with three working auth methods (GitHub OAuth, Google OAuth, email magic link) via Better Auth.
  • Design a PostgreSQL schema with Prisma on Neon and build Zod-validated API routes with proper separation of concerns (routes to services to database).
  • Create an invite-link community system where users can create communities, generate shareable links, and others can join.
  • Secret Mission: Add role-based access control with admin-only actions for removing members and regenerating invite links.

Ready to quiz yourself?