CSV-Powered Analytics Dashboard
Build a full-stack Next.js dashboard with OAuth, PostgreSQL, and D3.js charts.
Introduction
30 Second Summary
Think about all the CSV files you download from apps, export from spreadsheets, or receive from teammates. Without a tool to visualize that data, those files just sit there collecting dust.
In this project, you will build a full-stack analytics dashboard called "DataVault" that lets authenticated users upload CSV files and instantly see their data rendered as interactive charts.
What You'll Build
DataVault is a Next.js application with GitHub OAuth login, PostgreSQL database storage via Prisma, CSV parsing with PapaParse, and interactive D3.js bar and line charts.
By the end of this project, you'll have:
- A GitHub OAuth login flow that authenticates users with Auth.js v5 and stores sessions in a database.
- A CSV upload pipeline that parses files client-side and persists structured data to PostgreSQL via Prisma.
- Interactive D3.js charts (bar and line) with hover tooltips that visualize uploaded datasets.
- Protected routes enforced by middleware-based authentication that redirect unauthenticated visitors.
- Secret Mission: Add real-time upload progress streaming with Server-Sent Events.
Are there any prerequisites?
You should be comfortable with React, TypeScript, and basic Next.js concepts like pages, components, and routing. Familiarity with npm is also expected.
No prior experience with databases, authentication, or D3.js is needed. This project teaches those from scratch.
Before We Start
You're about to build a full-stack analytics dashboard that lets authenticated users upload CSV data and visualize it as interactive charts. This project teaches you the backend fundamentals that separate a frontend developer from a full-stack developer.
Before writing any code, you need two external accounts configured. Auth.js (the authentication library) needs a GitHub OAuth App to handle login. Prisma (the database toolkit) needs a Neon PostgreSQL database to store user data.
In this step, get ready to:
- Create a free Neon account for your PostgreSQL database.
- Create a GitHub OAuth App and note your Client ID and Client Secret.
Create a free Neon account
Neon is a serverless PostgreSQL provider with a generous free tier. Your app will store users, sessions, and uploaded datasets here.
- Open your browser and navigate to https://neon.tech.
- Click Sign Up and create an account using your GitHub login or email.
No credit card is required. The free tier includes 0.5 GB of storage and uses scale-to-zero instead of pausing entirely.
Why Neon over other databases?
Neon's free tier uses scale-to-zero instead of pausing entirely after inactivity. Other providers like Supabase pause your database after one week of no activity, which breaks a learning project you might return to sporadically.
You do not need to create a database yet. You will provision one in a later step when you set up Prisma.
Create a GitHub OAuth App
GitHub OAuth lets your app's users sign in with their GitHub account instead of creating a new username and password. You need to register an OAuth App so GitHub knows where to redirect users after they authorize your app.
- Go to https://github.com/settings/developers (or navigate via your profile picture, then Settings, then Developer settings in the left sidebar).
- Click OAuth Apps in the left sidebar.
- Click New OAuth App.
Don't see New OAuth App?
If you have never created an OAuth app before, the button will say Register a new application instead. Click that button to proceed.
- In the Application name field, type DataVault.
- In the Homepage URL field, type http://localhost:3000.
- In the Authorization callback URL field, type http://localhost:3000/api/auth/callback/github.
- Click Register application.
What is the Authorization callback URL?
After a user approves your app on GitHub, GitHub redirects them back to this URL with an authorization code. Auth.js listens at this exact path to complete the sign-in flow.
After registration, GitHub shows your new app's details page.
- Copy the Client ID shown at the top of the page and save it somewhere safe (a text file or notes app).
- Click Generate a new client secret.
- Copy the generated secret immediately and save it alongside your Client ID.
Keep your Client Secret private
The Client Secret is shown only once. If you lose it, you will need to generate a new one. Never commit it to version control or share it publicly.
Your accounts are ready. Next up, you will scaffold the Next.js project and install all the dependencies your dashboard needs.
Set Up the Project and Install Dependencies
DataVault combines several tools into one full-stack app. You need Next.js for the framework, Auth.js for authentication, Prisma for the database, and D3.js for charts.
Before building any features, you'll scaffold the project, install every dependency, and confirm the development server starts cleanly.
In this step, get ready to:
- Scaffold a Next.js 16 project with TypeScript and Tailwind CSS.
- Install all project dependencies (auth, database, charting, and CSV parsing libraries).
- Verify the dev server runs at localhost:3000.
Scaffold the Next.js project
The create-next-app CLI scaffolds a complete Next.js project in one command. It sets up TypeScript, Tailwind CSS, the App Router, and Turbopack (a fast bundler) automatically.
- 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
- Scaffold the DataVault project by running this command:
npx create-next-app@latest datavault --yes
What does this command do?
npx create-next-app@latest datavault --yes creates a new folder called datavault on your Desktop with a complete Next.js project inside.
The --yes flag skips all the setup prompts and uses the recommended defaults: TypeScript, Tailwind CSS, ESLint, App Router, and Turbopack.
- Move into the new project folder by running:
cd datavault
- Confirm the folder was created by running:
ls
You should see files like package.json, tsconfig.json, and an app folder in the output.
Command not found or download error?
Make sure Node.js is installed and up to date. Run node --version to confirm it's version 20.9 or higher.
If npx hangs, check your internet connection. The command needs to download the scaffolding tool from npm.
help me fix an error running create-next-app
Now open the project in VS Code so you can use its integrated terminal for the rest of the setup.
- 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.
- Navigate to your Desktop, select the datavault folder, then click Open.
Install the project dependencies
The scaffolded project already has Next.js and React. You still need the libraries for authentication, database access, CSV parsing, and data visualization.
- Open VS Code's integrated terminal by pressing Ctrl+` (backtick key, both macOS and Windows).
- Install the production dependencies by running this command:
npm install next-auth@beta @auth/prisma-adapter @prisma/client d3 papaparse
What are these packages?
- next-auth@beta is Auth.js v5 for Next.js. It handles OAuth login flows like GitHub sign-in.
- @auth/prisma-adapter connects Auth.js to your PostgreSQL database so user sessions persist.
- @prisma/client is the auto-generated database client that lets your app read and write data.
- d3 is the D3.js visualization library for building interactive charts.
- papaparse is PapaParse, a CSV parsing library that turns spreadsheet files into structured data.
- Install the development dependencies by running this command:
npm install prisma @types/d3 @types/papaparse --save-dev
Why are these separate?
The --save-dev flag marks packages as development-only. prisma is a CLI tool for managing your database schema. The @types/ packages give TypeScript type definitions so your editor provides autocomplete and catches errors.
None of these ship to production. They only help you during development.
- Confirm your dependencies installed correctly by checking package.json. Open it from the VS Code Explorer sidebar on the left.
You should see next-auth, d3, and papaparse listed under dependencies. You should see prisma and the @types/ packages under devDependencies.
Missing packages in package.json?
Make sure you ran both npm install commands. The first installs production packages. The second installs dev-only packages.
If you see permission errors on macOS, try prefixing the command with sudo. On Windows, run your terminal as Administrator.
help me fix npm install errors in my datavault project
✔️ Awesome, I've got everything!
Great. Make sure you've saved package.json (it auto-saves after npm install, so no action needed here).
ⓧ I'd like to double check the full code
Your package.json should look similar to this. The exact version numbers may differ, but the package names should match:
{
"name": "datavault",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@auth/prisma-adapter": "latest",
"@prisma/client": "latest",
"d3": "latest",
"next": "16",
"next-auth": "latest",
"papaparse": "latest",
"react": "latest",
"react-dom": "latest"
},
"devDependencies": {
"@types/d3": "latest",
"@types/node": "latest",
"@types/papaparse": "latest",
"@types/react": "latest",
"@types/react-dom": "latest",
"autoprefixer": "latest",
"postcss": "latest",
"prisma": "latest",
"tailwindcss": "latest",
"typescript": "latest"
}
}
Start the development server
With everything installed, you can now verify that the scaffolded project runs correctly on your machine.
- Start the development server by running this command in your VS Code terminal:
npm run dev
- Open your browser and go to http://localhost:3000.
You should see the default Next.js welcome page with links to documentation, examples, and a prompt to start editing app/page.tsx.
Don't see the welcome page?
Check your terminal for error messages. A common issue is port 3000 already being in use by another application. If so, Next.js will automatically try port 3001 and print the URL in the terminal.
Make sure you're inside the datavault folder when you run the command. Run pwd (macOS) or cd (Windows) to check your current location.
help me get the Next.js dev server running
Your project foundation is solid. Next up, you'll connect a real database with Neon and define the data models that power DataVault.
Set Up Neon Database and Prisma
Your Next.js app needs a database to store user accounts, uploaded datasets, and data points.
In this step, you'll provision a free PostgreSQL database on Neon and connect it to your project using Prisma, an ORM that lets you define your database tables as TypeScript models.
In this step, get ready to:
- Provision a PostgreSQL database on Neon.
- Configure Prisma and define your data schema.
- Run your first migration to create database tables.
Provision a PostgreSQL database on Neon
Neon provides serverless PostgreSQL databases that scale to zero when idle. The free tier gives you 0.5 GB of storage with no credit card required.
- Open your browser and navigate to the Neon Console at console.neon.tech.
- Click New Project.
- Enter datavault as the Project Name.
- Leave Postgres version as the default (Postgres 17).
- Select a Region close to you (e.g. US East 1).
- Click Create Project.
After creation, Neon shows a connection string on the Project Dashboard. You'll need this in the next substep.
- On the Project Dashboard, locate the Connection string section.
- Click the copy icon to copy the full connection string. It looks like postgresql://user:password@ep-xyz.us-east-1.aws.neon.tech/neondb?sslmode=require.
What is a connection string?
A connection string is a URL that contains everything your app needs to connect to a database: the username, password, host, and database name. Prisma reads this from an environment variable so your credentials stay out of your code.
Don't see a connection string?
Click the Connect button in the top-right of your Neon dashboard. Make sure Node.js is selected as the framework, then copy the connection string shown.
help me find my Neon connection string
Configure Prisma with the Neon connection string and define the schema
Prisma needs three things to work: a config file that tells the CLI where to find your database URL, a schema file that defines your data models, and the connection string itself stored in an environment variable.
- Initialize Prisma in your project by running this command in your terminal:
npx prisma init --datasource-provider postgresql --generator-provider prisma-client-js
What does this command do?
This creates a prisma/schema.prisma file where you define your data models, a prisma.config.ts file that configures the Prisma CLI, and a .env file for your database URL.
The --generator-provider prisma-client-js flag tells Prisma to generate the client into node_modules, which simplifies imports.
- Install the dotenv package by running:
npm install dotenv
Why install dotenv?
Prisma 7 does not load environment variables from .env files automatically. The dotenv package handles this. The generated prisma.config.ts already includes import "dotenv/config" which loads your .env file when running Prisma CLI commands.
Next.js uses .env.local for local environment variables that should never be committed to Git. You'll store your database URL there instead of the generated .env file.
- Open prisma.config.ts in VS Code. Replace its contents with the following:
import { config } from "dotenv";
// Load .env.local so both Prisma CLI and Next.js use the same file
config({ path: ".env.local" });
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: env("DATABASE_URL"),
},
});
What does this config do?
This file tells the Prisma CLI where to find your schema, where to store migrations, and where to read the database URL from. The config({ path: ".env.local" }) line loads variables from .env.local instead of the default .env.
- Create a new file called .env.local in the root of your datavault folder.
- Paste the following into .env.local, replacing the placeholder with your Neon connection string:
DATABASE_URL="your-neon-connection-string-here"
# AUTH_SECRET and GitHub OAuth vars to be added in next step
Keep your connection string safe
The .env.local file is already listed in .gitignore by the Next.js scaffold. Your database credentials will never be committed to version control.
- Delete the generated .env file that prisma init created (all your env vars live in .env.local now).
Now define your database schema. This is the heart of your data layer.
- Open prisma/schema.prisma and replace its entire contents with:
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?
accounts Account[]
sessions Session[]
datasets Dataset[]
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
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 VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
model Dataset {
id String @id @default(cuid())
name String
userId String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
dataPoints DataPoint[]
}
model DataPoint {
id String @id @default(cuid())
datasetId String
label String
value Float
dataset Dataset @relation(fields: [datasetId], references: [id], onDelete: Cascade)
}
What do these models represent?
- User, Account, Session, and VerificationToken are required by Auth.js to store authentication data. You'll wire these up in the next step.
- Dataset represents an uploaded CSV file. Each dataset belongs to a user and has a name and timestamp.
- DataPoint holds individual rows from the CSV. Each point has a label (the x-axis value) and a numeric value (the y-axis value).
- Save prisma/schema.prisma.
Seeing red underlines in the schema file?
VS Code may show errors if the Prisma extension is not installed. Search for Prisma in the Extensions panel and install the official Prisma extension for syntax highlighting and auto-formatting.
help me fix Prisma schema errors in VS Code
Run the migration to create database tables
A migration takes your schema models and turns them into actual PostgreSQL tables. Prisma generates SQL migration files and runs them against your Neon database.
- Run your first migration by executing this command:
npx prisma migrate dev --name init
What does this command do?
The migrate dev command compares your schema to the current database state, generates a SQL migration file, applies it to your Neon database, and generates the Prisma Client.
The --name init flag gives this migration a descriptive name so you can identify it later.
- Wait for the command to complete. You should see output confirming the migration was applied and the Prisma Client was generated.
Seeing a connection error?
Double-check that your DATABASE_URL in .env.local exactly matches what Neon gave you. The string must include ?sslmode=require at the end.
Also verify that prisma.config.ts loads .env.local with the config({ path: ".env.local" }) line at the top.
help me fix my Prisma database connection error
- Verify the tables were created by running:
npx prisma studio
Prisma Studio opens in your browser showing all six tables: User, Account, Session, VerificationToken, Dataset, and DataPoint. They're empty for now, but they're ready to store data.
- Close Prisma Studio by pressing Ctrl+C in your terminal.
✔️ Awesome, I've got everything!
Great. Double check you've saved both prisma.config.ts and prisma/schema.prisma.
ⓧ I'd like to double check the full code
Here is the complete prisma/schema.prisma file:
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?
accounts Account[]
sessions Session[]
datasets Dataset[]
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
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 VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
model Dataset {
id String @id @default(cuid())
name String
userId String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
dataPoints DataPoint[]
}
model DataPoint {
id String @id @default(cuid())
datasetId String
label String
value Float
dataset Dataset @relation(fields: [datasetId], references: [id], onDelete: Cascade)
}
And here is .env.local (with your own Neon connection string):
DATABASE_URL="postgresql://<user>:<password>@<host>.neon.tech/<dbname>?sslmode=require"
# AUTH_SECRET and GitHub OAuth vars to be added in next step
Your database is live and your schema is ready. Next up, you'll configure GitHub OAuth so users can log in and own the datasets they upload.
Configure GitHub OAuth and Auth.js
Your database is ready and your GitHub OAuth App is registered. Now you need to connect the two so users can actually sign into your app.
In this step, you'll configure Auth.js v5 to handle the full OAuth flow. Auth.js manages the handshake between your app and GitHub, stores user data in your database via the Prisma adapter, and issues encrypted JWTs so your app knows who is logged in.
In this step, get ready to:
- Add your GitHub OAuth credentials and auth secret to the project.
- Create the Auth.js configuration with the GitHub provider and Prisma adapter.
- Wire up the catch-all API route and test login/logout.
Update your GitHub OAuth App callback URL and add credentials
When a user clicks "Sign in with GitHub", GitHub redirects them back to a specific URL in your app after they approve. Auth.js expects this callback URL to follow a specific pattern.
- Return to your GitHub OAuth App settings page.
- Click Settings in the top-right menu of your GitHub profile.
- Scroll down to Developer settings in the left sidebar.
- Click OAuth Apps, then click your app name.
- In the Authorization callback URL field, enter http://localhost:3000/api/auth/callback/github.
- Click Update application.
Why this specific callback URL?
Auth.js automatically creates API routes under /api/auth/. The pattern /api/auth/callback/[provider] is where Auth.js expects GitHub to redirect users after they approve the OAuth request.
- Generate an AUTH_SECRET value by running this command in your terminal:
npx auth secret
What does this command do?
This uses the official Auth.js CLI to generate a cryptographically secure random string. Auth.js uses this secret to encrypt JWTs and sign cookies. The command automatically adds the value to your .env.local file.
- Open .env.local and verify that AUTH_SECRET was added.
- Add your GitHub OAuth credentials below it so the full file looks like this:
DATABASE_URL="postgresql://<user>:<password>@<host>.neon.tech/<dbname>?sslmode=require"
AUTH_SECRET="your-generated-secret-value"
GITHUB_CLIENT_ID="your-github-client-id-here"
GITHUB_CLIENT_SECRET="your-github-client-secret-here"
Where do I find my Client ID and Client Secret?
On your GitHub OAuth App page, the Client ID is displayed at the top. For the Client Secret, click Generate a new client secret if you haven't already.
Copy each value and paste it into your .env.local file, replacing the placeholder text.
- Save .env.local.
Auth secret not generated?
If npx auth secret did not add the value automatically, you can generate one manually with openssl rand -base64 33 in your terminal. Paste the output as the AUTH_SECRET value in .env.local.
help me set up my AUTH_SECRET for Auth.js
Set up Auth.js v5 configuration with the GitHub provider
Auth.js v5 uses a single configuration file at your project root. This file exports everything your app needs for authentication: route handlers, a session helper, and sign-in/sign-out server actions.
Before creating the auth config, you need a Prisma client singleton. This prevents creating a new database connection on every request during development.
- Create a new folder called lib inside your datavault project folder (at the same level as app and prisma).
- Create a new file called prisma.ts inside lib/ and add the following code:
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: ['query'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
What does this code do?
In development, Next.js hot-reloads your code frequently. Without this singleton pattern, each reload would create a brand new database connection. This file stores the Prisma client on the global object so it persists across reloads.
The log: ['query'] option prints SQL queries to your terminal. This is helpful for debugging database interactions during development.
- Save lib/prisma.ts.
- Create a new file called auth.ts at the root of your datavault project folder (at the same level as package.json) and add the following 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_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
],
session: { strategy: 'jwt' },
callbacks: {
session({ session, token }) {
if (token.sub) session.user.id = token.sub
return session
},
},
})
What does this code do?
- PrismaAdapter(prisma) connects Auth.js to your Neon database. When a user logs in for the first time, Auth.js creates a User and Account record automatically.
- GitHub({...}) configures the GitHub OAuth provider with your app credentials.
- session: { strategy: 'jwt' } tells Auth.js to use encrypted JSON Web Tokens instead of database sessions. This is faster because it avoids a database query on every page load.
- The session callback injects the user's database ID into the session object. This lets you use session.user.id later to query datasets owned by that user.
- Save auth.ts.
Seeing a TypeScript error on session.user.id?
TypeScript may warn that id doesn't exist on the session user type. This is expected for now. The app will still compile and run correctly because token.sub always exists at runtime.
help me fix the TypeScript error on session.user.id in Auth.js
Create the catch-all API route and test login/logout
Auth.js needs a Route Handler to process login and callback requests from GitHub. This single file handles all auth-related API endpoints.
- Create the folder path app/api/auth/[...nextauth]/ inside your project.
- Create a new file called route.ts inside that folder and add the following code:
import { handlers } from '@/auth'
export const { GET, POST } = handlers
What does this code do?
The [...nextauth] folder name is a Next.js catch-all route. It matches any URL under /api/auth/ (like /api/auth/signin, /api/auth/callback/github, etc.).
The handlers export from your auth.ts contains the GET and POST functions that Auth.js needs to handle OAuth flows.
- Save app/api/auth/[...nextauth]/route.ts.
- Start your dev server by running:
npm run dev
- Open your browser and navigate to http://localhost:3000/api/auth/signin.
You should see a sign-in page with a Sign in with GitHub button.
- Click Sign in with GitHub.
- GitHub will ask you to authorize the app. Click Authorize.
- After authorization, you'll be redirected back to http://localhost:3000.
- Verify the session is working by navigating to http://localhost:3000/api/auth/session.
You should see a JSON response containing your GitHub name, email, and profile image. This confirms Auth.js, GitHub OAuth, and your database are all connected.
- Test sign-out by navigating to http://localhost:3000/api/auth/signout and clicking the sign-out button.
Getting an OAuth error from GitHub?
Double check that the Authorization callback URL in your GitHub OAuth App settings is exactly http://localhost:3000/api/auth/callback/github. A trailing slash or typo will cause GitHub to reject the redirect.
Also verify that GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET in .env.local match the values on your GitHub OAuth App page. Restart the dev server after making changes to .env.local.
help me debug my GitHub OAuth error in Auth.js
✔️ Awesome, I've got everything!
Great. Double check you've saved all files before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each file created in this step:
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: ['query'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
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_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
],
session: { strategy: 'jwt' },
callbacks: {
session({ session, token }) {
if (token.sub) session.user.id = token.sub
return session
},
},
})
import { handlers } from '@/auth'
export const { GET, POST } = handlers
DATABASE_URL="postgresql://<user>:<password>@<host>.neon.tech/<dbname>?sslmode=require"
AUTH_SECRET="<generated-secret>"
GITHUB_CLIENT_ID="<github-client-id>"
GITHUB_CLIENT_SECRET="<github-client-secret>"
Authentication is wired up. Next, you'll build the protected dashboard layout that only signed-in users can see.
Build the Protected Dashboard Layout
Your authentication system is configured and tested. Now you need to actually protect your dashboard so that only signed-in users can see it.
In this step, you'll create a middleware layer that intercepts requests and redirects unauthenticated visitors to a login page. You'll also build the login page itself and a dashboard layout that shows the signed-in user's identity.
In this step, get ready to:
- Create middleware that redirects unauthenticated users away from the dashboard.
- Build a dashboard layout with a sidebar showing the user's avatar and a sign-out button.
- Create a login page with a "Sign in with GitHub" button.
Create the auth middleware
Next.js middleware runs before every matched request. It can check whether the user is authenticated and redirect them if they are not.
- Create a new file called middleware.ts in the root of your project (the same level as auth.ts).
- Add the following code to middleware.ts:
import { auth } from '@/auth'
import { NextResponse } from 'next/server'
// Wrap auth around the middleware to access req.auth
export default auth((req) => {
const isLoggedIn = !!req.auth
const isOnDashboard = req.nextUrl.pathname.startsWith('/dashboard')
// Redirect unauthenticated users to login
if (isOnDashboard && !isLoggedIn) {
return NextResponse.redirect(new URL('/login', req.url))
}
return NextResponse.next()
})
// Only run middleware on dashboard routes
export const config = {
matcher: ['/dashboard/:path*'],
}
What does this code do?
The auth function from Auth.js v5 wraps your middleware and attaches the current session to req.auth. If the user visits any /dashboard route without a session, they get redirected to /login.
The matcher config tells Next.js to only run this middleware on /dashboard routes. This keeps other pages (like the login page itself) accessible to everyone.
- Save middleware.ts.
Why protect routes in middleware instead of each page?
Middleware gives you a single place to enforce authentication across all dashboard routes. Without it, you would need to add session checks to every page and API route individually.
You should still verify the session inside pages that fetch data. Middleware is a first line of defence, not the only one.
Build the dashboard layout and placeholder page
Next.js layouts wrap all pages in a route segment. A layout at app/dashboard/layout.tsx will surround every page inside the /dashboard folder.
- Create a new folder called dashboard inside your app directory.
- Inside app/dashboard/, create a file called layout.tsx and add the following code:
import { auth, signOut } from '@/auth'
import { redirect } from 'next/navigation'
import Image from 'next/image'
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
// Get the current user session on the server
const session = await auth()
if (!session?.user) redirect('/login')
return (
<div className="flex min-h-screen">
<aside className="w-64 bg-gray-900 text-white flex flex-col p-6">
<h1 className="text-xl font-bold mb-8">DataVault</h1>
<div className="flex items-center gap-3 mb-6">
{session.user.image && (
<Image
src={session.user.image}
alt="avatar"
width={36}
height={36}
className="rounded-full"
/>
)}
<span className="text-sm font-medium">{session.user.name}</span>
</div>
<form
action={async () => {
'use server'
await signOut({ redirectTo: '/login' })
}}
className="mt-auto"
>
<button
type="submit"
className="text-sm text-gray-400 hover:text-white transition"
>
Sign out
</button>
</form>
</aside>
<main className="flex-1 bg-gray-50 p-8">{children}</main>
</div>
)
}
What does this code do?
- auth() fetches the current session on the server. If there is no session, the user is redirected to /login.
- The sidebar displays the user's GitHub avatar using the Next.js Image component and their name from the session.
- The sign-out button uses a Server Action that calls signOut from Auth.js. After signing out, the user lands back on the login page.
- mt-auto pushes the sign-out button to the bottom of the sidebar.
Now create a simple placeholder page that the layout will wrap.
- Inside app/dashboard/, create a file called page.tsx and add the following code:
export default function DashboardPage() {
return (
<div>
<h2 className="text-2xl font-bold mb-4">Dashboard</h2>
<p className="text-gray-500">Upload a CSV file to get started.</p>
</div>
)
}
What does this code do?
This is a minimal placeholder page. It renders inside the layout's {children} slot. You'll replace this content with the actual upload form and charts in later steps.
- Save both app/dashboard/layout.tsx and app/dashboard/page.tsx.
Seeing an error about hostname not being configured?
The Next.js Image component requires you to allow external image domains. If you see an error mentioning remotePatterns, open your next.config.ts file and add GitHub's avatar hostname to the images.remotePatterns array. The hostname to allow is avatars.githubusercontent.com.
help me configure remotePatterns for GitHub avatars in next.config
Create the login page
The middleware redirects to /login, so you need a page at that path. This page uses Auth.js Server Actions to trigger the GitHub OAuth flow.
- Create a new folder called login inside your app directory.
- Inside app/login/, create a file called page.tsx and add the following code:
import { signIn } from '@/auth'
export default function LoginPage() {
return (
<main className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="rounded-xl bg-white p-10 shadow-md text-center">
<h1 className="text-3xl font-bold mb-2">DataVault</h1>
<p className="text-gray-500 mb-6">Sign in to upload and visualize your CSV data</p>
<form
action={async () => {
'use server'
await signIn('github', { redirectTo: '/dashboard' })
}}
>
<button
type="submit"
className="bg-gray-900 text-white px-6 py-3 rounded-lg hover:bg-gray-700 transition"
>
Sign in with GitHub
</button>
</form>
</div>
</main>
)
}
What does this code do?
- signIn('github', { redirectTo: '/dashboard' }) is a Server Action that starts the GitHub OAuth flow. After the user authorizes, they land on /dashboard.
- The 'use server' directive marks the form action as a Server Action. This means the sign-in logic runs on the server, never in the browser.
- The form wraps the button so that clicking it submits the action without any client-side JavaScript.
- Save app/login/page.tsx.
- Return to your browser and navigate to http://localhost:3000/dashboard.
- You should be redirected to /login and see the login card with the DataVault heading and a "Sign in with GitHub" button.
- Click Sign in with GitHub. After authorizing, you should land on the dashboard with your GitHub avatar and name in the sidebar.
Stuck in a redirect loop or seeing a 404?
Make sure middleware.ts is at the project root (next to auth.ts), not inside the app folder. Next.js only picks up middleware from the root.
If you see a 404 on /login, confirm that your file is at app/login/page.tsx (not app/login/index.tsx).
help me debug my Auth.js middleware redirect loop
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files before moving on.
ⓧ I'd like to double check the full code
Here are all the files you created in this step.
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*'],
}
import { auth, signOut } from '@/auth'
import { redirect } from 'next/navigation'
import Image from 'next/image'
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await auth()
if (!session?.user) redirect('/login')
return (
<div className="flex min-h-screen">
<aside className="w-64 bg-gray-900 text-white flex flex-col p-6">
<h1 className="text-xl font-bold mb-8">DataVault</h1>
<div className="flex items-center gap-3 mb-6">
{session.user.image && (
<Image
src={session.user.image}
alt="avatar"
width={36}
height={36}
className="rounded-full"
/>
)}
<span className="text-sm font-medium">{session.user.name}</span>
</div>
<form
action={async () => {
'use server'
await signOut({ redirectTo: '/login' })
}}
className="mt-auto"
>
<button
type="submit"
className="text-sm text-gray-400 hover:text-white transition"
>
Sign out
</button>
</form>
</aside>
<main className="flex-1 bg-gray-50 p-8">{children}</main>
</div>
)
}
export default function DashboardPage() {
return (
<div>
<h2 className="text-2xl font-bold mb-4">Dashboard</h2>
<p className="text-gray-500">Upload a CSV file to get started.</p>
</div>
)
}
import { signIn } from '@/auth'
export default function LoginPage() {
return (
<main className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="rounded-xl bg-white p-10 shadow-md text-center">
<h1 className="text-3xl font-bold mb-2">DataVault</h1>
<p className="text-gray-500 mb-6">Sign in to upload and visualize your CSV data</p>
<form
action={async () => {
'use server'
await signIn('github', { redirectTo: '/dashboard' })
}}
>
<button
type="submit"
className="bg-gray-900 text-white px-6 py-3 rounded-lg hover:bg-gray-700 transition"
>
Sign in with GitHub
</button>
</form>
</div>
</main>
)
}
Your app now has a complete auth flow. Unauthenticated users are redirected to login, and signed-in users see a personalized dashboard. Next up, you'll build the CSV upload pipeline to get data into the app.
Build the CSV Upload Pipeline
Your dashboard is locked down and only authenticated users can see it. Now it's time to build the core feature: letting users upload CSV files and store the data in your database.
This step ties together the client and server sides of your app. You'll build a drag-and-drop upload form that parses CSV data in the browser using PapaParse, then send that structured data to a Route Handler that saves it into your Neon database via Prisma.
In this step, get ready to:
- Create a file upload form with drag-and-drop that accepts CSV files and parses them client-side.
- Build a POST Route Handler that validates parsed data and stores datasets in PostgreSQL.
- Update the dashboard page to render the upload form and list previously uploaded datasets.
Create the upload form component
The upload form is a client component that handles two jobs: accepting a file via drag-and-drop (or click), and parsing the CSV contents into structured JSON using PapaParse. Parsing happens entirely in the browser so the server only receives clean, validated data.
- Create a new folder called components at the root of your datavault project (same level as app/ and lib/).
- Inside components/, create a new file called UploadForm.tsx.
- Add the imports, type definition, and state declarations by pasting this code:
'use client'
import { useState, useRef } from 'react'
import Papa from 'papaparse'
type ParsedRow = { label: string; value: number }
export default function UploadForm({ onUploadComplete }: { onUploadComplete: () => void }) {
const [dragging, setDragging] = useState(false)
const [status, setStatus] = useState<'idle' | 'parsing' | 'uploading' | 'done' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
}
What does this code do?
'use client' marks this as a Client Component because it needs browser APIs (file drag-and-drop, state). The ParsedRow type defines the shape of each data point: a text label and a numeric value.
The status state tracks where the component is in the upload lifecycle (idle, parsing, uploading, done, or error). This drives the UI feedback the user sees.
- Now add the handleFile function inside the component (below the inputRef line). This function handles CSV parsing and uploading:
const handleFile = (file: File) => {
if (!file.name.endsWith('.csv')) {
setErrorMsg('Please upload a .csv file.')
setStatus('error')
return
}
setStatus('parsing')
Papa.parse<Record<string, string>>(file, {
header: true,
dynamicTyping: true,
skipEmptyLines: true,
complete: async (results) => {
const rows: ParsedRow[] = results.data
.map((row) => ({
label: String(Object.values(row)[0] ?? ''),
value: Number(Object.values(row)[1] ?? 0),
}))
.filter((r) => r.label && !isNaN(r.value))
if (rows.length === 0) {
setErrorMsg('No valid rows found. CSV must have at least two columns: label and value.')
setStatus('error')
return
}
setStatus('uploading')
try {
const res = await fetch('/api/datasets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: file.name, rows }),
})
if (!res.ok) throw new Error(await res.text())
setStatus('done')
onUploadComplete()
} catch (e: unknown) {
setErrorMsg(e instanceof Error ? e.message : 'Upload failed')
setStatus('error')
}
},
})
}
What does PapaParse do here?
Papa.parse reads the CSV file directly in the browser. The header: true option uses the first row as column names. The dynamicTyping: true option automatically converts numeric strings to actual numbers.
After parsing, the code maps each row to a { label, value } shape by grabbing the first column as label and the second as value. Invalid rows are filtered out before sending data to the server.
- Finally, add the component's return statement below the handleFile function. This renders the drag-and-drop upload zone:
return (
<div
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => { e.preventDefault(); setDragging(false); const f = e.dataTransfer.files[0]; if (f) handleFile(f) }}
onClick={() => inputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition ${
dragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300 bg-white hover:border-blue-400'
}`}
>
<input ref={inputRef} type="file" accept=".csv" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f) }} />
{status === 'idle' && <p className="text-gray-500">Drag & drop a CSV file here, or click to select</p>}
{status === 'parsing' && <p className="text-blue-500">Parsing CSV...</p>}
{status === 'uploading' && <p className="text-blue-500">Uploading data...</p>}
{status === 'done' && <p className="text-green-600">Upload complete!</p>}
{status === 'error' && <p className="text-red-500">{errorMsg}</p>}
</div>
)
How does the drag-and-drop work?
The outer div listens for drag events and click events. When the user drags a file over the zone, the border turns blue. Dropping or clicking triggers handleFile.
A hidden <input type="file"> is triggered programmatically on click. The visible text changes based on the current status state, giving the user real-time feedback.
- Save components/UploadForm.tsx.
Seeing a red underline on the Papa import?
Make sure you installed papaparse and its types in the earlier step. If the import is unresolved, run npm install papaparse @types/papaparse from your project root.
help me fix a PapaParse import error in my UploadForm component
✔️ Awesome, I've got everything!
Great. Double check you've saved the file before moving on.
ⓧ I'd like to double check the full code
'use client'
import { useState, useRef } from 'react'
import Papa from 'papaparse'
type ParsedRow = { label: string; value: number }
export default function UploadForm({ onUploadComplete }: { onUploadComplete: () => void }) {
const [dragging, setDragging] = useState(false)
const [status, setStatus] = useState<'idle' | 'parsing' | 'uploading' | 'done' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const handleFile = (file: File) => {
if (!file.name.endsWith('.csv')) {
setErrorMsg('Please upload a .csv file.')
setStatus('error')
return
}
setStatus('parsing')
Papa.parse<Record<string, string>>(file, {
header: true,
dynamicTyping: true,
skipEmptyLines: true,
complete: async (results) => {
const rows: ParsedRow[] = results.data
.map((row) => ({
label: String(Object.values(row)[0] ?? ''),
value: Number(Object.values(row)[1] ?? 0),
}))
.filter((r) => r.label && !isNaN(r.value))
if (rows.length === 0) {
setErrorMsg('No valid rows found. CSV must have at least two columns: label and value.')
setStatus('error')
return
}
setStatus('uploading')
try {
const res = await fetch('/api/datasets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: file.name, rows }),
})
if (!res.ok) throw new Error(await res.text())
setStatus('done')
onUploadComplete()
} catch (e: unknown) {
setErrorMsg(e instanceof Error ? e.message : 'Upload failed')
setStatus('error')
}
},
})
}
return (
<div
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={(e) => { e.preventDefault(); setDragging(false); const f = e.dataTransfer.files[0]; if (f) handleFile(f) }}
onClick={() => inputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition ${
dragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300 bg-white hover:border-blue-400'
}`}
>
<input ref={inputRef} type="file" accept=".csv" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f) }} />
{status === 'idle' && <p className="text-gray-500">Drag & drop a CSV file here, or click to select</p>}
{status === 'parsing' && <p className="text-blue-500">Parsing CSV...</p>}
{status === 'uploading' && <p className="text-blue-500">Uploading data...</p>}
{status === 'done' && <p className="text-green-600">Upload complete!</p>}
{status === 'error' && <p className="text-red-500">{errorMsg}</p>}
</div>
)
}
Create the datasets Route Handler
The upload form sends parsed CSV data to /api/datasets as JSON. You need a Route Handler on the server to receive that data, check that the user is authenticated, and write the dataset plus its data points into the database.
- Create the folder path app/api/datasets/ inside your project.
- Create a new file called route.ts inside app/api/datasets/.
- Add the POST Route Handler by pasting this code:
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
export async function POST(req: NextRequest) {
// Check that the user is authenticated
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Read the parsed CSV data from the request body
const body = await req.json()
const { name, rows } = body as { name: string; rows: { label: string; value: number }[] }
// Validate the payload shape
if (!name || !Array.isArray(rows) || rows.length === 0) {
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
}
// Create the dataset and all its data points in one transaction
const dataset = await prisma.dataset.create({
data: {
name,
userId: session.user.id,
dataPoints: {
create: rows.map((r) => ({ label: r.label, value: r.value })),
},
},
})
return NextResponse.json({ id: dataset.id }, { status: 201 })
}
What does this Route Handler do?
The handler first calls auth() to verify the request comes from a logged-in user. If not, it returns a 401. This protects your API from anonymous writes.
Prisma's nested create writes the Dataset record and all its DataPoint records in a single database transaction. If any row fails, the whole operation rolls back.
- Save app/api/datasets/route.ts.
Seeing a type error on prisma.dataset?
If TypeScript doesn't recognize prisma.dataset, your Prisma client might be out of date. Run npx prisma generate to regenerate the client types from your schema.
help me fix a Prisma type error in my Route Handler
✔️ Awesome, I've got everything!
Great. Make sure the file is saved before continuing.
ⓧ I'd like to double check the full code
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
export async function POST(req: NextRequest) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await req.json()
const { name, rows } = body as { name: string; rows: { label: string; value: number }[] }
if (!name || !Array.isArray(rows) || rows.length === 0) {
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
}
const dataset = await prisma.dataset.create({
data: {
name,
userId: session.user.id,
dataPoints: {
create: rows.map((r) => ({ label: r.label, value: r.value })),
},
},
})
return NextResponse.json({ id: dataset.id }, { status: 201 })
}
Wire up the dashboard page
The upload form and Route Handler are ready. Now you need to connect them to the dashboard so users can see the upload zone and a list of their previously uploaded datasets.
- Open app/dashboard/page.tsx in your editor.
- Replace the entire file contents with this code:
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import UploadForm from '@/components/UploadForm'
export default async function DashboardPage() {
// Get the authenticated user
const session = await auth()
if (!session?.user?.id) redirect('/login')
// Fetch all datasets belonging to this user
const datasets = await prisma.dataset.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'desc' },
include: { dataPoints: true },
})
return (
<div>
<h2 className="text-2xl font-bold mb-6">Your Datasets</h2>
<UploadForm onUploadComplete={() => {}} />
<ul className="mt-8 space-y-2">
{datasets.map((ds) => (
<li key={ds.id} className="bg-white rounded-lg p-4 shadow-sm">
<span className="font-medium">{ds.name}</span>
<span className="text-gray-400 text-sm ml-3">{ds.dataPoints.length} rows</span>
</li>
))}
</ul>
</div>
)
}
What's happening here?
This page is a Server Component, so it can call Prisma directly to fetch data. It queries all datasets for the logged-in user, ordered by most recent. The data is passed straight into the JSX with no client-side fetch needed.
The UploadForm client component handles the interactive upload. After a successful upload, refreshing the page shows the new dataset in the list below.
- Save app/dashboard/page.tsx.
- Create a test CSV file on your computer. Open any text editor, paste the following, and save it as test-data.csv on your Desktop:
month,revenue
January,4500
February,6200
March,7800
April,5100
May,8400
- Make sure your dev server is running at http://localhost:3000.
- Sign in with GitHub and navigate to the dashboard.
- Drag your test-data.csv file onto the dashed upload zone (or click it to select the file).
- You should see the status change from "Parsing CSV..." to "Uploading data..." to "Upload complete!" in green text.
- Refresh the page. You should see test-data.csv listed below the upload zone with "5 rows" next to it.
Upload stuck or showing an error?
If you see "Unauthorized" in the error message, your session may have expired. Sign out and sign back in with GitHub.
If the upload zone says "No valid rows found", check that your CSV has a header row with at least two columns. The first column becomes the label and the second becomes the numeric value.
If you see a 500 error in the browser console, check your terminal for Prisma errors. You may need to run npx prisma migrate dev again if your schema changed.
help me debug my CSV upload pipeline
✔️ Awesome, I've got everything!
Great. Your full upload pipeline is working end-to-end.
ⓧ I'd like to double check the full code
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import UploadForm from '@/components/UploadForm'
export default async function DashboardPage() {
const session = await auth()
if (!session?.user?.id) redirect('/login')
const datasets = await prisma.dataset.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'desc' },
include: { dataPoints: true },
})
return (
<div>
<h2 className="text-2xl font-bold mb-6">Your Datasets</h2>
<UploadForm onUploadComplete={() => {}} />
<ul className="mt-8 space-y-2">
{datasets.map((ds) => (
<li key={ds.id} className="bg-white rounded-lg p-4 shadow-sm">
<span className="font-medium">{ds.name}</span>
<span className="text-gray-400 text-sm ml-3">{ds.dataPoints.length} rows</span>
</li>
))}
</ul>
</div>
)
}
Your upload pipeline is complete. Data flows from a CSV file on the user's computer, through PapaParse in the browser, to your Route Handler, and into Neon PostgreSQL via Prisma. Next up, you'll turn that stored data into interactive D3.js charts.
Build Interactive D3.js Charts
Your CSV upload pipeline stores structured data in the database. Now it is time to bring that data to life with interactive charts.
You will build two chart components using D3.js and a modern integration pattern called "React renders, D3 calculates." D3 handles the math (scales, axes, line generation) and React handles the actual SVG elements.
In this step, get ready to:
- Create a BarChart component that renders data as vertical bars with D3-powered axes.
- Create a LineChart component with hover tooltips that display data values.
- Wire up the dashboard to let users select a dataset and toggle between chart types.
Create the BarChart component
The "React renders, D3 calculates" pattern keeps React in full control of the DOM. D3 computes where each bar should sit (via scales) and draws the axes (via useEffect), but React renders every <rect> element directly in JSX.
- Create a new file at components/BarChart.tsx and paste the following code:
'use client'
import { useEffect, useRef } from 'react'
import * as d3 from 'd3'
type DataPoint = { label: string; value: number }
export default function BarChart({ data }: { data: DataPoint[] }) {
const svgRef = useRef<SVGSVGElement>(null)
const width = 600
const height = 350
const margin = { top: 20, right: 20, bottom: 60, left: 50 }
const innerWidth = width - margin.left - margin.right
const innerHeight = height - margin.top - margin.bottom
// D3 calculates: band scale for x, linear scale for y
const xScale = d3
.scaleBand()
.domain(data.map((d) => d.label))
.range([0, innerWidth])
.padding(0.2)
const yScale = d3
.scaleLinear()
.domain([0, d3.max(data, (d) => d.value) ?? 0])
.nice()
.range([innerHeight, 0])
// D3 draws axes imperatively via useEffect
useEffect(() => {
if (!svgRef.current) return
const svg = d3.select(svgRef.current)
svg.selectAll('.x-axis').remove()
svg.selectAll('.y-axis').remove()
const g = svg.select<SVGGElement>('.axes-group')
g.append('g')
.attr('class', 'x-axis')
.attr('transform', `translate(0,${innerHeight})`)
.call(d3.axisBottom(xScale))
.selectAll('text')
.attr('transform', 'rotate(-30)')
.style('text-anchor', 'end')
g.append('g').attr('class', 'y-axis').call(d3.axisLeft(yScale))
}, [data, xScale, yScale, innerHeight])
// React renders: SVG elements in JSX
return (
<svg ref={svgRef} width={width} height={height}>
<g className="axes-group" transform={`translate(${margin.left},${margin.top})`}>
{data.map((d) => (
<rect
key={d.label}
x={xScale(d.label)}
y={yScale(d.value)}
width={xScale.bandwidth()}
height={innerHeight - yScale(d.value)}
fill="#3b82f6"
className="hover:fill-blue-400 transition-colors"
/>
))}
</g>
</svg>
)
}
What does this code do?
This component splits work between D3 and React:
- scaleBand() maps each label to an evenly spaced band along the x-axis. scaleLinear() maps numeric values to pixel heights on the y-axis.
- The useEffect hook lets D3 draw axes imperatively (it needs direct DOM access for tick marks and labels).
- The data.map() inside the JSX return is React rendering each bar as a <rect> SVG element. React stays in control of the DOM while D3 just provides the math.
Why "React renders, D3 calculates"?
Older D3 tutorials let D3 create and remove DOM elements directly. That conflicts with React's virtual DOM, causing bugs and stale renders.
By letting React own the DOM and D3 handle only the calculations, you avoid those conflicts entirely. The axes are the one exception because D3's axis generator produces complex tick-mark SVG that would be tedious to replicate in JSX.
- Save components/BarChart.tsx.
You will not see anything in the browser yet. The BarChart is not wired into the dashboard page. You will connect it in the final substep.
Seeing a TypeScript error on d3 imports?
Make sure you installed @types/d3 in Step 1. If not, run npm install @types/d3 in your terminal.
help me fix a D3 TypeScript import error in my Next.js project
Create the LineChart component with hover tooltips
The LineChart follows the same pattern as BarChart but uses a D3 line generator to connect data points. It also adds interactive hover tooltips so users can inspect exact values.
- Create a new file at components/LineChart.tsx and paste the following code:
'use client'
import { useEffect, useRef, useState } from 'react'
import * as d3 from 'd3'
type DataPoint = { label: string; value: number }
export default function LineChart({ data }: { data: DataPoint[] }) {
const svgRef = useRef<SVGSVGElement>(null)
const [tooltip, setTooltip] = useState<{ x: number; y: number; label: string; value: number } | null>(null)
const width = 600
const height = 350
const margin = { top: 20, right: 20, bottom: 60, left: 50 }
const innerWidth = width - margin.left - margin.right
const innerHeight = height - margin.top - margin.bottom
// D3 calculates: point scale for x, linear scale for y
const xScale = d3
.scalePoint()
.domain(data.map((d) => d.label))
.range([0, innerWidth])
.padding(0.5)
const yScale = d3
.scaleLinear()
.domain([0, d3.max(data, (d) => d.value) ?? 0])
.nice()
.range([innerHeight, 0])
// D3 generates the line path string
const lineGenerator = d3
.line<DataPoint>()
.x((d) => xScale(d.label) ?? 0)
.y((d) => yScale(d.value))
.curve(d3.curveMonotoneX)
// D3 draws axes imperatively
useEffect(() => {
if (!svgRef.current) return
const svg = d3.select(svgRef.current)
svg.selectAll('.x-axis').remove()
svg.selectAll('.y-axis').remove()
const g = svg.select<SVGGElement>('.axes-group')
g.append('g')
.attr('class', 'x-axis')
.attr('transform', `translate(0,${innerHeight})`)
.call(d3.axisBottom(xScale))
.selectAll('text')
.attr('transform', 'rotate(-30)')
.style('text-anchor', 'end')
g.append('g').attr('class', 'y-axis').call(d3.axisLeft(yScale))
}, [data, xScale, yScale, innerHeight])
// React renders: path, circles, and tooltip
return (
<div className="relative">
<svg ref={svgRef} width={width} height={height}>
<g className="axes-group" transform={`translate(${margin.left},${margin.top})`}>
<path
d={lineGenerator(data) ?? ''}
fill="none"
stroke="#3b82f6"
strokeWidth={2}
/>
{data.map((d) => (
<circle
key={d.label}
cx={xScale(d.label)}
cy={yScale(d.value)}
r={5}
fill="#3b82f6"
className="hover:fill-blue-300 cursor-pointer"
onMouseEnter={(e) =>
setTooltip({
x: (xScale(d.label) ?? 0) + margin.left,
y: yScale(d.value) + margin.top,
label: d.label,
value: d.value,
})
}
onMouseLeave={() => setTooltip(null)}
/>
))}
</g>
</svg>
{tooltip && (
<div
className="absolute bg-gray-900 text-white text-xs rounded px-2 py-1 pointer-events-none"
style={{ left: tooltip.x + 10, top: tooltip.y - 10 }}
>
{tooltip.label}: {tooltip.value}
</div>
)}
</div>
)
}
What does this code do?
- scalePoint() spaces labels evenly along the x-axis (unlike scaleBand which creates wide bands, scalePoint places them at single points for connecting with a line).
- d3.line() generates a path string from your data array. The curveMonotoneX interpolation creates smooth curves that still pass through each data point.
- The tooltip state tracks which circle the user is hovering over. React renders an absolutely-positioned <div> near the cursor showing the label and value.
- Save components/LineChart.tsx.
Like BarChart, this component is not visible yet. The next substep connects both charts to the dashboard.
Seeing a red underline on lineGenerator?
Check that the DataPoint type is defined with exactly label: string and value: number. The generic d3.line<DataPoint>() needs those exact fields to type-check the accessor functions.
help me debug a TypeScript error with d3.line in my LineChart
Wire up the dashboard with a dataset selector and chart toggle
You have two chart components ready. Now you need a client component that lets users click a dataset to select it and toggle between bar and line views.
- Create a new file at components/DatasetViewer.tsx and paste the following code:
'use client'
import { useState } from 'react'
import BarChart from './BarChart'
import LineChart from './LineChart'
type DataPoint = { id: string; label: string; value: number; datasetId: string }
type Dataset = { id: string; name: string; dataPoints: DataPoint[] }
export default function DatasetViewer({ datasets }: { datasets: Dataset[] }) {
const [selectedId, setSelectedId] = useState<string | null>(datasets[0]?.id ?? null)
const [chartType, setChartType] = useState<'bar' | 'line'>('bar')
const selected = datasets.find((d) => d.id === selectedId)
return (
<div className="mt-8">
{/* Dataset list - click to select */}
<ul className="space-y-2 mb-6">
{datasets.map((ds) => (
<li
key={ds.id}
onClick={() => setSelectedId(ds.id)}
className={`bg-white rounded-lg p-4 shadow-sm cursor-pointer ${
selectedId === ds.id ? 'ring-2 ring-blue-500' : ''
}`}
>
<span className="font-medium">{ds.name}</span>
<span className="text-gray-400 text-sm ml-3">{ds.dataPoints.length} rows</span>
</li>
))}
</ul>
{/* Chart type toggle and chart render */}
{selected && (
<div>
<div className="flex gap-2 mb-4">
<button
onClick={() => setChartType('bar')}
className={`px-4 py-2 rounded ${
chartType === 'bar' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700'
}`}
>
Bar Chart
</button>
<button
onClick={() => setChartType('line')}
className={`px-4 py-2 rounded ${
chartType === 'line' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700'
}`}
>
Line Chart
</button>
</div>
{chartType === 'bar' ? (
<BarChart data={selected.dataPoints} />
) : (
<LineChart data={selected.dataPoints} />
)}
</div>
)}
</div>
)
}
What does this code do?
- selectedId tracks which dataset the user clicked. The selected dataset gets a blue ring highlight.
- chartType toggles between 'bar' and 'line'. The active button gets the blue background.
- The component conditionally renders either BarChart or LineChart, passing the selected dataset's data points as props.
- Save components/DatasetViewer.tsx.
Now update the dashboard page to use DatasetViewer instead of the plain list.
- Open app/dashboard/page.tsx.
- Find the existing import lines at the top of the file:
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import UploadForm from '@/components/UploadForm'
- Replace those four import lines with the following, which adds imports for the chart components and the DatasetViewer:
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import UploadForm from '@/components/UploadForm'
import BarChart from '@/components/BarChart'
import LineChart from '@/components/LineChart'
import DatasetViewer from '@/components/DatasetViewer'
- Next, find the <ul> block that renders the dataset list:
<ul className="mt-8 space-y-2">
{datasets.map((ds) => (
<li key={ds.id} className="bg-white rounded-lg p-4 shadow-sm">
<span className="font-medium">{ds.name}</span>
<span className="text-gray-400 text-sm ml-3">{ds.dataPoints.length} rows</span>
</li>
))}
</ul>
- Replace that entire <ul> block with a single line:
<DatasetViewer datasets={datasets} />
- Save app/dashboard/page.tsx and refresh your browser at http://localhost:3000/dashboard.
If you uploaded a CSV in the previous step, you should see your datasets listed. Click one to select it, and a blue bar chart renders below. Click the **Line Chart** button to switch views. Hover over the circles in the line chart to see tooltip values.
Chart not appearing after selecting a dataset?
Check that your DatasetViewer imports reference ./BarChart and ./LineChart (relative paths, since they are sibling files in the same components folder).
If you see a blank space instead of the chart, open the browser console. A missing 'use client' directive at the top of any chart file causes a hydration error.
help me debug why my D3 chart is not rendering in my Next.js dashboard
No CSV uploaded yet?
Create a quick test file. Make a file called test.csv with this content: label,value on line 1, then rows like Jan,120 and Feb,95 on subsequent lines. Upload it via the drag-and-drop area on your dashboard.
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files: BarChart.tsx, LineChart.tsx, DatasetViewer.tsx, and the updated app/dashboard/page.tsx.
ⓧ I'd like to double check the full code
Here is the full code for each file you created or modified in this step.
'use client'
import { useEffect, useRef } from 'react'
import * as d3 from 'd3'
type DataPoint = { label: string; value: number }
export default function BarChart({ data }: { data: DataPoint[] }) {
const svgRef = useRef<SVGSVGElement>(null)
const width = 600
const height = 350
const margin = { top: 20, right: 20, bottom: 60, left: 50 }
const innerWidth = width - margin.left - margin.right
const innerHeight = height - margin.top - margin.bottom
const xScale = d3
.scaleBand()
.domain(data.map((d) => d.label))
.range([0, innerWidth])
.padding(0.2)
const yScale = d3
.scaleLinear()
.domain([0, d3.max(data, (d) => d.value) ?? 0])
.nice()
.range([innerHeight, 0])
useEffect(() => {
if (!svgRef.current) return
const svg = d3.select(svgRef.current)
svg.selectAll('.x-axis').remove()
svg.selectAll('.y-axis').remove()
const g = svg.select<SVGGElement>('.axes-group')
g.append('g')
.attr('class', 'x-axis')
.attr('transform', `translate(0,${innerHeight})`)
.call(d3.axisBottom(xScale))
.selectAll('text')
.attr('transform', 'rotate(-30)')
.style('text-anchor', 'end')
g.append('g').attr('class', 'y-axis').call(d3.axisLeft(yScale))
}, [data, xScale, yScale, innerHeight])
return (
<svg ref={svgRef} width={width} height={height}>
<g className="axes-group" transform={`translate(${margin.left},${margin.top})`}>
{data.map((d) => (
<rect
key={d.label}
x={xScale(d.label)}
y={yScale(d.value)}
width={xScale.bandwidth()}
height={innerHeight - yScale(d.value)}
fill="#3b82f6"
className="hover:fill-blue-400 transition-colors"
/>
))}
</g>
</svg>
)
}
'use client'
import { useEffect, useRef, useState } from 'react'
import * as d3 from 'd3'
type DataPoint = { label: string; value: number }
export default function LineChart({ data }: { data: DataPoint[] }) {
const svgRef = useRef<SVGSVGElement>(null)
const [tooltip, setTooltip] = useState<{ x: number; y: number; label: string; value: number } | null>(null)
const width = 600
const height = 350
const margin = { top: 20, right: 20, bottom: 60, left: 50 }
const innerWidth = width - margin.left - margin.right
const innerHeight = height - margin.top - margin.bottom
const xScale = d3
.scalePoint()
.domain(data.map((d) => d.label))
.range([0, innerWidth])
.padding(0.5)
const yScale = d3
.scaleLinear()
.domain([0, d3.max(data, (d) => d.value) ?? 0])
.nice()
.range([innerHeight, 0])
const lineGenerator = d3
.line<DataPoint>()
.x((d) => xScale(d.label) ?? 0)
.y((d) => yScale(d.value))
.curve(d3.curveMonotoneX)
useEffect(() => {
if (!svgRef.current) return
const svg = d3.select(svgRef.current)
svg.selectAll('.x-axis').remove()
svg.selectAll('.y-axis').remove()
const g = svg.select<SVGGElement>('.axes-group')
g.append('g')
.attr('class', 'x-axis')
.attr('transform', `translate(0,${innerHeight})`)
.call(d3.axisBottom(xScale))
.selectAll('text')
.attr('transform', 'rotate(-30)')
.style('text-anchor', 'end')
g.append('g').attr('class', 'y-axis').call(d3.axisLeft(yScale))
}, [data, xScale, yScale, innerHeight])
return (
<div className="relative">
<svg ref={svgRef} width={width} height={height}>
<g className="axes-group" transform={`translate(${margin.left},${margin.top})`}>
<path
d={lineGenerator(data) ?? ''}
fill="none"
stroke="#3b82f6"
strokeWidth={2}
/>
{data.map((d) => (
<circle
key={d.label}
cx={xScale(d.label)}
cy={yScale(d.value)}
r={5}
fill="#3b82f6"
className="hover:fill-blue-300 cursor-pointer"
onMouseEnter={(e) =>
setTooltip({
x: (xScale(d.label) ?? 0) + margin.left,
y: yScale(d.value) + margin.top,
label: d.label,
value: d.value,
})
}
onMouseLeave={() => setTooltip(null)}
/>
))}
</g>
</svg>
{tooltip && (
<div
className="absolute bg-gray-900 text-white text-xs rounded px-2 py-1 pointer-events-none"
style={{ left: tooltip.x + 10, top: tooltip.y - 10 }}
>
{tooltip.label}: {tooltip.value}
</div>
)}
</div>
)
}
'use client'
import { useState } from 'react'
import BarChart from './BarChart'
import LineChart from './LineChart'
type DataPoint = { id: string; label: string; value: number; datasetId: string }
type Dataset = { id: string; name: string; dataPoints: DataPoint[] }
export default function DatasetViewer({ datasets }: { datasets: Dataset[] }) {
const [selectedId, setSelectedId] = useState<string | null>(datasets[0]?.id ?? null)
const [chartType, setChartType] = useState<'bar' | 'line'>('bar')
const selected = datasets.find((d) => d.id === selectedId)
return (
<div className="mt-8">
<ul className="space-y-2 mb-6">
{datasets.map((ds) => (
<li
key={ds.id}
onClick={() => setSelectedId(ds.id)}
className={`bg-white rounded-lg p-4 shadow-sm cursor-pointer ${
selectedId === ds.id ? 'ring-2 ring-blue-500' : ''
}`}
>
<span className="font-medium">{ds.name}</span>
<span className="text-gray-400 text-sm ml-3">{ds.dataPoints.length} rows</span>
</li>
))}
</ul>
{selected && (
<div>
<div className="flex gap-2 mb-4">
<button
onClick={() => setChartType('bar')}
className={`px-4 py-2 rounded ${
chartType === 'bar' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700'
}`}
>
Bar Chart
</button>
<button
onClick={() => setChartType('line')}
className={`px-4 py-2 rounded ${
chartType === 'line' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700'
}`}
>
Line Chart
</button>
</div>
{chartType === 'bar' ? (
<BarChart data={selected.dataPoints} />
) : (
<LineChart data={selected.dataPoints} />
)}
</div>
)}
</div>
)
}
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import UploadForm from '@/components/UploadForm'
import BarChart from '@/components/BarChart'
import LineChart from '@/components/LineChart'
import DatasetViewer from '@/components/DatasetViewer'
export default async function DashboardPage() {
const session = await auth()
if (!session?.user?.id) redirect('/login')
const datasets = await prisma.dataset.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'desc' },
include: { dataPoints: true },
})
return (
<div>
<h2 className="text-2xl font-bold mb-6">Your Datasets</h2>
<UploadForm onUploadComplete={() => {}} />
<DatasetViewer datasets={datasets} />
</div>
)
}
Your data now comes to life the moment it is uploaded. Next up, push this project further with a Secret Mission that adds real-time upload progress streaming.
Secret mission
Real-Time Upload Progress with Server-Sent Events
Your upload pipeline works, but users stare at a blank "Uploading..." message with no idea how long they'll wait. In this mission, you'll add a real-time progress bar that streams live updates from the server as it processes each batch of rows. The server pushes progress events via Server-Sent Events (SSE), and the client subscribes with EventSource to animate a progress bar in real time.
Clean Up Your Resources
Clean Up Your Resources
This project runs on Neon's free tier and local files, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Local datavault project folder on your Desktop.
- Neon PostgreSQL database (project: datavault).
- GitHub OAuth App (used for authentication).
- Environment variables in .env.local (secrets stored locally).
Keep everything running
No action needed. Choose this if you're still actively building or plan to extend the project with the Secret Mission.
- Your Neon database stays available on the free tier with scale-to-zero after 5 minutes of inactivity. No charges will accrue.
- Your GitHub OAuth App remains registered and functional.
- Your local project files stay on your Desktop.
Pause - I'll come back to this later
Shut down the running dev server to free up memory. Your files and cloud resources stay intact for when you return.
- In your terminal, press Ctrl+C to stop the Next.js dev server.
- Your Neon database will automatically scale to zero after 5 minutes of inactivity. It wakes up instantly on the next connection.
- Your local files and GitHub OAuth App remain as they are.
Delete - I don't want to use this again
Remove all project resources and start fresh. This cannot be undone.
- Stop the dev server by pressing Ctrl+C in your terminal.
- Delete the local project folder by running this command (macOS/Linux):
- On Windows, run this command instead:
cd ~/Desktop
rm -rf datavault
cd ~\Desktop
Remove-Item -Recurse -Force datavault
- Delete your Neon project:
- Navigate to the Neon Console at console.neon.tech.
- Select the datavault project.
- Click Settings in the sidebar.
- Scroll down and click Delete.
- Confirm the deletion. This permanently removes all branches, databases, and roles.
- Delete your GitHub OAuth App:
- Go to GitHub and click your profile picture, then click Settings.
- In the left sidebar, click Developer settings.
- Click OAuth Apps in the left sidebar.
- Select the OAuth app you created for DataVault.
- Click Advanced in the left sidebar.
- Click Delete application.
- Click Delete this OAuth Application to confirm.
Nice Work!
Nice Work!
You just built a production-grade full-stack application. DataVault proves you can wire together authentication, database operations, file processing, and advanced data visualization in a single Next.js project.
You've learned how to:
- Implement GitHub OAuth authentication with Auth.js v5 and database-backed sessions managed through Prisma.
- Build a Route Handler that receives parsed CSV data and persists it to PostgreSQL via Prisma migrations and batch inserts.
- Create interactive D3.js bar and line charts using the modern "React renders, D3 calculates" pattern with hover tooltips.
- Add real-time upload progress streaming with Server-Sent Events and a ReadableStream-powered Route Handler.
Ready to quiz yourself?