Build a Link Hub with Next.js

Build a full stack link-in-bio app with auth, a database, and live deployment.

Introduction

30 Second Summary

You have a portfolio, a GitHub, a blog, a LinkedIn, and maybe a dozen other profiles scattered across the internet. What if you had one link that showed them all?

In this project, you will build your own "link-in-bio" web app (like Linktree) from scratch. You will sign in with GitHub, manage your links from a dashboard, and share a public profile page with anyone.

What You'll Build

A full stack link hub built with Next.js, Prisma, PostgreSQL, Auth.js, and deployed to Vercel.

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

  • A working GitHub OAuth sign-in flow that authenticates users without passwords.
  • A protected dashboard for adding and deleting links stored in a PostgreSQL database.
  • A live deployed app on Vercel with a public profile page anyone can visit.
  • Secret Mission: Add drag-and-drop link reordering so users can arrange their links in any order.

Are there any prerequisites?

You should have intermediate Node.js knowledge and be comfortable using the terminal. A basic understanding of HTTP requests and JSON is helpful.

You will need VS Code, Node.js, and a GitHub account before starting. Everything else (Neon, Vercel) is set up during the project for free.

Before We Start

Before We Start...

Set Up Your Project and Tools

Before building any features, your Link Hub needs a solid foundation. You need a project skeleton, a cloud database to store your data, and the right packages installed.

In this step, you'll scaffold a Next.js app, set up a free cloud PostgreSQL database on Neon, and install the dependencies that power authentication and database access.

In this step, get ready to:
  • Create a Next.js 16 project with TypeScript and Tailwind CSS.
  • Set up a free Neon PostgreSQL database.
  • Install Prisma and Auth.js dependencies and configure project files.
Create your Next.js project

Next.js combines your frontend and backend into a single project. Instead of managing two separate servers, you get one unified codebase with built-in routing, API handling, and server-side rendering.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your Next.js project by running this command:
npx create-next-app@latest link-hub

What will the terminal ask me?

The CLI will ask: "Would you like to use src/ directory?" Select Yes.

For all other prompts (TypeScript, ESLint, Tailwind CSS, App Router, Turbopack, import alias), accept the defaults by pressing Enter.

Once the installation finishes, you should see a "Success!" message in your terminal with your new link-hub project ready.

Seeing 'command not found' for npx?

This means Node.js is not installed or not in your PATH. Run node --version to check if Node.js is available. You need Node.js 20.9 or higher for Next.js 16.

help me install Node.js for my operating system

  • Move into your new project folder by running:
cd link-hub
  • Open the project in VS Code by running:
code .

VS Code didn't open?

If code . doesn't work, open VS Code manually. Press Cmd+Space (macOS) or the Windows key (Windows), type VS Code, and press Enter. Then click File > Open Folder and select the link-hub folder on your Desktop.

Set up your Neon database

Your Link Hub needs a database to store user accounts and links. Neon provides a free cloud PostgreSQL database that requires no installation or maintenance on your end.

  • Open your browser and go to console.neon.tech/signup.
  • Sign up for a free account using your GitHub, Google, or email.
  • Once signed in, click Create Project.
  • Enter link-hub as your project name.
  • Leave the region and PostgreSQL version at their defaults.
  • Click Create Project to finish.

Neon creates your database automatically and shows you a connection string on the project dashboard.

  • On the project dashboard, find the Connection string section.
  • Click the copy icon next to the connection string. It looks like postgresql://username:password@ep-something.region.aws.neon.tech/neondb?sslmode=require.
  • Paste it somewhere safe (a notes app or text file) for now. You'll add it to your project in the next substep.

Why Neon instead of a local database?

Running PostgreSQL locally requires installation and configuration that varies by operating system. Neon gives you a production-ready database in seconds with a generous free tier (0.5 GB storage, 30 projects).

When you deploy to Vercel later, your app will already be connected to the same cloud database. No migration needed.

Install dependencies and configure Prisma

Prisma is an ORM (Object-Relational Mapping) that lets you interact with your database using TypeScript instead of raw SQL. Auth.js handles user authentication so you don't have to build login flows from scratch.

  • In your VS Code terminal, install the project dependencies by running this command:
npm install prisma @prisma/client next-auth@beta @auth/prisma-adapter

What are these packages?

  • prisma is the CLI tool that generates database migrations and the client library.
  • @prisma/client is the auto-generated library your app uses to query the database.
  • next-auth@beta is Auth.js v5, the authentication library built for Next.js.
  • @auth/prisma-adapter connects Auth.js to your Prisma database so user accounts are stored automatically.

You should see a success message once the packages finish installing.

Seeing permission errors or install failures?

Make sure you're running the command inside the link-hub folder. Run pwd to check your current directory. It should end with /link-hub.

help me fix npm install errors in my Next.js project

  • Initialize Prisma in your project by running:
npx prisma init --datasource-provider postgresql

What did this create?

This command created two things: a prisma/schema.prisma file where you'll define your database tables, and a .env file for storing your database connection string securely.

  • Confirm the files were created by running:
ls prisma/.

You should see schema.prisma in the output.

  • In VS Code, open the .env file in the root of your link-hub folder.
  • Find the line that says:
DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"
  • Replace the entire placeholder value with your Neon connection string you copied earlier, so the line looks like this:
DATABASE_URL="[[NEON_CONNECTION_STRING="your Neon connection string from the dashboard"]]"
  • Save the .env file.

Why use an .env file?

The .env file keeps sensitive values like database passwords out of your code. It's already listed in .gitignore so it won't be pushed to GitHub.

Now let's create a helper file that gives your app a single, reusable connection to the database.

  • In VS Code, expand the src folder in the Explorer sidebar.
  • Right-click the src folder and select New Folder.
  • Name the folder lib.
  • Right-click the new lib folder and select New File.
  • Name the file prisma.ts.
  • Paste the following code into src/lib/prisma.ts:
import { PrismaClient } from "@prisma/client";

// Store the Prisma client on globalThis to prevent creating multiple connections in development
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };

// Reuse the existing client or create a new one
export const prisma = globalForPrisma.prisma || new PrismaClient();

// In development, save the client to globalThis so hot reloads don't create new connections
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

What does this file do?

Next.js restarts your code frequently during development (hot reloading). Without this file, every restart would create a new database connection until you run out.

This singleton pattern stores one shared Prisma client on globalThis so all hot reloads reuse the same connection.

  • Save src/lib/prisma.ts.

