Build an AI Hackathon Advisor

Build a full-stack AI SaaS app that ranks hackathon problem statements.

Introduction

30 Second Summary

Hackathons are exciting, but choosing the right problem statement from a list of options can be paralyzing. Your team only has limited hours, so picking the wrong one means wasted effort.

In this project, you will build HackPick, an AI-powered web app that analyzes hackathon problem statements, ranks them by feasibility and uniqueness, and generates a project guide with task assignments for your team.

What You'll Build

A full-stack SaaS application using Next.js 16, Clerk authentication, Prisma with Neon PostgreSQL, and NVIDIA NIM for AI-powered problem statement analysis and project planning.

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

  • A protected dashboard where users sign in, paste 2-5 problem statements, and receive AI-ranked recommendations with scores for feasibility, uniqueness, and complexity.
  • A guide generation workflow that creates a suggested tech stack, phased roadmap, and task division for each team member.
  • A persistent history page that stores all past analyses in a PostgreSQL database tied to each authenticated user.
  • Secret Mission: Add a team skills input so the AI tailors task assignments to each member's strengths.

Are there any prerequisites?

This project assumes familiarity with React, TypeScript, and Prisma. You should also have an existing Neon account for your PostgreSQL database.

You will create free accounts for Clerk and the NVIDIA Developer Program during setup. No credit card is required for any service in this project.

Scaffold the Project and Configure Services

Every AI SaaS starts the same way: a solid foundation of tooling that handles the boring parts so you can focus on the interesting ones.

In this step, you will scaffold a Next.js 16 project with Bun, wire up Clerk for user authentication, and connect Prisma to your Neon database. By the end, your dev server will show a working sign-in page.

In this step, get ready to:
  • Scaffold a Next.js 16 project and configure Bun runtime scripts.
  • Set up Clerk authentication with the Clerk CLI.
  • Initialize Prisma and configure environment variables for Neon and NVIDIA NIM.
Scaffold a Next.js 16 app with Bun

Bun is a fast JavaScript runtime that replaces Node.js for running your dev server and build scripts. You will use it to scaffold a new Next.js project and run the app throughout this project.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your HackPick project by running this command:
bun create next-app@latest hackpick

What will this command ask?

The CLI will ask whether you want to use the recommended Next.js defaults. Select No, customize settings so you can skip features this project does not need.

You will then see a series of prompts. Answer each one individually as listed below.

  • Enter Yes for the TypeScript prompt.
  • Select None for the linter prompt.
  • Enter No for the React Compiler prompt.
  • Enter Yes for the Tailwind CSS prompt.
  • Enter No for the src/ directory prompt.
  • Enter Yes for the App Router prompt.
  • Enter No for the import alias prompt.
  • Enter No for the AGENTS.md prompt.

Bun will create the hackpick folder and install dependencies automatically.

  • Move into your new project folder by running:
cd hackpick

Next, you need to update the package.json scripts so Bun runs the Next.js CLI directly instead of deferring to Node.js.

  • Open hackpick in VS Code. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select the hackpick folder on your Desktop, then click Open.
  • Open package.json from the file explorer on the left.
  • Find the "scripts" section and replace it with the following:
"scripts": {
  "dev": "bun --bun next dev",
  "build": "bun --bun next build",
  "start": "bun --bun next start"
}

What does bun --bun do?

The bun --bun prefix tells Bun to use its own runtime instead of Node.js when executing Next.js commands. This gives you faster startup times and native TypeScript support without a separate compilation step.

  • Save package.json.

Now install the additional dependencies this project needs for database access.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Install Prisma and the Prisma Client by running:
bun add @prisma/client@7.8.0 && bun add -d prisma@7.8.0

What are these packages?

The @prisma/client package is the runtime library your app uses to query the database. The prisma dev dependency provides the CLI for generating migrations and the typed client.

  • Confirm the packages installed by checking that your terminal output shows no errors.

Seeing install errors?

Make sure you are inside the hackpick folder (run pwd to check). If you see a network error, check your internet connection and try again.

Help me troubleshoot this install error

Set up Clerk authentication

Clerk handles user sign-up, sign-in, and session management so you do not have to build auth from scratch. The Clerk CLI automates the entire integration in a few commands.

  • Install the Clerk CLI globally by running:
bun install -g clerk
  • Log in to your Clerk account from the terminal by running:
clerk auth login

What will happen?

This command opens your browser to complete the Clerk login flow. Sign in with the Clerk account you created earlier, then return to your terminal. You will see a success message confirming you are authenticated.

  • Initialize Clerk in your project by running:
clerk init

What does clerk init do?

This command detects that you have a Next.js 16 project, installs @clerk/nextjs, writes a proxy.ts file (the Next.js 16 replacement for middleware), adds ClerkProvider to your layout, and scaffolds sign-in/sign-up routes.

Follow any interactive prompts the CLI shows. If it offers to install agent skills, you can decline.

  • Pull your Clerk environment keys into the project by running:
clerk env pull

This writes NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY into your .env.local file automatically.

Now update the layout to include navigation and the Clerk UI components your app needs.

  • Open app/layout.tsx in VS Code.
  • Replace the entire file contents with the following code:
import { ClerkProvider, Show, UserButton, SignInButton } from "@clerk/nextjs";
import Link from "next/link";
import "./globals.css";

export const metadata = {
  title: "HackPick - AI Hackathon Advisor",
  description: "Pick the best problem statement for your hackathon team",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body className="min-h-screen bg-gray-50">
          <nav className="bg-white border-b px-6 py-3 flex items-center justify-between">
            <div className="flex items-center gap-6">
              <Link href="/dashboard" className="font-bold text-lg">
                HackPick
              </Link>
              <Show when="signed-in">
                <Link
                  href="/dashboard"
                  className="text-sm text-gray-600 hover:text-gray-900"
                >
                  New Analysis
                </Link>
                <Link
                  href="/dashboard/history"
                  className="text-sm text-gray-600 hover:text-gray-900"
                >
                  History
                </Link>
              </Show>
            </div>
            <div>
              <Show when="signed-out">
                <SignInButton />
              </Show>
              <Show when="signed-in">
                <UserButton />
              </Show>
            </div>
          </nav>
          <main className="max-w-4xl mx-auto p-6">{children}</main>
        </body>
      </html>
    </ClerkProvider>
  );
}

What does this layout do?

The ClerkProvider wraps your entire app so every page can access the user's authentication state.

The Show component conditionally renders content based on whether the user is signed in or signed out. UserButton displays the user's avatar with account management options. SignInButton shows a sign-in link when no user is logged in.

  • Save app/layout.tsx.

Finally, update the global stylesheet so Clerk's styles layer correctly with Tailwind CSS v4.

  • Open app/globals.css in VS Code.
  • Replace the entire file contents with the following:
@layer clerk;
@import "tailwindcss";

Why the @layer directive?

Tailwind CSS v4 uses @import "tailwindcss" instead of the older @tailwind directives. The @layer clerk line ensures Clerk's own styles load before Tailwind's utility classes, so your Tailwind overrides always win.

  • Save app/globals.css.

Clerk init did not create proxy.ts?

If clerk init did not generate a proxy.ts file in your project root, create it manually. Add a new file called proxy.ts at the root of your project (next to package.json) and paste the proxy code shown in the "double check" tab at the end of this step.

How do I set up proxy.ts manually?

Initialize Prisma and configure API keys

Prisma needs a schema file pointing to your Neon PostgreSQL database. You also need an API key from NVIDIA to power the AI analysis later.

  • Initialize Prisma with the PostgreSQL provider by running:
bunx prisma init --datasource-provider postgresql

What does this create?

This command creates a prisma/ directory with a schema.prisma file configured for PostgreSQL. It also adds a placeholder DATABASE_URL to your .env file.

Now you need to set the actual database connection string and your NVIDIA API key.

  • Open your Neon dashboard in a browser.
  • Copy the connection string for your database. It looks like postgresql://user:password@host/dbname.
  • Open .env.local in VS Code (this file was created by clerk env pull earlier).
  • Add these two lines at the bottom of the file:
DATABASE_URL="your-neon-connection-string"
NVIDIA_API_KEY="your-nvidia-api-key"
  • Replace your-neon-connection-string with the actual connection string you copied from Neon.

To get the NVIDIA API key, follow these steps.

  • Navigate to build.nvidia.com in your browser.
  • Select any model from the catalog (for example, meta/llama-3.3-70b-instruct).
  • Click Get API Key.
  • Sign in with your NVIDIA Developer Program account.
  • Click Generate Key.
  • Copy the key and paste it into .env.local replacing your-nvidia-api-key.
  • Save .env.local.

What is NVIDIA NIM?

NVIDIA NIM provides free hosted AI model endpoints with an OpenAI-compatible API format. You get 40 requests per minute on the free tier, which is plenty for development and testing.

Can't find the Get API Key button?

Make sure you are signed in to your NVIDIA Developer Program account. The button appears on individual model pages in the API Catalog, not the main landing page.

Help me find the NVIDIA API key button

Now verify everything works together by starting the dev server.

  • Start the development server by running:
bun --bun run dev
  • Open http://localhost:3000 in your browser.
  • You should see the Clerk sign-in UI appear.
  • Click Sign up and create your first user account.

Not seeing the Clerk sign-in page?

Check that .env.local contains NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY (from clerk env pull). If they are missing, run clerk env pull again.

Also verify that proxy.ts exists in your project root. Without it, Clerk cannot intercept requests.

Help me debug Clerk sign-in not showing

✔️ Awesome, I've got everything!

Great. Make sure you have signed up and created your first user account before moving on.

ⓧ I'd like to double check the full code

Here are the key files your project should have at this point.

{
  "name": "hackpick",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "bun --bun next dev",
    "build": "bun --bun next build",
    "start": "bun --bun next start"
  },
  "dependencies": {
    "@clerk/nextjs": "^7.3.1",
    "@prisma/client": "7.8.0",
    "next": "16.2.5",
    "react": "^19.2.6",
    "react-dom": "^19.2.6"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4.2.4",
    "prisma": "7.8.0",
    "tailwindcss": "^4.2.4",
    "typescript": "^5.7.0",
    "@types/node": "^22.0.0",
    "@types/react": "^19.0.0",
    "@types/react-dom": "^19.0.0"
  }
}
import { ClerkProvider, Show, UserButton, SignInButton } from "@clerk/nextjs";
import Link from "next/link";
import "./globals.css";

export const metadata = {
  title: "HackPick - AI Hackathon Advisor",
  description: "Pick the best problem statement for your hackathon team",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body className="min-h-screen bg-gray-50">
          <nav className="bg-white border-b px-6 py-3 flex items-center justify-between">
            <div className="flex items-center gap-6">
              <Link href="/dashboard" className="font-bold text-lg">
                HackPick
              </Link>
              <Show when="signed-in">
                <Link
                  href="/dashboard"
                  className="text-sm text-gray-600 hover:text-gray-900"
                >
                  New Analysis
                </Link>
                <Link
                  href="/dashboard/history"
                  className="text-sm text-gray-600 hover:text-gray-900"
                >
                  History
                </Link>
              </Show>
            </div>
            <div>
              <Show when="signed-out">
                <SignInButton />
              </Show>
              <Show when="signed-in">
                <UserButton />
              </Show>
            </div>
          </nav>
          <main className="max-w-4xl mx-auto p-6">{children}</main>
        </body>
      </html>
    </ClerkProvider>
  );
}
@layer clerk;
@import "tailwindcss";
import { clerkMiddleware } from "@clerk/nextjs/server";

export default clerkMiddleware();

export const config = {
  matcher: [
    "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
    "/(api|trpc)(.*)",
  ],
};

Your foundation is solid. Next up, you will define the database schema and set up Prisma to talk to your Neon database.

Define the Database Schema and Prisma Client

Your app is running and Clerk handles authentication. Now you need a place to store the analyses and guides that users generate.

In this step, you will define the database models that describe what an analysis looks like and what a guide contains. Then you will run a migration to create the actual tables in your Neon database and set up a shared Prisma client for your app to use.

In this step, get ready to:
  • Define the Analysis and Guide models in your Prisma schema.
  • Run a migration to create the tables in your Neon PostgreSQL database.
  • Create a shared Prisma client file that prevents duplicate connections in development.
Define the Prisma schema models

Your app needs two data models. An Analysis stores the problem statements and AI ranking for a single submission. A Guide stores the generated project plan linked to a specific analysis.

  • In VS Code, open prisma/schema.prisma from the Explorer sidebar.
  • Replace the entire file contents with the following schema:
generator client {
  provider = "prisma-client-js"
}

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

// Stores a user's submitted problem statements and the AI ranking
model Analysis {
  id                String   @id @default(cuid())
  userId            String
  problemStatements Json
  teamSize          Int
  ranking           Json
  createdAt         DateTime @default(now())
  guide             Guide?
}

// Stores the generated project guide linked to one Analysis
model Guide {
  id           String   @id @default(cuid())
  analysisId   String   @unique
  analysis     Analysis @relation(fields: [analysisId], references: [id])
  selectedPS   String
  techStack    Json
  roadmap      Json
  taskDivision Json
  createdAt    DateTime @default(now())
}

What does this schema do?

The Analysis model holds everything about one submission: the user's ID from Clerk, the raw problem statements as JSON, the team size, and the AI-generated ranking. The optional guide field creates a one-to-one relationship.