Next, let's update the two app files that create-next-app generated with the boilerplate code you don't need.

  • Open src/app/layout.tsx in VS Code.
  • Select all the existing content (Cmd+A on macOS, Ctrl+A on Windows) and replace it with the following:
import type { Metadata } from "next";
import "./globals.css";

// App-wide metadata shown in the browser tab
export const metadata: Metadata = {
  title: "Link Hub",
  description: "Your personal link-in-bio page",
};

// Root layout wraps every page in the app
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

What does this file do?

The layout file is the outermost wrapper for your entire app. Every page renders inside this layout's <body> tag.

We removed the default Geist font imports and demo styles to keep things clean. Tailwind CSS (imported via globals.css) handles all styling from here.

  • Save src/app/layout.tsx.
  • Now open src/app/page.tsx.
  • Select all the existing content and replace it with the following:
import { auth } from "../../auth";
import { redirect } from "next/navigation";

// Home page redirects users based on their login status
export default async function Home() {
  const session = await auth();
  if (session?.user) redirect("/dashboard");
  redirect("/login");
}

What does this file do?

This is the home page of your app (the / route). Instead of showing content, it checks if the user is logged in.

Logged-in users get redirected to /dashboard. Everyone else goes to /login. This pattern keeps your app feeling seamless.

This file will show an error for now

The import from "../../auth" references a file that doesn't exist yet. You'll create auth.ts in Step 3 when you set up authentication. The error will disappear then.

  • Save src/app/page.tsx.

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Here are the files you should have at this point. Compare them with your code in VS Code.

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

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
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;
import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "Link Hub",
  description: "Your personal link-in-bio page",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}
import { auth } from "../../auth";
import { redirect } from "next/navigation";

export default async function Home() {
  const session = await auth();
  if (session?.user) redirect("/dashboard");
  redirect("/login");
}

Your project skeleton is ready with a database connection and the right packages installed. Next up, you'll define the database tables that store users and their links.

Design Your Database Schema

Your project is set up, and your database connection is ready. Now you need to tell the database what data to store.

Every app needs a blueprint for its data. In this step, you will define that blueprint using Prisma's schema language. Your Link Hub needs four types of data: users, their OAuth accounts, their sessions, and their links.

In this step, get ready to:
  • Define User, Account, Session, and Link models in your Prisma schema.
  • Verify your database connection is configured correctly.
  • Run your first migration to create real tables in your Neon database.
Define your data models

A Prisma schema defines your data as models. Each model becomes a table in your database. Your Link Hub needs four models that work together.

Why four models?

The User model stores who signed up. The Account and Session models are required by Auth.js to manage OAuth sign-ins and active sessions.

The Link model is your app's custom data. It stores the actual links each user adds to their profile.

  • In VS Code, open prisma/schema.prisma from the Explorer sidebar.
  • Below the existing datasource db block, add the User model:
model User {
  id            String    @id @default(cuid())
  name          String?
  email         String?   @unique
  emailVerified DateTime?
  image         String?
  username      String?   @unique
  accounts      Account[]
  sessions      Session[]
  links         Link[]
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt
}

What does this model do?

  • Each field has a name, a type, and optional attributes like @unique or @default.
  • The ? after a type means the field is optional (it can be empty).
  • The username field is custom. Auth.js does not provide one by default, but you need it for your public profile URL (/username).
  • The accounts, sessions, and links fields are relations. They connect this User to rows in other tables.
  • Below the User model, add the Account and Session models required by Auth.js:
model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String?
  access_token      String?
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String?
  session_state     String?
  user              User    @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

What do these models do?

The Account model stores OAuth provider details (like GitHub tokens). The @@unique([provider, providerAccountId]) constraint ensures one GitHub account can only link to one user.

The Session model tracks active sign-ins. The onDelete: Cascade means if a user is deleted, their accounts and sessions are automatically removed too.

  • Below the Session model, add the Link model:
model Link {
  id        String   @id @default(cuid())
  title     String
  url       String
  order     Int      @default(0)
  userId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  createdAt DateTime @default(now())
}

What does the Link model do?

  • Each link has a title (what the user sees) and a url (where it goes).
  • The order field controls the display position of each link. It defaults to 0 for now.
  • The userId field connects each link to the user who created it. This is a foreign key relationship.
  • Save prisma/schema.prisma.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete prisma/schema.prisma file. Make sure your file matches this exactly:

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

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

model User {
  id            String    @id @default(cuid())
  name          String?
  email         String?   @unique
  emailVerified DateTime?
  image         String?
  username      String?   @unique
  accounts      Account[]
  sessions      Session[]
  links         Link[]
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt
}

model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String?
  access_token      String?
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String?
  session_state     String?
  user              User    @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model Link {
  id        String   @id @default(cuid())
  title     String
  url       String
  order     Int      @default(0)
  userId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  createdAt DateTime @default(now())
}
Verify your database connection

Your schema defines the shape of your data. But Prisma also needs to know where to send that data. This is handled by the datasource block at the top of your schema file.

  • Look at the top of your prisma/schema.prisma file. You should see this block:
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

What does this configuration mean?

The provider = "postgresql" tells Prisma you are using a PostgreSQL database (which is what Neon provides).