The Guide model links back to one Analysis via analysisId. It stores the selected problem statement title, a recommended tech stack, a roadmap broken into phases, and a task division plan. All structured data uses Json fields because the shape comes from the AI response.

  • Save prisma/schema.prisma.
Run the migration and generate the typed client

A schema file alone does not create tables. You need to run a migration to push the models to your Neon database, then generate the typed client so your TypeScript code can interact with those tables.

  • Apply the schema to your Neon database by running this command:
bunx prisma migrate dev --name init

What does this command do?

The prisma migrate dev command compares your schema to the current database state, generates a SQL migration file, and applies it. The --name init flag names the migration folder so you can track changes in version control.

  • You should see output confirming the migration was applied successfully and that Prisma Client was generated.

Seeing a connection error?

Make sure your DATABASE_URL in .env.local points to your Neon connection string. You can find it in the Neon dashboard under your project's connection details.

If Prisma cannot reach the database, double-check that the string starts with postgresql:// and includes sslmode=require at the end.

Help me fix my Neon database connection error in Prisma.

  • Now generate the typed Prisma client by running:
bunx prisma generate

Why run generate separately?

The migrate command already generated the client once. Running prisma generate explicitly ensures you always have the latest typed client matching your schema, even if you edit the schema without migrating.

Create the shared Prisma client file

In development, Next.js restarts the server on file changes. Each restart would create a new database connection if you instantiate Prisma directly. A singleton pattern avoids this by reusing a single client instance.

  • Create a new folder called lib in the root of your hackpick project.
  • Create a new file called prisma.ts inside the lib folder.
  • Add the following code to lib/prisma.ts:
import { PrismaClient } from "@prisma/client";

// Store the Prisma instance on globalThis to survive hot reloads
const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

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

// In development, attach the client to globalThis so it persists across reloads
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

What does this code do?

The file casts globalThis to hold an optional PrismaClient reference. If one already exists from a previous hot reload, it reuses it. Otherwise it creates a fresh client.

In production, a new client is created once per cold start. In development, the global reference prevents connection pool exhaustion when Next.js restarts your server file dozens of times.

  • Save lib/prisma.ts.
  • Verify the tables exist in your Neon database by running:
bunx prisma studio
  • Prisma Studio opens in your browser. You should see Analysis and Guide listed as tables. Both are empty for now.

Don't see both tables?

If only one table appears or neither shows, re-run bunx prisma migrate dev --name init and check the terminal output for errors. Make sure you saved prisma/schema.prisma before migrating.

Help me troubleshoot missing tables in Prisma Studio.

✔️ Awesome, I've got everything!

Great. Make sure you have saved both prisma/schema.prisma and lib/prisma.ts.

ⓧ I'd like to double check the full code

Here is the complete prisma/schema.prisma:

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

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

model Analysis {
  id                String   @id @default(cuid())
  userId            String
  problemStatements Json
  teamSize          Int
  ranking           Json
  createdAt         DateTime @default(now())
  guide             Guide?
}

model Guide {
  id           String   @id @default(cuid())
  analysisId   String   @unique
  analysis     Analysis @relation(fields: [analysisId], references: [id])
  selectedPS   String
  techStack    Json
  roadmap      Json
  taskDivision Json
  createdAt    DateTime @default(now())
}

Here is the complete lib/prisma.ts:

import { PrismaClient } from "@prisma/client";

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

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

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

Your database is ready. Next up, you will build the analysis form and connect it to the NVIDIA NIM API to rank problem statements.

Build the Analysis Form and NVIDIA NIM Integration

Your database is ready to store analyses, but you need a way for users to submit their hackathon problem statements and get AI-powered rankings back.

In this step, you will build the input form, connect it to NVIDIA NIM (a free hosted LLM endpoint), and create the Server Action that ties everything together.

In this step, get ready to:
  • Create a protected dashboard page with a dynamic analysis form.
  • Build a helper function that calls the NVIDIA NIM API.
  • Wire up a Server Action that ranks problem statements and saves results to the database.
Create the protected dashboard page and analysis form

The dashboard page needs to verify the user is signed in before showing anything. If not authenticated, it redirects to the sign-in page.

  • Create a new folder called dashboard inside your app directory.
  • Inside app/dashboard, create a file called page.tsx and paste the following code:
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
import { AnalysisForm } from "./analysis-form";

export default async function DashboardPage() {
  // Check if the user is signed in
  const { userId } = await auth();
  if (!userId) redirect("/sign-in");

  return (
    <div>
      <h1 className="text-2xl font-bold mb-6">Analyze Problem Statements</h1>
      <AnalysisForm />
    </div>
  );
}

What does this code do?

  • auth() from Clerk checks the current session on the server. If no userId exists, the user gets redirected to /sign-in.
  • The page imports an AnalysisForm component that you will create next. This is a client component that handles the interactive form.

Now you need the form itself. This is a client component because it manages dynamic state (adding and removing problem statement inputs).

  • In the same app/dashboard folder, create a file called analysis-form.tsx and paste the following code:
"use client";

import { useState } from "react";
import { analyzeStatements } from "./actions";

export function AnalysisForm() {
  // Track the list of problem statements (min 2, max 5)
  const [statements, setStatements] = useState([
    { title: "", description: "" },
    { title: "", description: "" },
  ]);
  const [teamSize, setTeamSize] = useState(3);
  const [loading, setLoading] = useState(false);

  function addStatement() {
    if (statements.length < 5) {
      setStatements([...statements, { title: "", description: "" }]);
    }
  }

  function removeStatement(index: number) {
    if (statements.length > 2) {
      setStatements(statements.filter((_, i) => i !== index));
    }
  }

  function updateStatement(index: number, field: "title" | "description", value: string) {
    const updated = [...statements];
    updated[index][field] = value;
    setStatements(updated);
  }

  // Serialize state into FormData before calling the Server Action
  async function handleSubmit(formData: FormData) {
    setLoading(true);
    formData.set("statements", JSON.stringify(statements));
    formData.set("teamSize", String(teamSize));
    await analyzeStatements(formData);
  }

  return (
    <form action={handleSubmit} className="space-y-6">
      {statements.map((stmt, i) => (
        <div key={i} className="border rounded-lg p-4 bg-white space-y-3">
          <div className="flex justify-between items-center">
            <span className="font-medium">Problem Statement {i + 1}</span>
            {statements.length > 2 && (
              <button type="button" onClick={() => removeStatement(i)} className="text-red-500 text-sm">Remove</button>
            )}
          </div>
          <input type="text" placeholder="Title" value={stmt.title} onChange={(e) => updateStatement(i, "title", e.target.value)} className="w-full border rounded px-3 py-2" required />
          <textarea placeholder="Description" value={stmt.description} onChange={(e) => updateStatement(i, "description", e.target.value)} className="w-full border rounded px-3 py-2 h-24" required />
        </div>
      ))}
      {statements.length < 5 && (
        <button type="button" onClick={addStatement} className="text-blue-600 text-sm font-medium">+ Add Problem Statement</button>
      )}
      <div className="border rounded-lg p-4 bg-white">
        <label className="block font-medium mb-2">Team Size</label>
        <input type="number" min={1} max={10} value={teamSize} onChange={(e) => setTeamSize(Number(e.target.value))} className="border rounded px-3 py-2 w-24" />
      </div>
      <button type="submit" disabled={loading} className="bg-blue-600 text-white px-6 py-3 rounded-lg font-medium disabled:opacity-50">
        {loading ? "Analyzing..." : "Analyze Problem Statements"}
      </button>
    </form>
  );
}