The url = env("DATABASE_URL") tells Prisma to read the connection string from your .env file. This keeps your database credentials out of your code.

  • Open your .env file and confirm the DATABASE_URL line contains your Neon connection string (it should start with postgresql://).

DATABASE_URL is empty or missing?

Go back to your Neon dashboard, find your project, and copy the connection string from the **Connection Details** panel. Paste it as the value for DATABASE_URL in your .env file.

help me find my Neon connection string

Run your first migration

Your schema exists in code, but your Neon database is still empty. A migration reads your schema and creates the actual tables in the database.

  • In your VS Code terminal, run the migration command:
npx prisma migrate dev --name init

What does this command do?

  • Prisma compares your schema to the current database state (which is empty).
  • It generates a SQL migration file that creates your four tables.
  • It runs that SQL against your Neon database to create the tables.
  • It regenerates the Prisma client so your TypeScript code can use the new models with full type safety.

You should see output that includes Your database is now in sync with your schema and a list of the created migration files.

Seeing a connection error?

Make sure your .env file is saved and the DATABASE_URL value has no extra spaces or line breaks. The URL must start with postgresql:// and end with ?sslmode=require.

If you see Can't reach database server, your Neon project may be suspended. Go to your Neon dashboard and check that the project is active.

help me fix my Prisma migration error

  • Confirm the migration created a new folder inside prisma/migrations/ by running:
ls prisma/migrations/

You should see a folder with a timestamp and the name init (something like 20260511120000_init).

Your database is live and structured. Next up, you will set up GitHub OAuth so users can actually sign in and start adding links.

Set Up GitHub OAuth Authentication

Your database is ready with tables for users, accounts, and links. Now you need a way for people to actually sign in to your app.

In this step, you'll set up OAuth authentication using GitHub. Instead of building registration forms and password systems, users will click one button and sign in with their existing GitHub account.

In this step, get ready to:
  • Create a GitHub OAuth App and store the credentials in your project.
  • Configure Auth.js v5 with the GitHub provider and JWT sessions.
  • Create the route handler, middleware, and login page.
Create a GitHub OAuth App

For GitHub to trust your app and let users sign in, you need to register your app with GitHub. This gives you a Client ID and Client Secret that your app uses to communicate with GitHub's authentication servers.

  • Open GitHub in your browser and click your profile photo in the top right corner.
  • Click Settings.
  • Scroll down to the bottom of the left sidebar and click Developer settings.
  • Click OAuth Apps in the left sidebar.
  • Click New OAuth App.

Fill in the registration form with these details:

  • Set Application name to Link Hub.
  • Set Homepage URL to http://localhost:3000.
  • Set Authorization callback URL to http://localhost:3000/api/auth/callback/github.
  • Click Register application.

What is a callback URL?

After a user signs in with GitHub, GitHub needs to know where to send them back. The callback URL is that return address. Auth.js listens at this specific path to complete the sign-in process.

Now you need to copy the credentials GitHub gives you.

  • On the app page, find the Client ID value near the top. Copy it.
  • Click Generate a new client secret.
  • Copy the secret value immediately. You will not be able to see it again after leaving this page.

Copy your secret now

GitHub only shows the client secret once. If you navigate away without copying it, you'll need to generate a new one.

  • Open your .env file in VS Code (inside the link-hub folder).
  • Add the GitHub credentials and an auth secret by adding these lines below your DATABASE_URL:
AUTH_SECRET="your-generated-secret-here"
GITHUB_ID="your-github-client-id"
GITHUB_SECRET="your-github-client-secret"
  • Replace your-github-client-id with the Client ID you copied from GitHub.
  • Replace your-github-client-secret with the Client Secret you copied from GitHub.

For AUTH_SECRET, you need to generate a random value. Generate one by running this command in your terminal:

openssl rand -base64 32
  • Copy the output and paste it as the value for AUTH_SECRET in your .env file.
  • Save .env.

What is AUTH_SECRET?

Auth.js uses this secret to encrypt JWT tokens and sign cookies. It should be a random string that only your server knows. Without it, Auth.js refuses to start.

Getting 'openssl' is not recognized?

On Windows, openssl may not be available in PowerShell. You can run npx auth secret instead, which generates the value for you.

help me generate an AUTH_SECRET for my Auth.js project

Configure Auth.js with the GitHub provider

Auth.js v5 uses a single configuration file at the project root. This file defines which authentication providers you support, how sessions work, and how to connect to your database.

  • Create a new file called auth.ts at the root of your link-hub folder (the same level as package.json, not inside src/).
  • Paste the following code into auth.ts:
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";

export const { handlers, auth, signIn, signOut } = NextAuth({
  // Connect Auth.js to your database via Prisma
  adapter: PrismaAdapter(prisma),
  providers: [
    GitHub({
      clientId: process.env.GITHUB_ID!,
      clientSecret: process.env.GITHUB_SECRET!,
    }),
  ],
  // Use JWT tokens instead of database sessions
  session: {
    strategy: "jwt",
  },
  // Attach the user ID to the token and session
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.id = user.id;
      }
      return token;
    },
    async session({ session, token }) {
      if (session.user && token.id) {
        session.user.id = token.id as string;
      }
      return session;
    },
  },
  // Redirect to our custom login page
  pages: {
    signIn: "/login",
  },
});

What does this code do?

PrismaAdapter(prisma) tells Auth.js to store user accounts in your Neon database using Prisma. When someone signs in for the first time, Auth.js automatically creates a User and Account record.

strategy: "jwt" means sessions are stored as encrypted tokens in cookies instead of in the database. This is simpler for serverless deployments on Vercel because it avoids a database query on every request.

The callbacks ensure your user's database ID is available in the session object. Without them, you would not be able to look up the current user's links.

  • Save auth.ts.

Seeing a red underline on the import?