What does this form do?

  • statements state holds an array of objects with title and description fields. The form starts with 2 and allows up to 5.
  • addStatement() and removeStatement() enforce the 2-5 limit dynamically.
  • handleSubmit() serializes the React state into FormData before passing it to the Server Action. This bridges client state with server-side processing.
  • Save both app/dashboard/page.tsx and app/dashboard/analysis-form.tsx.
  • Switch back to your browser and visit localhost:3000/dashboard. You should see the heading "Analyze Problem Statements" and a form with two problem statement cards and a team size input.

Don't see the form?

  • Make sure you are signed in. The page redirects unauthenticated users to /sign-in.
  • Check that you created the files inside app/dashboard/ and not a different folder.
  • If you see a module-not-found error for ./actions, that is expected for now. You will create that file in the next substep.

Help me fix my dashboard page not loading.

Build the NVIDIA NIM helper function

Before creating the Server Action, you need a reusable function that handles the API call to NVIDIA NIM. This keeps your action code clean and makes the NIM integration testable independently.

  • Create a new file called nvidia.ts inside your lib folder and paste the following code:
const NVIDIA_API_URL = "https://integrate.api.nvidia.com/v1/chat/completions";
const MODEL_ID = "meta/llama-3.3-70b-instruct";

export async function callNvidiaNIM(
  systemPrompt: string,
  userPrompt: string
): Promise<string> {
  // POST to the NVIDIA NIM endpoint with OpenAI-compatible format
  const response = await fetch(NVIDIA_API_URL, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.NVIDIA_API_KEY}`,
    },
    body: JSON.stringify({
      model: MODEL_ID,
      messages: [
        { role: "system", content: systemPrompt },
        { role: "user", content: userPrompt },
      ],
      temperature: 0.3,
      top_p: 0.7,
      max_tokens: 2048,
      stream: false,
    }),
  });

  if (!response.ok) {
    throw new Error(`NVIDIA NIM API error: ${response.status}`);
  }

  // Extract the completion text from the OpenAI-compatible response
  const data = await response.json();
  return data.choices[0].message.content;
}

What does this helper do?

  • NVIDIA NIM uses the same request and response format as OpenAI. You send a messages array with system and user roles, and get back data.choices[0].message.content.
  • temperature: 0.3 and top_p: 0.7 keep the output focused and deterministic, which is important for structured JSON responses.
  • stream: false returns the full response at once instead of streaming tokens. This makes JSON parsing straightforward.

Why a separate helper file?

Isolating the API call in lib/nvidia.ts means you can reuse it for different prompts without duplicating the fetch logic. You will call this same function twice in this project: once for ranking and once for guide generation.

  • Save lib/nvidia.ts.

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

const NVIDIA_API_URL = "https://integrate.api.nvidia.com/v1/chat/completions";
const MODEL_ID = "meta/llama-3.3-70b-instruct";

export async function callNvidiaNIM(
  systemPrompt: string,
  userPrompt: string
): Promise<string> {
  const response = await fetch(NVIDIA_API_URL, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.NVIDIA_API_KEY}`,
    },
    body: JSON.stringify({
      model: MODEL_ID,
      messages: [
        { role: "system", content: systemPrompt },
        { role: "user", content: userPrompt },
      ],
      temperature: 0.3,
      top_p: 0.7,
      max_tokens: 2048,
      stream: false,
    }),
  });

  if (!response.ok) {
    throw new Error(`NVIDIA NIM API error: ${response.status}`);
  }

  const data = await response.json();
  return data.choices[0].message.content;
}
Create the analysis Server Action

The Server Action is where the form submission, AI call, database write, and redirect all come together. This file also includes a second action for guide generation that you will use in the next step.

  • In the app/dashboard folder, create a file called actions.ts and paste the following code:
"use server";

import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { callNvidiaNIM } from "@/lib/nvidia";

export async function analyzeStatements(formData: FormData) {
  // Verify the user is authenticated
  const { userId } = await auth();
  if (!userId) throw new Error("Unauthorized");

  // Extract form data
  const statements = JSON.parse(formData.get("statements") as string);
  const teamSize = Number(formData.get("teamSize"));

  // System prompt instructs the LLM to return structured JSON
  const systemPrompt = `You are a hackathon advisor. Analyze the given problem statements and return a JSON object with this exact structure:
{
  "rankings": [
    {
      "title": "problem statement title",
      "feasibility": 1-10,
      "uniqueness": 1-10,
      "complexity": 1-10,
      "reasoning": "brief explanation"
    }
  ],
  "recommendation": {
    "title": "recommended problem statement title",
    "reasoning": "why this is the best choice for a hackathon"
  }
}
Only return valid JSON, no other text.`;

  // Build the user prompt with all problem statements
  const userPrompt = `Here are the problem statements to analyze:\n\n${statements
    .map((s: { title: string; description: string }, i: number) => `${i + 1}. "${s.title}": ${s.description}`)
    .join("\n\n")}\n\nTeam size: ${teamSize} members`;

  // Call NVIDIA NIM and parse the JSON response
  const aiResponse = await callNvidiaNIM(systemPrompt, userPrompt);
  const ranking = JSON.parse(aiResponse);

  // Save the analysis to the database
  const analysis = await prisma.analysis.create({
    data: { userId, problemStatements: statements, teamSize, ranking },
  });

  redirect(`/dashboard/results/${analysis.id}`);
}