If VS Code shows an error on @/lib/prisma, make sure your tsconfig.json has a paths alias for @/*. The create-next-app command sets this up automatically, but if it is missing, try restarting VS Code.

help me fix import path errors in my Auth.js config

✔️ Awesome, I've got everything!

Great. Make sure you saved auth.ts at the project root, not inside the src/ folder.

ⓧ I'd like to double check the full code

import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  providers: [
    GitHub({
      clientId: process.env.GITHUB_ID!,
      clientSecret: process.env.GITHUB_SECRET!,
    }),
  ],
  session: {
    strategy: "jwt",
  },
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.id = user.id;
      }
      return token;
    },
    async session({ session, token }) {
      if (session.user && token.id) {
        session.user.id = token.id as string;
      }
      return session;
    },
  },
  pages: {
    signIn: "/login",
  },
});
Create the route handler, middleware, and login page

Auth.js needs an API endpoint to handle the OAuth flow. It also needs middleware to protect routes that only signed-in users should access. Finally, you need a login page where users land when they are not authenticated.

  • Create the folder path src/app/api/auth/[...nextauth]/ inside your project.
  • Create a file called route.ts inside that folder and paste this code:
import { handlers } from "../../../../../auth";

export const { GET, POST } = handlers;

What does this file do?

This is the API Route Handler that Auth.js uses to process sign-in and sign-out requests. The [...nextauth] folder name is a catch-all route that matches any path under /api/auth/. It exports GET and POST so Next.js knows how to handle both HTTP methods.

  • Save route.ts.
  • Now create a file called middleware.ts at the root of your link-hub folder (same level as auth.ts and package.json).
  • Paste the following code into middleware.ts:
import { auth } from "./auth";
import { NextResponse } from "next/server";

// Wrap the auth helper so we can check login status on each request
export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isOnDashboard = req.nextUrl.pathname.startsWith("/dashboard");

  // Redirect unauthenticated users away from the dashboard
  if (isOnDashboard && !isLoggedIn) {
    return NextResponse.redirect(new URL("/login", req.url));
  }

  return NextResponse.next();
});

// Only run this middleware on dashboard routes
export const config = {
  matcher: ["/dashboard/:path*"],
};

What does this code do?

matcher: ["/dashboard/:path*"] tells Next.js to only run this middleware on URLs that start with /dashboard. Every other page loads without this check.

Inside the function, req.auth contains the user's session if they are signed in. If a visitor tries to access the dashboard without signing in, they get redirected to /login.

  • Save middleware.ts.

Middleware not working later?

The middleware.ts file must be at the project root (next to package.json), not inside src/. Next.js only recognizes middleware in this location.

help me fix my middleware file location

✔️ Awesome, I've got everything!

Great. Make sure middleware.ts is at the project root alongside auth.ts.

ⓧ I'd like to double check the full code

import { auth } from "./auth";
import { NextResponse } from "next/server";

export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isOnDashboard = req.nextUrl.pathname.startsWith("/dashboard");

  if (isOnDashboard && !isLoggedIn) {
    return NextResponse.redirect(new URL("/login", req.url));
  }

  return NextResponse.next();
});

export const config = {
  matcher: ["/dashboard/:path*"],
};

The last piece is the login page itself. This is what users see when they visit /login or get redirected by the middleware.

  • Create the folder src/app/login/.
  • Create a file called page.tsx inside it and paste this code:
import { signIn } from "../../../auth";

export default function LoginPage() {
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="bg-white p-8 rounded-lg shadow-md w-full max-w-sm text-center">
        <h1 className="text-2xl font-bold mb-2">Link Hub</h1>
        <p className="text-gray-600 mb-6">Sign in to manage your links</p>
        {/* Server Action: calls Auth.js signIn on the server */}
        <form
          action={async () => {
            "use server";
            await signIn("github", { redirectTo: "/dashboard" });
          }}
        >
          <button
            type="submit"
            className="w-full bg-gray-900 text-white py-3 px-4 rounded-md hover:bg-gray-800 transition-colors"
          >
            Sign in with GitHub
          </button>
        </form>
      </div>
    </div>
  );
}

What does this code do?

The form's action uses a Server Action (indicated by "use server"). When the user clicks the button, the sign-in logic runs on the server, not in the browser. This keeps your auth credentials safe.

signIn("github", { redirectTo: "/dashboard" }) triggers the GitHub OAuth flow. After successful authentication, the user lands on the dashboard.

  • Save page.tsx.
  • Start your dev server by running this command in your terminal:
npm run dev
  • Open http://localhost:3000/login in your browser.
  • You should see a white card centered on the page with the heading "Link Hub" and a dark "Sign in with GitHub" button.

Seeing a compile error instead of the login page?

Check that auth.ts is at the project root. The import path ../../../auth in login/page.tsx goes up three directories from src/app/login/ to reach the project root.

Also confirm that your .env file has all four variables: DATABASE_URL, AUTH_SECRET, GITHUB_ID, and GITHUB_SECRET. Auth.js will throw an error if any are missing.

help me fix my login page compile error

✔️ Awesome, I've got everything!

Great. Your authentication system is fully wired up and the login page is rendering.

ⓧ I'd like to double check the full code

import { signIn } from "../../../auth";

export default function LoginPage() {
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="bg-white p-8 rounded-lg shadow-md w-full max-w-sm text-center">
        <h1 className="text-2xl font-bold mb-2">Link Hub</h1>
        <p className="text-gray-600 mb-6">Sign in to manage your links</p>
        <form
          action={async () => {
            "use server";
            await signIn("github", { redirectTo: "/dashboard" });
          }}
        >
          <button
            type="submit"
            className="w-full bg-gray-900 text-white py-3 px-4 rounded-md hover:bg-gray-800 transition-colors"
          >
            Sign in with GitHub
          </button>
        </form>
      </div>
    </div>
  );
}

Why JWT instead of database sessions?

Database sessions store login state in a table and query it on every request. JWT sessions store an encrypted token in the browser's cookies instead. For serverless platforms like Vercel, JWT avoids an extra database round-trip on every page load. This makes your app faster and simpler to deploy.

Authentication is ready. In the next step, you'll build the dashboard where signed-in users can add and manage their links.

Build the Dashboard

Your authentication system is in place. Users can sign in with GitHub and the middleware protects the dashboard route.

Now you need to build the actual dashboard page where users manage their links. This involves creating a protected page that reads from the database, plus form components that write to it.

In this step, get ready to:
  • Create a protected dashboard page that displays the user's links.
  • Build a form component that adds new links using a Server Action.
  • Add a delete button that removes links using a Server Action.
Create the protected dashboard page

The dashboard is a Server Component that runs entirely on the server. It checks the user's session, fetches their links from the database, and renders the page before sending it to the browser.

  • In your src/app/ folder, create a new folder called dashboard.
  • Inside src/app/dashboard/, create a new file called page.tsx and paste the following code:
import { auth } from "../../../auth";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { AddLinkForm } from "./add-link-form";
import { LinkItem } from "./link-item";

export default async function DashboardPage() {
  // Check if the user is signed in
  const session = await auth();
  if (!session?.user) redirect("/login");

  // Fetch all links belonging to this user, sorted by order
  const links = await prisma.link.findMany({
    where: { userId: session.user.id },
    orderBy: { order: "asc" },
  });

  // Fetch the user's username for the public profile link
  const user = await prisma.user.findUnique({
    where: { id: session.user.id },
    select: { username: true },
  });

  return (
    <div className="min-h-screen bg-gray-50 py-8">
      <div className="max-w-2xl mx-auto px-4">
        <div className="flex justify-between items-center mb-8">
          <h1 className="text-2xl font-bold">Your Links</h1>
          {user?.username && (
            <a
              href={`/${user.username}`}
              className="text-blue-600 hover:underline text-sm"
            >
              View public profile
            </a>
          )}
        </div>
        <AddLinkForm />
        <div className="mt-6 space-y-3">
          {links.map((link) => (
            <LinkItem key={link.id} link={link} />
          ))}
          {links.length === 0 && (
            <p className="text-gray-500 text-center py-8">
              No links yet. Add your first link above!
            </p>
          )}
        </div>
      </div>
    </div>
  );
}

What does this code do?

This is an async Server Component. It runs on the server every time someone visits /dashboard.

  • auth() retrieves the current user's session. If there's no session, the page redirects to /login.
  • prisma.link.findMany() fetches all links belonging to the signed-in user, sorted by their order field.
  • The page renders an AddLinkForm component and maps over the user's links to display each one as a LinkItem.
  • Save page.tsx.

You won't see a change yet because the page imports AddLinkForm and LinkItem components that don't exist. The next two substeps create them.

Build a form component to add new links

Server Actions are functions that run on the server but can be called directly from a form submission. They handle the database write and refresh the page with new data.

  • Inside src/app/dashboard/, create a new file called actions.ts and paste this code:
"use server";

import { auth } from "../../../auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";

export async function addLink(formData: FormData) {
  // Verify the user is authenticated
  const session = await auth();
  if (!session?.user?.id) throw new Error("Not authenticated");

  // Extract form values
  const title = formData.get("title") as string;
  const url = formData.get("url") as string;

  if (!title || !url) throw new Error("Title and URL are required");

  // Count existing links to set the order for the new link
  const linkCount = await prisma.link.count({
    where: { userId: session.user.id },
  });

  // Create the new link in the database
  await prisma.link.create({
    data: {
      title,
      url,
      order: linkCount,
      userId: session.user.id,
    },
  });

  // Refresh the dashboard to show the new link
  revalidatePath("/dashboard");
}

What does this code do?

  • "use server" at the top marks every exported function in this file as a Server Action. They execute on the server, never in the browser.
  • addLink first checks the session to make sure the request is authenticated. This prevents unauthorized users from adding links.
  • It counts the user's existing links to determine the order value for the new link. The first link gets order 0, the second gets order 1, and so on.
  • revalidatePath("/dashboard") tells Next.js to re-render the dashboard page so the new link appears immediately.
  • Save actions.ts.

You won't see a change yet. The next chunk creates the form that calls this action.

Now you need a Client Component for the form. Client Components run in the browser and can handle interactive behavior like clearing form fields after submission.

  • Inside src/app/dashboard/, create a new file called add-link-form.tsx and paste this code:
"use client";

import { addLink } from "./actions";
import { useRef } from "react";

export function AddLinkForm() {
  // Reference to the form element so we can reset it after submission
  const formRef = useRef<HTMLFormElement>(null);

  return (
    <form
      ref={formRef}
      action={async (formData) => {
        await addLink(formData);
        formRef.current?.reset();
      }}
      className="bg-white p-4 rounded-lg shadow-sm border"
    >
      <div className="flex flex-col gap-3 sm:flex-row">
        <input
          name="title"
          type="text"
          placeholder="Link title"
          required
          className="flex-1 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        <input
          name="url"
          type="url"
          placeholder="https://example.com"
          required
          className="flex-1 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        <button
          type="submit"
          className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 transition-colors"
        >
          Add
        </button>
      </div>
    </form>
  );
}

What does this code do?

  • "use client" marks this as a Client Component. It runs in the browser so it can use React hooks like useRef.
  • The form's action calls the addLink Server Action directly. No fetch calls or API routes needed.
  • After successful submission, formRef.current?.reset() clears the input fields so the user can immediately add another link.
  • Save add-link-form.tsx.

You won't see a change yet. The dashboard still needs the LinkItem component before it compiles.

Add a delete button on each link

Each link in the dashboard needs a way to be removed. You'll add a deleteLink Server Action and a component that renders each link with a delete button.

  • Open src/app/dashboard/actions.ts.
  • Add this function below the addLink function:
export async function deleteLink(linkId: string) {
  // Verify the user is authenticated
  const session = await auth();
  if (!session?.user?.id) throw new Error("Not authenticated");

  // Delete the link, but only if it belongs to this user
  await prisma.link.delete({
    where: {
      id: linkId,
      userId: session.user.id,
    },
  });

  // Refresh the dashboard to reflect the deletion
  revalidatePath("/dashboard");
}

What does this code do?

  • deleteLink takes a link ID and removes it from the database.
  • The where clause includes both id and userId. This ensures a user can only delete their own links, not someone else's.
  • Save actions.ts.

✔️ Awesome, I've got everything!

Great. Double check you've saved the file.

ⓧ I'd like to double check the full code

"use server";

import { auth } from "../../../auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";

export async function addLink(formData: FormData) {
  const session = await auth();
  if (!session?.user?.id) throw new Error("Not authenticated");

  const title = formData.get("title") as string;
  const url = formData.get("url") as string;

  if (!title || !url) throw new Error("Title and URL are required");

  const linkCount = await prisma.link.count({
    where: { userId: session.user.id },
  });

  await prisma.link.create({
    data: {
      title,
      url,
      order: linkCount,
      userId: session.user.id,
    },
  });

  revalidatePath("/dashboard");
}

export async function deleteLink(linkId: string) {
  const session = await auth();
  if (!session?.user?.id) throw new Error("Not authenticated");

  await prisma.link.delete({
    where: {
      id: linkId,
      userId: session.user.id,
    },
  });

  revalidatePath("/dashboard");
}

Now create the component that renders each individual link with its delete button.

  • Inside src/app/dashboard/, create a new file called link-item.tsx and paste this code:
"use client";

import { deleteLink } from "./actions";

interface LinkItemProps {
  link: {
    id: string;
    title: string;
    url: string;
  };
}

export function LinkItem({ link }: LinkItemProps) {
  return (
    <div className="bg-white p-4 rounded-lg shadow-sm border flex items-center justify-between">
      <div>
        <h3 className="font-medium">{link.title}</h3>
        <p className="text-sm text-gray-500 truncate max-w-xs">{link.url}</p>
      </div>
      <form
        action={async () => {
          await deleteLink(link.id);
        }}
      >
        <button
          type="submit"
          className="text-red-500 hover:text-red-700 text-sm"
        >
          Delete
        </button>
      </form>
    </div>
  );
}

What does this code do?

  • The LinkItemProps interface defines the shape of data this component expects. TypeScript will catch any mismatches at build time.
  • Each link renders its title, URL, and a small form with a delete button. The form's action calls deleteLink(link.id) on the server.
  • Using a <form> for the delete action means it works even if JavaScript fails to load in the browser.
  • Save link-item.tsx.
  • Start your dev server by running this command in the terminal:
npm run dev
  • Open http://localhost:3000 in your browser.
  • Click Sign in with GitHub on the login page.
  • Authorize the OAuth app when GitHub asks for permission.

After signing in, you should land on the dashboard page with the heading "Your Links" and the add link form below it.

  • Type My Portfolio in the title field and https://example.com in the URL field.
  • Click Add.

Your new link appears in the list below the form. Click Delete to confirm the delete button works too.