export async function generateGuide(analysisId: string, selectedPS: string) {
  // Verify authentication and ownership
  const { userId } = await auth();
  if (!userId) throw new Error("Unauthorized");

  const analysis = await prisma.analysis.findFirst({ where: { id: analysisId, userId } });
  if (!analysis) throw new Error("Analysis not found");

  // System prompt for project guide generation
  const systemPrompt = `You are a hackathon project planner. Given a selected problem statement and team size, generate a project guide. Return a JSON object with this exact structure:
{
  "techStack": ["technology1", "technology2", ...],
  "roadmap": [
    {"phase": "Phase 1", "title": "phase title", "tasks": ["task1", "task2"], "duration": "estimated time"}
  ],
  "taskDivision": [
    {"member": "Team Member 1", "tasks": ["task1", "task2"], "focus": "area of focus"}
  ]
}
Only return valid JSON, no other text.`;

  const userPrompt = `Problem statement: "${selectedPS}"\nTeam size: ${analysis.teamSize} members\n\nGenerate a detailed project guide for a hackathon.`;

  const aiResponse = await callNvidiaNIM(systemPrompt, userPrompt);
  const guideData = JSON.parse(aiResponse);

  // Save the guide linked to the analysis
  await prisma.guide.create({
    data: { analysisId, selectedPS, techStack: guideData.techStack, roadmap: guideData.roadmap, taskDivision: guideData.taskDivision },
  });

  redirect(`/dashboard/results/${analysisId}`);
}

What do these Server Actions do?

analyzeStatements is the action your form calls. It verifies the user is signed in, extracts the problem statements from FormData, sends them to NVIDIA NIM with a system prompt that demands structured JSON, parses the AI response, saves it to the database, and redirects to the results page.

generateGuide is a second action you will wire up in the next step. It takes a selected problem statement and asks the AI to produce a tech stack, roadmap, and task division plan for the team.

Why does the system prompt demand JSON only?

LLMs often add conversational text around their answers. By ending the prompt with "Only return valid JSON, no other text" and keeping temperature low at 0.3, you make JSON.parse() succeed reliably. This is a common pattern for structured AI output parsing.

  • Save app/dashboard/actions.ts.
  • Switch back to your browser and refresh localhost:3000/dashboard. The form should now load without any import errors.
  • Fill in 2 problem statements with any hackathon ideas (for example: "Smart Campus Navigation" and "AI Study Buddy"). Set the team size to 3 and click Analyze Problem Statements.
  • The button text changes to "Analyzing..." while the request processes. After a few seconds, the page attempts to redirect to a results URL. You will see a 404 page because the results page does not exist yet. That is expected.

Seeing an API error or the button stays on Analyzing?

  • Check that NVIDIA_API_KEY is set in your .env.local file. Restart the dev server after changing environment variables.
  • Confirm your NVIDIA API key is valid by checking the build.nvidia.com dashboard. Free tier keys have a 40 requests per minute limit.
  • If you see a JSON parse error in the terminal, the LLM may have returned extra text. Try submitting again. Low temperature helps but does not guarantee perfect JSON on every call.

Help me debug my NVIDIA NIM API call returning an error.

✔️ Awesome, I've got everything!

Great. Double check you have saved all files before moving on.

ⓧ I'd like to double check the full code

"use server";

import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { callNvidiaNIM } from "@/lib/nvidia";

export async function analyzeStatements(formData: FormData) {
  const { userId } = await auth();
  if (!userId) throw new Error("Unauthorized");

  const statements = JSON.parse(formData.get("statements") as string);
  const teamSize = Number(formData.get("teamSize"));

  const systemPrompt = `You are a hackathon advisor. Analyze the given problem statements and return a JSON object with this exact structure:
{
  "rankings": [
    {
      "title": "problem statement title",
      "feasibility": 1-10,
      "uniqueness": 1-10,
      "complexity": 1-10,
      "reasoning": "brief explanation"
    }
  ],
  "recommendation": {
    "title": "recommended problem statement title",
    "reasoning": "why this is the best choice for a hackathon"
  }
}
Only return valid JSON, no other text.`;

  const userPrompt = `Here are the problem statements to analyze:\n\n${statements
    .map((s: { title: string; description: string }, i: number) => `${i + 1}. "${s.title}": ${s.description}`)
    .join("\n\n")}\n\nTeam size: ${teamSize} members`;

  const aiResponse = await callNvidiaNIM(systemPrompt, userPrompt);
  const ranking = JSON.parse(aiResponse);

  const analysis = await prisma.analysis.create({
    data: { userId, problemStatements: statements, teamSize, ranking },
  });

  redirect(`/dashboard/results/${analysis.id}`);
}

export async function generateGuide(analysisId: string, selectedPS: string) {
  const { userId } = await auth();
  if (!userId) throw new Error("Unauthorized");

  const analysis = await prisma.analysis.findFirst({ where: { id: analysisId, userId } });
  if (!analysis) throw new Error("Analysis not found");

  const systemPrompt = `You are a hackathon project planner. Given a selected problem statement and team size, generate a project guide. Return a JSON object with this exact structure:
{
  "techStack": ["technology1", "technology2", ...],
  "roadmap": [
    {"phase": "Phase 1", "title": "phase title", "tasks": ["task1", "task2"], "duration": "estimated time"}
  ],
  "taskDivision": [
    {"member": "Team Member 1", "tasks": ["task1", "task2"], "focus": "area of focus"}
  ]
}
Only return valid JSON, no other text.`;

  const userPrompt = `Problem statement: "${selectedPS}"\nTeam size: ${analysis.teamSize} members\n\nGenerate a detailed project guide for a hackathon.`;

  const aiResponse = await callNvidiaNIM(systemPrompt, userPrompt);
  const guideData = JSON.parse(aiResponse);

  await prisma.guide.create({
    data: { analysisId, selectedPS, techStack: guideData.techStack, roadmap: guideData.roadmap, taskDivision: guideData.taskDivision },
  });

  redirect(`/dashboard/results/${analysisId}`);
}

Your form is live and talking to NVIDIA NIM. Next up, you will build the results page that displays the ranked analysis and lets you generate a full project guide.

Display Results and Generate Project Guides

Your analysis form submits problem statements to NVIDIA NIM and saves the ranked results to your database. The redirect at the end of analyzeStatements points to a results page that doesn't exist yet.

In this step, you'll build the results page that visualizes the AI's rankings and lets users generate a full project guide for their chosen problem statement. This is the core payoff of the app: turning raw AI output into actionable project plans.

In this step, get ready to:
  • Create a results page that displays ranked problem statements with scores.
  • Build a guide button component that triggers project guide generation.
  • Display the generated guide with tech stack, roadmap, and task division.
Create the results page

When the analysis form redirects to /dashboard/results/[id], Next.js looks for a page component at that dynamic route. This page needs to fetch the analysis, verify ownership, and render the ranked results.

  • Create the folder structure for the dynamic route by running this command:
  • Create the file app/dashboard/results/[id]/page.tsx and paste the following code:
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { GuideButton } from "./guide-button";

export default async function ResultsPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { userId } = await auth();
  if (!userId) redirect("/sign-in");

  const { id } = await params;

  const analysis = await prisma.analysis.findFirst({
    where: { id, userId },
    include: { guide: true },
  });

  if (!analysis) redirect("/dashboard");

  const ranking = analysis.ranking as {
    rankings: Array<{ title: string; feasibility: number; uniqueness: number; complexity: number; reasoning: string }>;
    recommendation: { title: string; reasoning: string };
  };

  return (
    <div className="space-y-6">
      <h1 className="text-2xl font-bold">Analysis Results</h1>
      <div className="bg-green-50 border border-green-200 rounded-lg p-4">
        <h2 className="font-semibold text-green-800">Recommendation</h2>
        <p className="text-green-700 mt-1">{ranking.recommendation.title}: {ranking.recommendation.reasoning}</p>
      </div>
      <div className="grid gap-4">
        {ranking.rankings.map((item, i) => (
          <div key={i} className="border rounded-lg p-4 bg-white">
            <h3 className="font-semibold">{item.title}</h3>
            <div className="flex gap-4 mt-2 text-sm">
              <span>Feasibility: {item.feasibility}/10</span>
              <span>Uniqueness: {item.uniqueness}/10</span>
              <span>Complexity: {item.complexity}/10</span>
            </div>
            <p className="text-gray-600 mt-2 text-sm">{item.reasoning}</p>
            {item.title === ranking.recommendation.title && !analysis.guide && (
              <GuideButton analysisId={analysis.id} selectedPS={item.title} />
            )}
          </div>
        ))}
      </div>
      {analysis.guide && (
        <div className="space-y-4 mt-8">
          <h2 className="text-xl font-bold">Project Guide</h2>
          <div className="border rounded-lg p-4 bg-white">
            <h3 className="font-semibold mb-2">Tech Stack</h3>
            <div className="flex flex-wrap gap-2">
              {(analysis.guide.techStack as string[]).map((tech, i) => (
                <span key={i} className="bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm">{tech}</span>
              ))}
            </div>
          </div>
          <div className="border rounded-lg p-4 bg-white">
            <h3 className="font-semibold mb-2">Roadmap</h3>
            {(analysis.guide.roadmap as Array<{ phase: string; title: string; tasks: string[]; duration: string }>).map((phase, i) => (
              <div key={i} className="mb-3">
                <h4 className="font-medium">{phase.phase}: {phase.title} ({phase.duration})</h4>
                <ul className="list-disc list-inside text-sm text-gray-600">
                  {phase.tasks.map((task, j) => <li key={j}>{task}</li>)}
                </ul>
              </div>
            ))}
          </div>
          <div className="border rounded-lg p-4 bg-white">
            <h3 className="font-semibold mb-2">Task Division</h3>
            {(analysis.guide.taskDivision as Array<{ member: string; tasks: string[]; focus: string }>).map((member, i) => (
              <div key={i} className="mb-3">
                <h4 className="font-medium">{member.member} - {member.focus}</h4>
                <ul className="list-disc list-inside text-sm text-gray-600">
                  {member.tasks.map((task, j) => <li key={j}>{task}</li>)}
                </ul>
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

What does this code do?

This is a Server Component that handles the entire results display. It runs on the server, so it can query the database directly.

  • params is a Promise in Next.js 16. You await it to get the dynamic [id] from the URL.
  • findFirst with both id and userId ensures users can only see their own analyses.
  • include: { guide: true } eagerly loads the related Guide record so you can display it without a second query.
  • The GuideButton only renders on the recommended card when no guide exists yet, preventing duplicate generation.
  • The guide section conditionally renders tech stack badges, roadmap phases, and task assignments when a guide has been generated.
  • Save app/dashboard/results/[id]/page.tsx.
  • The file won't compile yet because it imports GuideButton from ./guide-button. You'll create that next.
Create the guide button component

The guide button needs client-side interactivity for loading state, so it lives in its own "use client" component. It calls the generateGuide Server Action you created in the previous step.

  • Create app/dashboard/results/[id]/guide-button.tsx and paste the following code:
"use client";

import { useState } from "react";
import { generateGuide } from "../../actions";

export function GuideButton({
  analysisId,
  selectedPS,
}: {
  analysisId: string;
  selectedPS: string;
}) {
  const [loading, setLoading] = useState(false);

  async function handleClick() {
    setLoading(true);
    await generateGuide(analysisId, selectedPS);
  }

  return (
    <button onClick={handleClick} disabled={loading} className="mt-3 bg-green-600 text-white px-4 py-2 rounded font-medium text-sm disabled:opacity-50">
      {loading ? "Generating Guide..." : "Generate Project Guide"}
    </button>
  );
}

What does this code do?

This is a small client component whose only job is to manage a loading state while calling the server.

  • It imports generateGuide from ../../actions, which resolves to your existing app/dashboard/actions.ts file.
  • When clicked, the button disables itself and shows "Generating Guide..." while the AI processes the request.
  • Once the Server Action completes, it calls redirect() which refreshes the page with the new guide data.
  • Save app/dashboard/results/[id]/guide-button.tsx.

Seeing a module not found error?

  • Check that both files are inside app/dashboard/results/[id]/. The square brackets in the folder name are required for Next.js dynamic routes.
  • Make sure the import path in guide-button.tsx uses ../../actions (two levels up to reach the dashboard folder).
  • Verify page.tsx imports from ./guide-button (same folder, no extension).

Help me fix a module not found error in my Next.js dynamic route

Test the full analysis-to-guide flow

Both files are in place. Time to run through the complete workflow and see your results page in action.

  • Make sure your dev server is running (bun --bun run dev).
  • Navigate to localhost:3000/dashboard in your browser.
  • Fill in 2-3 problem statements with titles and descriptions.
  • Set a team size and click **Analyze Problem Statements**.
  • You should be redirected to a results page showing a green recommendation banner at the top, followed by cards with feasibility, uniqueness, and complexity scores for each problem statement.
  • Click **Generate Project Guide** on the recommended card.
  • After a few seconds, the page reloads with a Project Guide section showing tech stack badges, a phased roadmap, and a task division table.

Why does the page reload after generating a guide?

The generateGuide Server Action calls redirect() at the end, which triggers a full navigation back to the same URL. Since the page is a Server Component, it re-runs on the server with the fresh data, including the newly created Guide record.

Not seeing results or getting a blank page?

  • Check your terminal for errors. A JSON parse failure means the AI returned text that isn't valid JSON. Try submitting again.
  • If you see "Analysis not found" in the browser, check that you're signed in with the same account that created the analysis.
  • If the guide button does nothing, open the browser console for errors. The NVIDIA NIM API has a 40 RPM rate limit, so wait a moment if you've been testing rapidly.

Help me debug my results page

✔️ Awesome, I've got everything!

Great. Make sure both files are saved in app/dashboard/results/[id]/.

ⓧ I'd like to double check the full code

Here is app/dashboard/results/[id]/page.tsx:

import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { GuideButton } from "./guide-button";

export default async function ResultsPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { userId } = await auth();
  if (!userId) redirect("/sign-in");

  const { id } = await params;

  const analysis = await prisma.analysis.findFirst({
    where: { id, userId },
    include: { guide: true },
  });

  if (!analysis) redirect("/dashboard");

  const ranking = analysis.ranking as {
    rankings: Array<{ title: string; feasibility: number; uniqueness: number; complexity: number; reasoning: string }>;
    recommendation: { title: string; reasoning: string };
  };

  return (
    <div className="space-y-6">
      <h1 className="text-2xl font-bold">Analysis Results</h1>
      <div className="bg-green-50 border border-green-200 rounded-lg p-4">
        <h2 className="font-semibold text-green-800">Recommendation</h2>
        <p className="text-green-700 mt-1">{ranking.recommendation.title}: {ranking.recommendation.reasoning}</p>
      </div>
      <div className="grid gap-4">
        {ranking.rankings.map((item, i) => (
          <div key={i} className="border rounded-lg p-4 bg-white">
            <h3 className="font-semibold">{item.title}</h3>
            <div className="flex gap-4 mt-2 text-sm">
              <span>Feasibility: {item.feasibility}/10</span>
              <span>Uniqueness: {item.uniqueness}/10</span>
              <span>Complexity: {item.complexity}/10</span>
            </div>
            <p className="text-gray-600 mt-2 text-sm">{item.reasoning}</p>
            {item.title === ranking.recommendation.title && !analysis.guide && (
              <GuideButton analysisId={analysis.id} selectedPS={item.title} />
            )}
          </div>
        ))}
      </div>
      {analysis.guide && (
        <div className="space-y-4 mt-8">
          <h2 className="text-xl font-bold">Project Guide</h2>
          <div className="border rounded-lg p-4 bg-white">
            <h3 className="font-semibold mb-2">Tech Stack</h3>
            <div className="flex flex-wrap gap-2">
              {(analysis.guide.techStack as string[]).map((tech, i) => (
                <span key={i} className="bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm">{tech}</span>
              ))}
            </div>
          </div>
          <div className="border rounded-lg p-4 bg-white">
            <h3 className="font-semibold mb-2">Roadmap</h3>
            {(analysis.guide.roadmap as Array<{ phase: string; title: string; tasks: string[]; duration: string }>).map((phase, i) => (
              <div key={i} className="mb-3">
                <h4 className="font-medium">{phase.phase}: {phase.title} ({phase.duration})</h4>
                <ul className="list-disc list-inside text-sm text-gray-600">
                  {phase.tasks.map((task, j) => <li key={j}>{task}</li>)}
                </ul>
              </div>
            ))}
          </div>
          <div className="border rounded-lg p-4 bg-white">
            <h3 className="font-semibold mb-2">Task Division</h3>
            {(analysis.guide.taskDivision as Array<{ member: string; tasks: string[]; focus: string }>).map((member, i) => (
              <div key={i} className="mb-3">
                <h4 className="font-medium">{member.member} - {member.focus}</h4>
                <ul className="list-disc list-inside text-sm text-gray-600">
                  {member.tasks.map((task, j) => <li key={j}>{task}</li>)}
                </ul>
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

Here is app/dashboard/results/[id]/guide-button.tsx:

"use client";

import { useState } from "react";
import { generateGuide } from "../../actions";

export function GuideButton({
  analysisId,
  selectedPS,
}: {
  analysisId: string;
  selectedPS: string;
}) {
  const [loading, setLoading] = useState(false);

  async function handleClick() {
    setLoading(true);
    await generateGuide(analysisId, selectedPS);
  }

  return (
    <button onClick={handleClick} disabled={loading} className="mt-3 bg-green-600 text-white px-4 py-2 rounded font-medium text-sm disabled:opacity-50">
      {loading ? "Generating Guide..." : "Generate Project Guide"}
    </button>
  );
}

Your results page is live and generating project guides on demand. Next up, you'll build a history dashboard so users can revisit all their past analyses.

Build the Analysis History Page

Your app can analyze problem statements and generate project guides. But once you navigate away from the results, there is no way to find them again.

In this step, you will build a history page that queries all past analyses for the logged-in user and displays them as clickable cards. This closes the loop on the full user workflow.

In this step, get ready to:
  • Create a protected history page that queries all past analyses.
  • Display each analysis as a card linking back to its results.
  • Verify the full workflow with multiple analyses.
Create the history page

Every analysis your app creates is stored in the Neon database with the user's ID. The history page queries those records and renders each one as a navigable card.

  • Create a new folder called history inside app/dashboard/.
  • Inside that folder, create a file called page.tsx and paste the following code:
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
import Link from "next/link";
import { prisma } from "@/lib/prisma";

export default async function HistoryPage() {
  const { userId } = await auth();
  if (!userId) redirect("/sign-in");

  // Query all analyses belonging to this user, newest first
  const analyses = await prisma.analysis.findMany({
    where: { userId },
    orderBy: { createdAt: "desc" },
    include: { guide: true },
  });

  return (
    <div>
      <h1 className="text-2xl font-bold mb-6">Analysis History</h1>

      {analyses.length === 0 ? (
        <p className="text-gray-500">
          No analyses yet.{" "}
          <Link href="/dashboard" className="text-blue-600">
            Create your first one.
          </Link>
        </p>
      ) : (
        <div className="space-y-3">
          {analyses.map((analysis) => {
            const ranking = analysis.ranking as {
              recommendation: { title: string };
            };
            const statements = analysis.problemStatements as Array<{
              title: string;
            }>;

            return (
              <Link
                key={analysis.id}
                href={`/dashboard/results/${analysis.id}`}
                className="block border rounded-lg p-4 bg-white hover:border-blue-300 transition-colors"
              >
                <div className="flex justify-between items-center">
                  <div>
                    <p className="font-medium">
                      {ranking.recommendation.title}
                    </p>
                    <p className="text-sm text-gray-500">
                      {statements.length} problem statements analyzed | Team of{" "}
                      {analysis.teamSize}
                    </p>
                  </div>
                  <div className="text-right">
                    <p className="text-sm text-gray-400">
                      {analysis.createdAt.toLocaleDateString()}
                    </p>
                    {analysis.guide && (
                      <span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded">
                        Guide generated
                      </span>
                    )}
                  </div>
                </div>
              </Link>
            );
          })}
        </div>
      )}
    </div>
  );
}

What does this code do?

  • auth() protects the page. If no user is signed in, they get redirected to /sign-in.
  • prisma.analysis.findMany fetches every analysis belonging to the current user, sorted newest first. The include: { guide: true } eager-loads the related Guide so you can check if one exists.
  • Each card is wrapped in a Link pointing to /dashboard/results/[id], so clicking any card takes the user straight back to their full results and guide.
  • The empty state shows a friendly link back to the dashboard so new users are never stuck on a blank page.
  • Save app/dashboard/history/page.tsx.
  • In your browser, click the History link in the nav bar.
  • If you already ran an analysis earlier, you should see a card showing the recommended problem statement title, the number of statements analyzed, and the date. If you have not run one yet, you should see the empty state message with a link to create your first analysis.

Seeing a 404 on /dashboard/history?

  • Confirm your file is at exactly app/dashboard/history/page.tsx. The folder name must be history (not History) and the file must be named page.tsx.
  • Make sure your dev server is still running. If it crashed, restart it with bun --bun run dev.

Still stuck? Ask for help.

Test the full history workflow

A single card is useful, but the real value of a history page shows when there are multiple entries. Create another analysis so you can confirm the list populates correctly.

  • Click New Analysis in the nav bar.
  • Fill in 2-3 different problem statements with new titles and descriptions.
  • Click Analyze Problem Statements and wait for the results page to load.
  • Click History in the nav bar.

You should now see multiple cards listed with the newest analysis at the top. Each card shows the recommended problem statement title, the number of statements analyzed, the team size, and the creation date.

  • Click on any history card to confirm it takes you to the correct /dashboard/results/[id] page with that analysis's rankings and guide.

What about the 'Guide generated' badge?

If you generated a project guide for any of your analyses in the previous step, that card displays a small green badge reading "Guide generated". Cards without a guide do not show the badge. This helps you quickly see which analyses are complete.

Cards not showing any data?

  • Check that you are signed in with the same account you used to create the analyses. The query filters by userId, so a different account sees an empty list.
  • Open your terminal and check for errors. A common issue is a typo in the type assertions for analysis.ranking or analysis.problemStatements.

Help me debug my history page.

✔️ Awesome, I've got everything!

Great. Double check you have saved app/dashboard/history/page.tsx and that your history page loads correctly.

ⓧ I'd like to double check the full code

Here is the complete code for app/dashboard/history/page.tsx:

import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
import Link from "next/link";
import { prisma } from "@/lib/prisma";

export default async function HistoryPage() {
  const { userId } = await auth();
  if (!userId) redirect("/sign-in");

  const analyses = await prisma.analysis.findMany({
    where: { userId },
    orderBy: { createdAt: "desc" },
    include: { guide: true },
  });

  return (
    <div>
      <h1 className="text-2xl font-bold mb-6">Analysis History</h1>

      {analyses.length === 0 ? (
        <p className="text-gray-500">
          No analyses yet.{" "}
          <Link href="/dashboard" className="text-blue-600">
            Create your first one.
          </Link>
        </p>
      ) : (
        <div className="space-y-3">
          {analyses.map((analysis) => {
            const ranking = analysis.ranking as {
              recommendation: { title: string };
            };
            const statements = analysis.problemStatements as Array<{
              title: string;
            }>;

            return (
              <Link
                key={analysis.id}
                href={`/dashboard/results/${analysis.id}`}
                className="block border rounded-lg p-4 bg-white hover:border-blue-300 transition-colors"
              >
                <div className="flex justify-between items-center">
                  <div>
                    <p className="font-medium">
                      {ranking.recommendation.title}
                    </p>
                    <p className="text-sm text-gray-500">
                      {statements.length} problem statements analyzed | Team of{" "}
                      {analysis.teamSize}
                    </p>
                  </div>
                  <div className="text-right">
                    <p className="text-sm text-gray-400">
                      {analysis.createdAt.toLocaleDateString()}
                    </p>
                    {analysis.guide && (
                      <span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded">
                        Guide generated
                      </span>
                    )}
                  </div>
                </div>
              </Link>
            );
          })}
        </div>
      )}
    </div>
  );
}

Your app now has the complete user workflow: sign in, submit problem statements, view ranked results, generate a project guide, and revisit any past analysis from the history page. That is a full-featured AI SaaS application. Next up, you will tackle the Secret Mission to make the task assignments even smarter.

Secret mission

Team Skills Customization

Your guide generation assigns tasks generically to "Team Member 1", "Team Member 2", and so on. What if the AI knew each person's actual skills and could match tasks to strengths? In this secret mission, you'll add a team members input so HackPick generates truly personalized task assignments.

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 only free-tier services, so there are no ongoing costs regardless of which option you choose.

Resources you used:

  • Clerk application (free tier, no charges).
  • Neon PostgreSQL database (free tier, 0.5 GB storage limit).
  • NVIDIA Developer Program API key (free, 40 RPM limit).
  • Local hackpick project folder on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or want to extend HackPick with more features.

  • All three services (Clerk, Neon, NVIDIA NIM) are on free tiers with generous limits.
  • Your Neon database persists your analysis history automatically.
  • The Clerk CLI remains installed globally on your machine via Bun.

Pause - I'll come back to this later

There is no persistent compute to pause. Your database and authentication persist automatically on their free tiers.

  • Stop the dev server by pressing Ctrl+C in your terminal.
  • Your Neon database, Clerk app, and NVIDIA API key all remain active at no cost.
  • When you return, run bun --bun run dev from the hackpick folder to pick up where you left off.

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 your local project files by running:
cd ~/Desktop
rm -rf hackpick
  • Remove the globally installed Clerk CLI by running:
bun remove -g clerk
  • Delete your Clerk application: sign in to your Clerk dashboard at clerk.com, select your HackPick application, open Settings, scroll to the bottom, and click Delete application.
  • Delete your Neon database: sign in to the Neon console at console.neon.tech, select your project, open Settings, and click Delete project.
  • Revoke your NVIDIA API key: visit build.nvidia.com, sign in, navigate to your API keys, and delete the key you generated for this project.

Nice Work!

Nice Work!

You just built HackPick, a full-stack AI SaaS application that helps hackathon teams pick the best problem statement and divide work efficiently.

You've learned how to:

  • Built a full-stack AI SaaS application with Next.js 16, Bun, and Clerk authentication protecting routes via proxy.ts.
  • Designed and deployed a PostgreSQL schema with Prisma on Neon, storing analyses and generated guides tied to authenticated users.
  • Integrated NVIDIA NIM's OpenAI-compatible API for two distinct AI workflows: ranking problem statements by feasibility, uniqueness, and complexity, then generating a project guide with tech stack, roadmap, and task division.
  • Secret Mission: Added personalized task assignment based on each team member's skills and strengths.

Ready to quiz yourself?