Seeing an error after signing in?

  • If you see a "Module not found" error, double check that all four files exist in src/app/dashboard/: page.tsx, actions.ts, add-link-form.tsx, and link-item.tsx.
  • If you see a database error, make sure your DATABASE_URL in .env is correct and that you ran npx prisma migrate dev in the previous step.
  • If the sign-in redirects back to the login page, check that your GitHub OAuth App's callback URL is exactly http://localhost:3000/api/auth/callback/github.

Why is my dashboard showing an error after sign-in?

Why Server Actions instead of API routes?

Traditional web apps require you to build separate API endpoints and then call them with fetch from the frontend. Server Actions collapse that into a single function call.

You write the database logic in a function, mark it with "use server", and call it directly from a form. Next.js handles the network request behind the scenes.

✔️ Awesome, I've got everything!

Great. Double check you've saved all four files in the src/app/dashboard/ folder.

ⓧ I'd like to double check the full code

import { auth } from "../../../auth";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { AddLinkForm } from "./add-link-form";
import { LinkItem } from "./link-item";

export default async function DashboardPage() {
  const session = await auth();
  if (!session?.user) redirect("/login");

  const links = await prisma.link.findMany({
    where: { userId: session.user.id },
    orderBy: { order: "asc" },
  });

  const user = await prisma.user.findUnique({
    where: { id: session.user.id },
    select: { username: true },
  });

  return (
    <div className="min-h-screen bg-gray-50 py-8">
      <div className="max-w-2xl mx-auto px-4">
        <div className="flex justify-between items-center mb-8">
          <h1 className="text-2xl font-bold">Your Links</h1>
          {user?.username && (
            <a
              href={`/${user.username}`}
              className="text-blue-600 hover:underline text-sm"
            >
              View public profile
            </a>
          )}
        </div>
        <AddLinkForm />
        <div className="mt-6 space-y-3">
          {links.map((link) => (
            <LinkItem key={link.id} link={link} />
          ))}
          {links.length === 0 && (
            <p className="text-gray-500 text-center py-8">
              No links yet. Add your first link above!
            </p>
          )}
        </div>
      </div>
    </div>
  );
}
"use server";

import { auth } from "../../../auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";

export async function addLink(formData: FormData) {
  const session = await auth();
  if (!session?.user?.id) throw new Error("Not authenticated");

  const title = formData.get("title") as string;
  const url = formData.get("url") as string;

  if (!title || !url) throw new Error("Title and URL are required");

  const linkCount = await prisma.link.count({
    where: { userId: session.user.id },
  });

  await prisma.link.create({
    data: {
      title,
      url,
      order: linkCount,
      userId: session.user.id,
    },
  });

  revalidatePath("/dashboard");
}

export async function deleteLink(linkId: string) {
  const session = await auth();
  if (!session?.user?.id) throw new Error("Not authenticated");

  await prisma.link.delete({
    where: {
      id: linkId,
      userId: session.user.id,
    },
  });

  revalidatePath("/dashboard");
}
"use client";

import { addLink } from "./actions";
import { useRef } from "react";

export function AddLinkForm() {
  const formRef = useRef<HTMLFormElement>(null);

  return (
    <form
      ref={formRef}
      action={async (formData) => {
        await addLink(formData);
        formRef.current?.reset();
      }}
      className="bg-white p-4 rounded-lg shadow-sm border"
    >
      <div className="flex flex-col gap-3 sm:flex-row">
        <input
          name="title"
          type="text"
          placeholder="Link title"
          required
          className="flex-1 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        <input
          name="url"
          type="url"
          placeholder="https://example.com"
          required
          className="flex-1 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        <button
          type="submit"
          className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 transition-colors"
        >
          Add
        </button>
      </div>
    </form>
  );
}
"use client";

import { deleteLink } from "./actions";

interface LinkItemProps {
  link: {
    id: string;
    title: string;
    url: string;
  };
}

export function LinkItem({ link }: LinkItemProps) {
  return (
    <div className="bg-white p-4 rounded-lg shadow-sm border flex items-center justify-between">
      <div>
        <h3 className="font-medium">{link.title}</h3>
        <p className="text-sm text-gray-500 truncate max-w-xs">{link.url}</p>
      </div>
      <form
        action={async () => {
          await deleteLink(link.id);
        }}
      >
        <button
          type="submit"
          className="text-red-500 hover:text-red-700 text-sm"
        >
          Delete
        </button>
      </form>
    </div>
  );
}

Your dashboard is fully functional. You can add links and delete them, all backed by your Neon database. Next up, you'll create a public profile page so anyone can view your links without signing in.

Create the Public Profile Page

Your dashboard is working. You can sign in, add links, and delete them. But right now, those links only exist behind your login wall.

The whole point of a link-in-bio app is sharing. In this step, you will build a public profile page that anyone can visit at a URL like /yourname. This page fetches links from your database and displays them without requiring authentication.

In this step, get ready to:
  • Create a dynamic route that serves a different page for each username.
  • Build a styled public profile page with Tailwind CSS.
  • Test the full app flow from sign-in to public profile.
Create the dynamic profile route

Next.js uses folder names with square brackets to create dynamic routes. A folder named [username] tells Next.js to match any URL path and pass the value as a parameter to your page component.

  • In the VS Code Explorer sidebar, right-click on the src/app folder.
  • Click New Folder.
  • Name the folder [username] (include the square brackets in the name).
  • Right-click on the new [username] folder and click New File.
  • Name the file page.tsx.
  • Paste the following code into src/app/[username]/page.tsx:
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";

// Define the shape of props for this dynamic route page
interface ProfilePageProps {
  params: Promise<{ username: string }>;
}

export default async function ProfilePage({ params }: ProfilePageProps) {
  // Await the params (required in Next.js 16)
  const { username } = await params;

  // Fetch the user and their links from the database
  const user = await prisma.user.findUnique({
    where: { username },
    include: {
      links: {
        orderBy: { order: "asc" },
      },
    },
  });

  // Show a 404 page if no user exists with this username
  if (!user) notFound();

  return (
    <div className="min-h-screen bg-gradient-to-b from-gray-900 to-gray-800 py-16">
      <div className="max-w-md mx-auto px-4 text-center">
        {user.image && (
          <img
            src={user.image}
            alt={user.name || "Profile"}
            className="w-20 h-20 rounded-full mx-auto mb-4"
          />
        )}
        <h1 className="text-xl font-bold text-white mb-1">
          {user.name || username}
        </h1>
        <p className="text-gray-400 mb-8">@{username}</p>
        <div className="space-y-3">
          {user.links.map((link) => (
            <a
              key={link.id}
              href={link.url}
              target="_blank"
              rel="noopener noreferrer"
              className="block w-full bg-white text-gray-900 py-3 px-4 rounded-lg font-medium hover:bg-gray-100 transition-colors"
            >
              {link.title}
            </a>
          ))}
        </div>
        {user.links.length === 0 && (
          <p className="text-gray-400">No links yet.</p>
        )}
        <a
          href="/login"
          className="inline-block mt-12 text-gray-500 text-sm hover:text-gray-300"
        >
          Get your own Link Hub
        </a>
      </div>
    </div>
  );
}

What does this code do?

This is a Server Component that runs on the server and fetches data before sending HTML to the browser. No client-side JavaScript needed.

  • params: Promise<{ username: string }> captures whatever value appears in the URL. In Next.js 16, params is a Promise that must be awaited.
  • prisma.user.findUnique with include: { links: ... } fetches the user AND their links in one database query.
  • notFound() triggers Next.js's built-in 404 page if no user matches that username.
  • The "Get your own Link Hub" link at the bottom acts as a call-to-action, sending visitors to your sign-in page.

Why a dark gradient background?

The dark gradient (from-gray-900 to-gray-800) makes the white link buttons pop visually. This matches the aesthetic of link-in-bio tools like Linktree where the profile is a focused, single-column card layout.

  • Save src/app/[username]/page.tsx.

Folder name doesn't include brackets?

The folder must be literally named [username] with the square brackets. If you named it just username (no brackets), Next.js will only match the exact path /username instead of any dynamic value.

help me fix my dynamic route folder name

✔️ Awesome, I've got everything!

Great. Make sure you saved the file before continuing.

ⓧ I'd like to double check the full code

import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";

interface ProfilePageProps {
  params: Promise<{ username: string }>;
}

export default async function ProfilePage({ params }: ProfilePageProps) {
  const { username } = await params;

  const user = await prisma.user.findUnique({
    where: { username },
    include: {
      links: {
        orderBy: { order: "asc" },
      },
    },
  });

  if (!user) notFound();

  return (
    <div className="min-h-screen bg-gradient-to-b from-gray-900 to-gray-800 py-16">
      <div className="max-w-md mx-auto px-4 text-center">
        {user.image && (
          <img
            src={user.image}
            alt={user.name || "Profile"}
            className="w-20 h-20 rounded-full mx-auto mb-4"
          />
        )}
        <h1 className="text-xl font-bold text-white mb-1">
          {user.name || username}
        </h1>
        <p className="text-gray-400 mb-8">@{username}</p>
        <div className="space-y-3">
          {user.links.map((link) => (
            <a
              key={link.id}
              href={link.url}
              target="_blank"
              rel="noopener noreferrer"
              className="block w-full bg-white text-gray-900 py-3 px-4 rounded-lg font-medium hover:bg-gray-100 transition-colors"
            >
              {link.title}
            </a>
          ))}
        </div>
        {user.links.length === 0 && (
          <p className="text-gray-400">No links yet.</p>
        )}
        <a
          href="/login"
          className="inline-block mt-12 text-gray-500 text-sm hover:text-gray-300"
        >
          Get your own Link Hub
        </a>
      </div>
    </div>
  );
}
Test the full app flow

Your app now has every piece: authentication, a protected dashboard, and a public profile. Time to test the complete flow from sign-in to public page.

  • Start your development server by running this command:
npm run dev
  • Open http://localhost:3000 in your browser.
  • Click Sign in with GitHub and authorize the app.
  • On the dashboard, add 2-3 test links using the form (for example, your GitHub profile URL and a portfolio site).
  • Click the View public profile link in the top-right corner of the dashboard.

You should see a dark gradient page with your GitHub avatar, your display name, and each link rendered as a white button.

  • Click one of the link buttons to confirm it opens the URL in a new tab.
  • Scroll to the bottom of the profile page and click Get your own Link Hub to confirm it takes you to the login page.
  • Test the 404 behavior by visiting http://localhost:3000/nonexistentuser123 in your browser.

You should see Next.js's default 404 page since no user with that username exists in your database.

Profile page shows 404 for your username?

Your User record in the database needs a username field populated. If you signed in with GitHub before adding the username field to your schema, the existing User record may have a null username.

You can update your username directly in the Neon console. Open your Neon project dashboard, click SQL Editor, and run a query like:

UPDATE "User" SET username = 'your-github-username' WHERE email = 'your-email@example.com';

Replace the email and username values with your own. Then refresh the profile page.

help me fix my profile page 404 issue

Your Link Hub is fully functional locally. Users can sign in, manage links, and share a public profile. Next up, you will deploy the whole thing to Vercel so anyone on the internet can access it.

Deploy to Vercel

Your Link Hub works locally, but right now only you can see it. The whole point of a link-in-bio app is sharing a public URL with the world.

In this step, you'll push your code to GitHub, deploy it to Vercel (a free hosting platform built for Next.js), and walk away with a live production URL anyone can visit.

In this step, get ready to:
  • Push your project to a new GitHub repository.
  • Deploy to Vercel with your environment variables.
  • Update your GitHub OAuth callback URL and test the live app.
Push your project to GitHub

Vercel deploys directly from a Git repository. You need your code on GitHub before Vercel can build and host it.

  • Open your browser and go to github.com/new to create a new repository.
  • Enter link-hub as the repository name.
  • Leave it set to Public.
  • Do NOT check any of the initialization boxes (README, .gitignore, license). Your project already has these.
  • Click Create repository.

GitHub shows you a page with setup commands. You'll use the "push an existing repository" option.

  • Back in your VS Code terminal, make sure you're in the link-hub folder.
  • Stage and commit all your files by running these commands:
git add .
git commit -m "Initial commit: Link Hub app"
  • Connect your local repository to GitHub and push by running these commands:
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR-USERNAME/link-hub.git"]]
git branch -M main
git push -u origin main

What do these commands do?

  • git remote add origin links your local folder to the GitHub repository you just created.
  • git branch -M main renames your default branch to main (GitHub's standard).
  • git push -u origin main uploads all your code to GitHub.
  • Refresh your GitHub repository page in the browser. You should see all your project files listed.

Is my .env file safe?

Yes. The .gitignore file created by create-next-app already excludes .env* files. Your secrets stay on your machine and never get pushed to GitHub.

Push failed or permission denied?

  • Make sure you're signed into GitHub in your terminal. If you see a credential prompt, enter your GitHub username and a personal access token (not your password).
  • Double-check the repository URL matches what GitHub shows on the empty repo page.

Help me push my code to GitHub

Deploy to Vercel

Vercel auto-detects Next.js projects and handles the entire build process for you. You just need to connect your repo and add your environment variables.

  • Go to vercel.com and click Sign Up.
  • Choose Continue with GitHub to sign up using your existing GitHub account.
  • Select the Hobby plan (free) when prompted.
  • Authorize Vercel to access your GitHub repositories when asked.

Once your account is ready, you'll import your repository.

  • On the Vercel dashboard, click Add New... then Project.
  • Find your link-hub repository in the list and click Import.

Don't see your repository?

Click Adjust GitHub App Permissions at the bottom of the import list. Grant Vercel access to your link-hub repository (or all repositories), then return to Vercel and refresh.

Before deploying, you need to add your environment variables. Without them, the app cannot connect to your database or authenticate users.

  • On the project configuration page, expand the Environment Variables section.
  • Add each of the following variables one at a time. For each one, type the name in the Key field and paste the value from your local .env file into the Value field, then click Add:
  • DATABASE_URL (your Neon connection string).
  • AUTH_SECRET (the secret you generated earlier).
  • GITHUB_ID (your GitHub OAuth App Client ID).
  • GITHUB_SECRET (your GitHub OAuth App Client Secret).
  • Confirm all four variables appear in the list.
  • Click Deploy.

Vercel will build your Next.js app. This takes about 1-2 minutes. You'll see a build log updating in real time.

  • Wait for the build to finish. You should see a "Congratulations" screen with a preview of your deployed site.
  • Copy your production URL from the Vercel dashboard. It looks something like https://link-hub-xyz.vercel.app.

Build failed?

  • Check the build log for the specific error. The most common issue is a missing environment variable.
  • Make sure all four environment variables are added with the correct names (case-sensitive).
  • If you see a Prisma error, confirm your DATABASE_URL value includes ?sslmode=require at the end.

Help me fix my Vercel build error

Update your OAuth callback URL and test

Your GitHub OAuth App is still configured to redirect to localhost:3000. You need to update it to your new production URL so sign-in works on the live site.

  • Go to your GitHub account settings. Click your profile picture in the top right, then select Settings.
  • Scroll down in the left sidebar and click Developer settings.
  • Click OAuth Apps in the left sidebar.
  • Click on the OAuth App you created earlier for Link Hub.
  • Update the Homepage URL field to your Vercel production URL (e.g. https://link-hub-xyz.vercel.app).
  • Update the Authorization callback URL field to your production callback URL. It follows this format:
[[VERCEL_CALLBACK="https://your-app-name.vercel.app"]]/api/auth/callback/github
  • Click Update application.

Keep localhost for local development

GitHub OAuth Apps only support one callback URL. If you want to continue developing locally, you can create a second OAuth App for local development with the http://localhost:3000/api/auth/callback/github callback URL.

Now test your live app to make sure everything works end-to-end.

  • Open your Vercel production URL in a new browser tab.
  • You should be redirected to the login page. Click Sign in with GitHub.
  • Authorize the app when GitHub prompts you.
  • You should land on your dashboard. Add a test link (any title and URL).
  • Click the View public profile link in your dashboard header to verify your public page displays the link.

Sign-in redirects to an error page?

  • Confirm the callback URL in your GitHub OAuth App matches your Vercel URL exactly, including /api/auth/callback/github at the end.
  • Check that GITHUB_ID and GITHUB_SECRET in Vercel match the values in your GitHub OAuth App page.
  • If you updated environment variables in Vercel after the first deploy, redeploy by going to the Deployments tab and clicking the three dots on the latest deployment, then Redeploy.

Help me fix GitHub OAuth on my deployed Vercel app

Your Link Hub is live. You have a full stack web app with authentication, a database, and a public URL you can share with anyone. Next up, there's an optional secret mission to add drag-and-drop link reordering.

Secret mission

Add Drag-and-Drop Link Reordering

Your Link Hub works, but links are stuck in the order you created them. What if you want your most important link at the top? In this secret mission, you'll add drag-and-drop reordering so users can arrange their links in any order they want.

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 uses the Neon free tier and Vercel Hobby plan, so there are no ongoing costs for keeping everything live.

Resources you used:

  • Next.js project folder on your local machine.
  • Neon PostgreSQL database (free tier, 0.5 GB storage).
  • Vercel deployment (Hobby plan, free hosting).
  • GitHub OAuth App in your Developer Settings.
  • Environment variables in your .env file (contains secrets).

Keep everything running

No action needed. Choose this if you are still actively building or want to keep your Link Hub live as a portfolio piece.

  • Your Vercel deployment stays live at your production URL for free.
  • Your Neon database remains active within the free tier limits (0.5 GB storage, 30 projects).
  • Your GitHub OAuth App continues to work for sign-ins.

Pause - I'll come back to this later

Free up local resources while keeping your cloud services intact. You can resume development at any time.

  • Stop the local development server by pressing Ctrl+C in your terminal.
  • Leave your Neon database and Vercel deployment running. Both are free and will remain active.
  • Keep your .env file safe. You will need those credentials when you return.
  • To resume later, open the project folder and run npm run dev to start the local server again.

Delete - I don't want to use this again

Remove all project resources completely. This cannot be undone.

  • Delete the Neon project: go to your Neon dashboard, select the project, open Settings, and click Delete Project. This permanently removes your database and all data.
  • Delete the Vercel project: go to your Vercel dashboard, select the project, open Settings, scroll to the bottom, and click Delete Project.
  • Delete the GitHub OAuth App: go to Settings > Developer settings > OAuth Apps, select your app, and click Delete application.
  • Delete the GitHub repository if you created one for deployment.
  • Delete your local project folder from your Desktop.

That's a Wrap!

That's a Wrap!

Nice work! You just built and deployed a full stack web application from scratch to production.

You've learned how to:

  • Build a Next.js 16 application with TypeScript, Tailwind CSS, and a PostgreSQL database powered by Prisma ORM.
  • Implement GitHub OAuth authentication with Auth.js v5, protected routes, and Server Actions for secure CRUD operations.
  • Deploy a production app to Vercel with a live public URL, a dynamic profile page, and a protected dashboard.
  • Secret Mission: Added drag-and-drop link reordering with persistent order stored in the database.

Ready to quiz yourself?