Portfolio Site with RESTful Admin Editor

Build a full-stack portfolio with a REST API and private editor dashboard.

Introduction

30 Second Summary

Every software engineer needs a portfolio that does more than look good. It needs to prove you can actually build things.

In this project, you will build a full-stack portfolio website with a public page for employers and a private editor dashboard that communicates with a RESTful API you design from scratch.

What You'll Build

A professional, minimalist portfolio site with a public-facing project showcase and a password-protected editor powered by a RESTful API and a PostgreSQL database.

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

  • A deployed portfolio website with a clean, responsive design displaying your projects to visitors and employers.
  • A RESTful API with four endpoints (GET, POST, PUT, DELETE) built using Next.js Route Handlers.
  • A private editor dashboard protected by Supabase Auth where only you can create, edit, and delete portfolio entries.
  • Secret Mission: Add image uploads to your portfolio entries using Supabase Storage.

Are there any prerequisites?

You should have basic HTML, CSS, and JavaScript knowledge before starting this project. You will also need Node.js installed and accounts on GitHub, Supabase, and Vercel.

Set Up the Next.js Project

Before you can build your portfolio, you need a working development environment. Next.js provides both the frontend framework and the backend API layer in a single project, so you won't need a separate server.

In this step, you'll scaffold a new Next.js app, connect it to Supabase with environment variables, and create the utility files your app needs to communicate with the database.

In this step, get ready to:
  • Scaffold a Next.js project with TypeScript, Tailwind CSS, and App Router.
  • Install Supabase packages and configure environment variables.
  • Create Supabase client utility files for browser and server use.
Scaffold the Next.js app with Tailwind CSS

The create-next-app CLI sets up a complete Next.js project with one command. It will prompt you to choose your configuration preferences.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (macOS and Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your Next.js project by running this command:
npx create-next-app@latest my-portfolio

What will the terminal ask you?

The CLI will ask you to choose your project configuration. You'll see this prompt:

Would you like to use the recommended Next.js defaults?

Select Yes, use recommended defaults - TypeScript, ESLint, Tailwind CSS, App Router, AGENTS.md. This gives you everything the project needs in one selection.

  • Wait for the installation to finish. You'll see a success message once the project is ready.
  • Navigate into your new project and install Supabase packages by running these commands:
cd my-portfolio
npm install @supabase/supabase-js @supabase/ssr

What do these packages do?

@supabase/supabase-js is the main Supabase client library. It handles database queries, authentication, and storage.

@supabase/ssr provides helpers specifically designed for server-side rendering frameworks like Next.js. It manages cookies so your auth session persists between page loads.

  • Confirm the packages installed by checking that the terminal shows added followed by a package count.

Seeing permission errors or command not found?

Make sure you're inside the my-portfolio folder before running npm install. Run pwd to check your current directory.

If npx is not found, your Node.js installation may not be on your system PATH. Restart your terminal and try again.

Help me troubleshoot my Next.js project setup

  • Open the my-portfolio folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Select the my-portfolio folder on your Desktop, then click Open.
Set up environment variables

Your app needs two pieces of information to connect to Supabase: your project URL and your publishable key. These go in a special file that Next.js reads automatically but keeps out of version control.

  • In the VS Code Explorer sidebar, right-click in the file list and select New File.
  • Name the file .env.local and press Enter.
  • Paste the following into .env.local:
NEXT_PUBLIC_SUPABASE_URL=your-project-url-here
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-key-here
  • Open the Supabase Dashboard in your browser and select your project.
  • Click the Connect button to open the Connect dialog.
  • Copy your Project URL and paste it as the value for NEXT_PUBLIC_SUPABASE_URL (replacing your-project-url-here).
  • Copy your Publishable key and paste it as the value for NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY (replacing your-publishable-key-here).
  • Save .env.local.

Where do I find the Connect dialog?

In your Supabase project dashboard, look for the Connect button near the top of the page. This shows your Project URL and keys in one place.

Alternatively, go to Settings then API Keys to find the same values. Your publishable key starts with sb_publishable_.

Why are these prefixed with NEXT_PUBLIC?

The NEXT_PUBLIC_ prefix tells Next.js to expose these values to the browser. The publishable key is designed to be safe for client-side use. It only allows operations that your Row Level Security policies permit.

Create Supabase client utilities

Your app will talk to Supabase from two different contexts: the browser (client components) and the server (route handlers and server components). Each context needs its own client setup.

  • In VS Code's Explorer sidebar, right-click the my-portfolio root folder and select New Folder.
  • Name it lib.
  • Right-click the lib folder and create another folder called supabase.
  • Right-click the lib/supabase folder and select New File. Name it client.ts.
  • Paste the following code into client.ts:
import { createBrowserClient } from "@supabase/ssr";

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
  );
}

What does this code do?

createBrowserClient creates a Supabase client that runs in the browser. It reads your URL and publishable key from the environment variables you just set up.

The ! after each variable tells TypeScript you guarantee these values exist at runtime.

  • Save client.ts.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

import { createBrowserClient } from "@supabase/ssr";

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
  );
}
  • Right-click the lib/supabase folder again and create a new file called server.ts.
  • Paste the following code into server.ts:
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {
            // The `setAll` method is called from a Server Component.
            // This can be ignored if you have middleware refreshing sessions.
          }
        },
      },
    }
  );
}

What does this code do?

createServerClient creates a Supabase client that runs on the server. It needs access to cookies so it can read the user's auth session on each request.

getAll() reads the current cookies from the incoming request. setAll() writes updated cookies back to the response (for example, when refreshing an expired token).

The try/catch block handles a known edge case where setting cookies from a Server Component silently fails. The middleware you'll add later handles session refresh instead.

  • Save server.ts.

✔️ Awesome, I've got everything!

Great. Double check you've saved both files in lib/supabase/.

ⓧ I'd like to double check the full code

import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {
            // The `setAll` method is called from a Server Component.
            // This can be ignored if you have middleware refreshing sessions.
          }
        },
      },
    }
  );
}
  • Start the development server by running this command in your terminal:
npm run dev
  • Open your browser and go to http://localhost:3000.
  • You should see the default Next.js welcome page.

Not seeing the welcome page?

Check your terminal for error messages. If you see an issue with .env.local, make sure there are no extra spaces around the = signs in your environment variables.

If port 3000 is already in use, Next.js will try port 3001 instead. Check the terminal output for the actual URL.

Help me fix my Next.js dev server

Your development environment is ready. Next up, you'll configure the Supabase database and set up authentication so your editor has a data layer to work with.

Configure Supabase Database and Auth

In the last step, you set up your Next.js project and connected it to Supabase with client utilities. Now it's time to create the actual database that will store your portfolio projects.

Your portfolio needs a structured place to store project data like titles, descriptions, and links. In this step, you'll create a database table in Supabase, lock it down with security policies, and set up authentication so only you can edit your portfolio.

In this step, get ready to:
  • Create a projects table with columns for your portfolio data.
  • Set up authentication and create your editor login.
  • Grant Data API permissions so your app can read and write data.
Create the projects table

Your portfolio data needs a home. You'll create a PostgreSQL table in Supabase's SQL Editor that defines the shape of each project entry.

  • Open your Supabase project dashboard in your browser.
  • In the left sidebar, click SQL Editor.
  • Click New query to open a blank editor.
  • Create the projects table by pasting and running this SQL:
-- Create the projects table to store portfolio entries
create table projects (
  id uuid default gen_random_uuid() primary key,
  title text,
  description text,
  tech_stack text,
  live_url text,
  github_url text,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);
  • Click Run (or press Cmd+Enter on macOS, Ctrl+Enter on Windows) to execute the query.

What does this SQL do?

This creates a table called projects with eight columns. Each row represents one portfolio project you want to display.

  • id automatically generates a unique identifier for each project using gen_random_uuid().
  • created_at and updated_at automatically record timestamps when rows are created.
  • The other columns store the project's display information: title, description, tech stack, and links.

You should see a "Success" message in the results panel.

Next, you need to secure this table. Right now anyone with your database URL could read or write to it. You'll enable Row Level Security (RLS) to control who can do what.

  • Clear the SQL Editor, then paste and run this SQL to enable RLS and create access policies:
-- Enable Row Level Security
alter table projects enable row level security;

-- Allow anyone to read projects (public portfolio)
create policy "Anyone can read projects"
  on projects for select
  to anon, authenticated
  using (true);

-- Only authenticated users can create projects
create policy "Authenticated users can insert"
  on projects for insert
  to authenticated
  with check (true);

-- Only authenticated users can update projects
create policy "Authenticated users can update"
  on projects for update
  to authenticated
  using (true)
  with check (true);

-- Only authenticated users can delete projects
create policy "Authenticated users can delete"
  on projects for delete
  to authenticated
  using (true);
  • Click Run to execute the query.

What is Row Level Security?

RLS is a PostgreSQL feature that lets you define access rules directly on your database tables. Instead of relying only on your application code to check permissions, the database itself enforces who can read and write data.

Here you created four policies. The SELECT policy uses to anon, authenticated so anyone (logged in or not) can read your portfolio. The INSERT, UPDATE, and DELETE policies use to authenticated so only logged-in users can modify data.

You should see another "Success" message confirming all policies were created.

Seeing an error about the table not existing?

Make sure you ran the CREATE TABLE query first and saw a success message before running the RLS query. The policies reference the projects table, which must exist first.

help me debug my SQL Editor error in Supabase

Set up authentication

Your editor dashboard needs a login gate so only you can create, edit, and delete portfolio entries. Supabase Auth provides email/password authentication out of the box.

  • In the left sidebar of your Supabase Dashboard, click Authentication.
  • Click Providers in the left submenu.
  • Confirm that Email is listed and enabled. It should be enabled by default on new Supabase projects.
  • Now create your editor account. Click Users in the left submenu.
  • Click Add user, then select Create new user.
  • Enter an email address and a password you'll remember. This is your personal login for the portfolio editor.
  • Click Create user.

You should see your new user appear in the Users list with a confirmed status.

Why create the user manually?

Since this portfolio is for you alone, you don't need a public sign-up flow. Creating your account directly in the dashboard means you can lock down registration entirely and still have a working login for the editor.

User shows as unconfirmed?

When you create a user through the dashboard with Create new user, it should auto-confirm. If the status shows unconfirmed, click the three-dot menu next to the user and select the option to confirm them manually.

help me confirm my Supabase Auth user

Grant Data API access

Supabase's Data API (which your @supabase/supabase-js client uses) needs explicit permission to access your table at the PostgreSQL role level. Without these grants, your API calls would return empty results even with RLS policies in place.

  • Switch back to the SQL Editor in the left sidebar.
  • Grant access to the anon and authenticated roles by pasting and running this SQL:
-- Allow the anon role to read projects via the Data API
grant select on projects to anon;

-- Allow authenticated users full access via the Data API
grant all on projects to authenticated;
  • Click Run to execute.

Why do you need GRANT and RLS?

PostgreSQL has two layers of access control. GRANT statements control what a role is allowed to attempt at the table level. RLS policies then filter which specific rows the role can actually see or modify.

Think of GRANT as the front door key and RLS as the room locks inside. The anon role gets read-only access (SELECT), while authenticated gets full access. RLS further ensures that even with full access, only logged-in users can insert, update, or delete.

You should see "Success" in the results panel. Now verify everything works end to end by inserting a test row.

  • In the left sidebar, click Table Editor.
  • Click on the projects table.
  • Click Insert, then Insert row.
  • Fill in a test row with these values:
  • title: Test Project.
  • description: A sample project to verify the database works.
  • tech_stack: Next.js, Supabase.
  • Leave the other fields blank for now.
  • Click Save.

Your test row should appear in the Table Editor with an auto-generated id and created_at timestamp.

  • Confirm your user account still exists by clicking Authentication in the left sidebar, then Users. You should see your email listed.

Don't see your table in the Table Editor?

Make sure you're viewing the public schema (shown in the dropdown at the top of the Table Editor). If projects doesn't appear, go back to the SQL Editor and re-run the CREATE TABLE query. Check for typos in the table name.

help me find my projects table in Supabase

Your database is configured and secured. Next up, you'll build the RESTful API that connects your Next.js app to this Supabase table.

Build the RESTful API

Your database is ready and waiting for requests. Now you need a way for the outside world to talk to it.

In this step, you will build a RESTful API using Next.js Route Handlers. Each HTTP method (GET, POST, PUT, DELETE) maps to a specific action on your projects data. You will also add authentication middleware to protect routes that only you should access.

In this step, get ready to:
  • Create GET and POST route handlers for listing and creating projects.
  • Create PUT and DELETE route handlers for updating and deleting individual projects.
  • Add authentication middleware to protect the editor route.
Create the GET and POST route handler

Route Handlers in Next.js live inside the app directory as route.ts files. Each exported function name corresponds to an HTTP method. A file at app/api/projects/route.ts becomes available at /api/projects.

  • In VS Code, create a new folder path: app/api/projects/ inside your my-portfolio project.
  • Create a new file called route.ts inside the app/api/projects/ folder.
  • Add the GET handler by pasting the following code into app/api/projects/route.ts:
import { createClient } from "@/lib/supabase/server";

export async function GET() {
  const supabase = await createClient();

  // Fetch all projects, newest first
  const { data, error } = await supabase
    .from("projects")
    .select("*")
    .order("created_at", { ascending: false });

  if (error) {
    return Response.json({ error: error.message }, { status: 500 });
  }

  return Response.json(data);
}

What does this code do?

The GET function runs whenever someone sends a GET request to /api/projects. It creates a Supabase server client, queries all rows from the projects table ordered by newest first, and returns them as JSON.

If the database query fails, it returns a 500 error with the message. This is the standard error-handling pattern for Route Handlers.

  • Save app/api/projects/route.ts.
  • Open your browser and visit http://localhost:3000/api/projects. You should see the test row you inserted earlier displayed as a JSON array.

Seeing a 500 error or empty response?

  • Check that your .env.local has the correct Supabase URL and publishable key. The dev server must be restarted after changing .env.local.
  • Confirm the Data API access was granted to the anon role on the projects table in Supabase.
  • Make sure the file path is exactly app/api/projects/route.ts. A typo in the folder structure means the route will not be found.

Why is my /api/projects route returning an error?

Now you need a POST handler so the editor can create new projects. The POST handler must verify the user is authenticated before allowing writes.

  • Add the POST handler below the GET function in the same file:
export async function POST(request: Request) {
  const supabase = await createClient();

  // Verify the user is logged in
  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  const body = await request.json();

  // Insert the new project into the database
  const { data, error } = await supabase
    .from("projects")
    .insert({
      title: body.title,
      description: body.description,
      tech_stack: body.tech_stack,
      live_url: body.live_url,
      github_url: body.github_url,
    })
    .select()
    .single();

  if (error) {
    return Response.json({ error: error.message }, { status: 500 });
  }

  return Response.json(data, { status: 201 });
}

What does this code do?

The POST handler first checks if the request comes from an authenticated user by calling supabase.auth.getUser(). If no user session exists, it immediately returns a 401 Unauthorized response.

For authenticated requests, it reads the JSON body, inserts a new row into the projects table, and returns the created project with a 201 status code. The .select().single() chain tells Supabase to return the inserted row back to us.

  • Save app/api/projects/route.ts.
  • Check your terminal. The dev server should recompile without errors.

Seeing a compile error?

  • Make sure the import { createClient } line at the top of the file appears only once. Do not duplicate the import when adding POST.
  • Check that the POST function is outside the GET function, not nested inside it.

Help me fix a compile error in my route.ts file

✔️ Awesome, I've got everything!

Great. Double check you have saved app/api/projects/route.ts before moving on.

ⓧ I'd like to double check the full code

import { createClient } from "@/lib/supabase/server";

export async function GET() {
  const supabase = await createClient();

  const { data, error } = await supabase
    .from("projects")
    .select("*")
    .order("created_at", { ascending: false });

  if (error) {
    return Response.json({ error: error.message }, { status: 500 });
  }

  return Response.json(data);
}

export async function POST(request: Request) {
  const supabase = await createClient();

  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  const body = await request.json();

  const { data, error } = await supabase
    .from("projects")
    .insert({
      title: body.title,
      description: body.description,
      tech_stack: body.tech_stack,
      live_url: body.live_url,
      github_url: body.github_url,
    })
    .select()
    .single();

  if (error) {
    return Response.json({ error: error.message }, { status: 500 });
  }

  return Response.json(data, { status: 201 });
}
Create the PUT and DELETE route handler

The PUT and DELETE handlers target a specific project by its ID. In Next.js, you create a dynamic route segment by wrapping the folder name in square brackets.

  • Inside app/api/projects/, create a new folder called [id] (including the square brackets).
  • Create a new file called route.ts inside the [id] folder.
  • Add the PUT handler by pasting the following code into app/api/projects/[id]/route.ts:
import { createClient } from "@/lib/supabase/server";

export async function PUT(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const supabase = await createClient();
  const { id } = await params;

  // Verify the user is logged in
  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  const body = await request.json();

  // Update the project matching this ID
  const { data, error } = await supabase
    .from("projects")
    .update({
      title: body.title,
      description: body.description,
      tech_stack: body.tech_stack,
      live_url: body.live_url,
      github_url: body.github_url,
      updated_at: new Date().toISOString(),
    })
    .eq("id", id)
    .select()
    .single();

  if (error) {
    return Response.json({ error: error.message }, { status: 500 });
  }

  return Response.json(data);
}

What does this code do?

The PUT function receives two arguments: the request object and a context object containing route params. In Next.js 16, params is a Promise that you must await to get the dynamic id value from the URL.

After verifying authentication, it updates only the project whose id matches the URL parameter. The updated_at field is set to the current timestamp so you can track when changes were made.

  • Save the file and confirm the dev server recompiles without errors.
  • Now add the DELETE handler below the PUT function in the same file:
export async function DELETE(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const supabase = await createClient();
  const { id } = await params;

  // Verify the user is logged in
  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  // Delete the project matching this ID
  const { error } = await supabase.from("projects").delete().eq("id", id);

  if (error) {
    return Response.json({ error: error.message }, { status: 500 });
  }

  return new Response(null, { status: 204 });
}

What does this code do?

The DELETE handler follows the same authentication pattern. It deletes the project row matching the id parameter and returns a 204 No Content response. A 204 means the action succeeded but there is nothing to send back.

  • Save app/api/projects/[id]/route.ts.
  • Check your terminal. The dev server should recompile without errors.

Seeing a type error about params?

  • Make sure params is typed as Promise<{ id: string }> and you use await params to destructure it. This is the required pattern in Next.js 16.
  • Confirm the folder is named exactly [id] with square brackets. Without them, Next.js treats it as a static route segment.

Help me fix a type error in my dynamic route handler

✔️ Awesome, I've got everything!

Great. Double check you have saved app/api/projects/[id]/route.ts before moving on.

ⓧ I'd like to double check the full code

import { createClient } from "@/lib/supabase/server";

export async function PUT(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const supabase = await createClient();
  const { id } = await params;

  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  const body = await request.json();

  const { data, error } = await supabase
    .from("projects")
    .update({
      title: body.title,
      description: body.description,
      tech_stack: body.tech_stack,
      live_url: body.live_url,
      github_url: body.github_url,
      updated_at: new Date().toISOString(),
    })
    .eq("id", id)
    .select()
    .single();

  if (error) {
    return Response.json({ error: error.message }, { status: 500 });
  }

  return Response.json(data);
}

export async function DELETE(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const supabase = await createClient();
  const { id } = await params;

  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { error } = await supabase.from("projects").delete().eq("id", id);

  if (error) {
    return Response.json({ error: error.message }, { status: 500 });
  }

  return new Response(null, { status: 204 });
}
Add authentication middleware

Your API endpoints reject unauthenticated requests with a 401 status. But you also need to protect the editor page itself at the route level, before the page even loads.

In Next.js 16, middleware lives in a file called proxy.ts at the project root. It runs on every matched request and can redirect visitors who are not logged in.

  • Create a new file called proxy.ts in the root of your my-portfolio project (the same level as package.json).
  • Paste the following code into proxy.ts:
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";

export async function middleware(request: NextRequest) {
  let supabaseResponse = NextResponse.next({
    request,
  });

  // Create a Supabase client that can read/write cookies on the request
  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll();
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value)
          );
          supabaseResponse = NextResponse.next({
            request,
          });
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          );
        },
      },
    }
  );

  // Check if the user is authenticated
  const {
    data: { user },
  } = await supabase.auth.getUser();

  // Redirect unauthenticated users away from /editor
  if (
    !user &&
    request.nextUrl.pathname.startsWith("/editor")
  ) {
    const url = request.nextUrl.clone();
    url.pathname = "/login";
    return NextResponse.redirect(url);
  }

  return supabaseResponse;
}

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

What does this code do?

The middleware intercepts every request that matches /editor/:path*. It creates a Supabase client, checks for a valid user session, and redirects unauthenticated visitors to /login.

The cookie handling ensures auth tokens refresh properly between the browser and server. This pattern is recommended by Supabase for Next.js apps using the App Router.

Why proxy.ts instead of middleware.ts?

Next.js 16 renamed middleware.ts to proxy.ts to better describe the file's role as a network boundary. The exported function is still called middleware. Only the filename changed.

  • Save proxy.ts.
  • Check your terminal. The dev server should recompile without errors.

Dev server crashing after adding proxy.ts?

  • Make sure proxy.ts is in the project root next to package.json. Placing it inside the app/ folder will not work.
  • Verify you are importing from @supabase/ssr and next/server. These packages should already be installed from Step 1.

Why is my proxy.ts causing errors?

✔️ Awesome, I've got everything!

Great. Double check you have saved proxy.ts before moving on.

ⓧ I'd like to double check the full code

import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";

export async function middleware(request: NextRequest) {
  let supabaseResponse = NextResponse.next({
    request,
  });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll();
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value)
          );
          supabaseResponse = NextResponse.next({
            request,
          });
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          );
        },
      },
    }
  );

  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (
    !user &&
    request.nextUrl.pathname.startsWith("/editor")
  ) {
    const url = request.nextUrl.clone();
    url.pathname = "/login";
    return NextResponse.redirect(url);
  }

  return supabaseResponse;
}

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

Time to verify the API works end to end.

  • Open your browser and visit http://localhost:3000/api/projects.
  • You should see your test project displayed as a JSON array.

Your REST API is live. You have GET for reading, POST for creating, PUT for updating, and DELETE for removing projects. Next up, you will build the public portfolio page that fetches and displays this data.

Build the Public Portfolio Page

Your RESTful API is fully built and ready to serve data. Now you need the page that visitors and employers actually see when they land on your portfolio URL.

In this step, you will replace the default Next.js homepage with a clean, minimalist portfolio page. The page fetches your projects directly from Supabase using a Server Component and displays them in a responsive grid.

In this step, get ready to:
  • Replace the default homepage with a hero section and project grid.
  • Fetch and display projects using a Server Component.
  • Apply minimalist styling with Tailwind CSS.
Create the portfolio layout and page

The default app/page.tsx that Next.js generated contains a welcome screen. You will replace it entirely with your portfolio component.

  • In VS Code, open app/page.tsx from the Explorer sidebar.
  • Select all the existing code and delete it.
  • Replace it with the following code:
import { createClient } from "@/lib/supabase/server";
import Link from "next/link";

export default async function Home() {
  // Fetch all projects from Supabase, newest first
  const supabase = await createClient();
  const { data: projects } = await supabase
    .from("projects")
    .select("*")
    .order("created_at", { ascending: false });

  return (
    <main className="min-h-screen bg-white text-gray-900">
      <div className="max-w-4xl mx-auto px-6 py-20">
        {/* Hero section with your name and intro */}
        <header className="mb-16">
          <h1 className="text-4xl font-bold tracking-tight">Your Name</h1>
          <p className="mt-2 text-lg text-gray-600">
            Software Engineer
          </p>
          <p className="mt-4 text-gray-500 max-w-2xl">
            I build web applications with modern technologies. Here are some of
            my recent projects.
          </p>
        </header>

        {/* Project cards grid */}
        <section>
          <h2 className="text-2xl font-semibold mb-8">Projects</h2>
          <div className="grid gap-6 md:grid-cols-2">
            {projects?.map((project) => (
              <div
                key={project.id}
                className="border border-gray-200 rounded-lg p-6 hover:border-gray-400 transition-colors"
              >
                <h3 className="text-lg font-medium">{project.title}</h3>
                <p className="mt-2 text-sm text-gray-600">
                  {project.description}
                </p>
                <p className="mt-3 text-xs text-gray-400">
                  {project.tech_stack}
                </p>
                <div className="mt-4 flex gap-3">
                  {project.live_url && (
                    <a
                      href={project.live_url}
                      target="_blank"
                      rel="noopener noreferrer"
                      className="text-sm text-blue-600 hover:underline"
                    >
                      Live Demo
                    </a>
                  )}
                  {project.github_url && (
                    <a
                      href={project.github_url}
                      target="_blank"
                      rel="noopener noreferrer"
                      className="text-sm text-blue-600 hover:underline"
                    >
                      GitHub
                    </a>
                  )}
                </div>
              </div>
            ))}
          </div>
        </section>
      </div>
    </main>
  );
}

What does this code do?

This file is a Server Component. That means it runs on the server before the HTML is sent to the browser. The async keyword on the function allows you to fetch data directly inside the component without useEffect or client-side loading states.

  • createClient() creates a Supabase server client (the same utility you built in lib/supabase/server.ts).
  • The query fetches all rows from the projects table, ordered by created_at descending (newest first).
  • The header block is your hero section with your name, title, and a short bio.
  • The projects?.map() loop renders each project as a card with its title, description, tech stack, and conditional links.
  • Save app/page.tsx.
  • Return to your browser at http://localhost:3000. You should see your hero section at the top and your test project displayed as a card below it.

Don't see your project card?

  • Check that your .env.local file has the correct Supabase URL and publishable key. The page fetches data server-side, so incorrect credentials produce an empty page with no browser console errors.
  • Confirm your test row still exists in the Supabase Table Editor. If you deleted it earlier, insert a new row with at least a title and description value.
  • Make sure the RLS public SELECT policy is active. Without it, the anon role cannot read from the table.

Why isn't my portfolio page showing any projects from Supabase?

Understand the Server Component data fetching pattern

Notice that your page component is an async function. This is what makes it a Server Component that fetches data before rendering.

Why fetch directly in the component?

In traditional React apps, you would use useEffect to fetch data after the page loads. This causes a loading flash while the browser waits for data.

Server Components fetch data on the server before sending HTML to the browser. Visitors see your projects instantly with no loading spinner. This also means search engines can index your portfolio content.

Review the minimalist design approach

The styling uses Tailwind CSS utility classes to create a clean, professional look. Every design choice is intentional.

What are the key styling decisions?

  • max-w-4xl mx-auto constrains content width and centers it. Wide text lines are hard to read.
  • md:grid-cols-2 creates a two-column grid on medium screens and above. On mobile, cards stack into a single column automatically.
  • hover:border-gray-400 transition-colors adds a subtle border darkening when visitors hover over a card. The transition makes it feel smooth.
  • Conditional rendering with {project.live_url && (...)} only shows links when the URL exists. This prevents empty link text from appearing.

Resize your browser window narrower than 768px. The two-column grid collapses to a single column, keeping your portfolio readable on mobile devices.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file.

ⓧ I'd like to double check the full code

Here is the complete app/page.tsx file:

import { createClient } from "@/lib/supabase/server";
import Link from "next/link";

export default async function Home() {
  const supabase = await createClient();
  const { data: projects } = await supabase
    .from("projects")
    .select("*")
    .order("created_at", { ascending: false });

  return (
    <main className="min-h-screen bg-white text-gray-900">
      <div className="max-w-4xl mx-auto px-6 py-20">
        <header className="mb-16">
          <h1 className="text-4xl font-bold tracking-tight">Your Name</h1>
          <p className="mt-2 text-lg text-gray-600">
            Software Engineer
          </p>
          <p className="mt-4 text-gray-500 max-w-2xl">
            I build web applications with modern technologies. Here are some of
            my recent projects.
          </p>
        </header>

        <section>
          <h2 className="text-2xl font-semibold mb-8">Projects</h2>
          <div className="grid gap-6 md:grid-cols-2">
            {projects?.map((project) => (
              <div
                key={project.id}
                className="border border-gray-200 rounded-lg p-6 hover:border-gray-400 transition-colors"
              >
                <h3 className="text-lg font-medium">{project.title}</h3>
                <p className="mt-2 text-sm text-gray-600">
                  {project.description}
                </p>
                <p className="mt-3 text-xs text-gray-400">
                  {project.tech_stack}
                </p>
                <div className="mt-4 flex gap-3">
                  {project.live_url && (
                    <a
                      href={project.live_url}
                      target="_blank"
                      rel="noopener noreferrer"
                      className="text-sm text-blue-600 hover:underline"
                    >
                      Live Demo
                    </a>
                  )}
                  {project.github_url && (
                    <a
                      href={project.github_url}
                      target="_blank"
                      rel="noopener noreferrer"
                      className="text-sm text-blue-600 hover:underline"
                    >
                      GitHub
                    </a>
                  )}
                </div>
              </div>
            ))}
          </div>
        </section>
      </div>
    </main>
  );
}

Your public portfolio page is live and pulling data straight from Supabase. Next up, you will build the private editor dashboard so you can create, edit, and delete projects without touching the database directly.

Build the Editor Dashboard and Deploy

Your REST API is ready to handle requests. Now you need a way for you (and only you) to manage your portfolio content through a browser.

In this step, you will build two new pages: a login form that authenticates you with Supabase Auth, and a private editor dashboard where you can create, edit, and delete projects. Then you will deploy the entire application to Vercel so it is live on the internet.

In this step, get ready to:
  • Create a login page that authenticates with Supabase.
  • Build an editor dashboard with full CRUD functionality.
  • Deploy the finished application to Vercel.
Create the login page

Before anyone can access the editor, they need to prove they are the portfolio owner. This login page uses your Supabase user account to authenticate with email and password.

  • Create a new folder and file at app/login/page.tsx inside your my-portfolio project.
  • Paste the following code into app/login/page.tsx:
"use client";

import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { useRouter } from "next/navigation";

export default function LoginPage() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");
  const router = useRouter();
  const supabase = createClient();

  // Handle form submission for login
  async function handleLogin(e: React.FormEvent) {
    e.preventDefault();
    setError("");

    const { error } = await supabase.auth.signInWithPassword({
      email,
      password,
    });

    if (error) {
      setError(error.message);
    } else {
      // Redirect to the editor on successful login
      router.push("/editor");
    }
  }

  return (
    <main className="min-h-screen flex items-center justify-center bg-white">
      <form onSubmit={handleLogin} className="w-full max-w-sm space-y-4">
        <h1 className="text-2xl font-bold text-center">Editor Login</h1>
        {error && <p className="text-red-500 text-sm text-center">{error}</p>}
        <input
          type="email"
          placeholder="Email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          className="w-full px-4 py-2 border border-gray-300 rounded-md"
          required
        />
        <input
          type="password"
          placeholder="Password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          className="w-full px-4 py-2 border border-gray-300 rounded-md"
          required
        />
        <button
          type="submit"
          className="w-full py-2 bg-gray-900 text-white rounded-md hover:bg-gray-800"
        >
          Sign In
        </button>
      </form>
    </main>
  );
}

What does this code do?

This is a Client Component ("use client") because it uses browser-side state and event handlers.

  • supabase.auth.signInWithPassword() sends the email and password to Supabase and returns a session if the credentials match.
  • On success, router.push("/editor") navigates the user to the protected editor page.
  • On failure, the error message from Supabase is displayed above the form.

How is the editor route protected?

The proxy.ts file you created earlier acts as middleware. It checks whether the user is authenticated before allowing access to any /editor path. If there is no session, the middleware redirects the visitor to /login automatically.

  • Save app/login/page.tsx.
  • In your browser, navigate to http://localhost:3000/login. You should see a centered login form with email and password fields.

Not seeing the login page?

  • Confirm the file is saved at exactly app/login/page.tsx (not app/login/login.tsx or similar). Next.js uses page.tsx as the route entry point.
  • Check that your dev server is still running in the terminal.

Help me debug my login page not appearing.

Build the CRUD editor interface

The editor page is where you manage all your portfolio projects. It fetches data from your REST API, displays a form for adding or editing entries, and provides delete buttons on each project card.

  • Create a new folder and file at app/editor/page.tsx inside your my-portfolio project.
  • Paste the following code into app/editor/page.tsx:
"use client";

import { useState, useEffect } from "react";
import { createClient } from "@/lib/supabase/client";
import { useRouter } from "next/navigation";

// Type definition for a portfolio project
interface Project {
  id: string;
  title: string;
  description: string;
  tech_stack: string;
  live_url: string;
  github_url: string;
}

export default function EditorPage() {
  const [projects, setProjects] = useState<Project[]>([]);
  const [editingId, setEditingId] = useState<string | null>(null);
  const [form, setForm] = useState({
    title: "",
    description: "",
    tech_stack: "",
    live_url: "",
    github_url: "",
  });
  const router = useRouter();
  const supabase = createClient();

  // Fetch projects on initial page load
  useEffect(() => {
    fetchProjects();
  }, []);

  // GET all projects from the API
  async function fetchProjects() {
    const res = await fetch("/api/projects");
    const data = await res.json();
    setProjects(data);
  }

  // Handle form submission for create or update
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();

    if (editingId) {
      // PUT request to update an existing project
      await fetch(`/api/projects/${editingId}`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(form),
      });
    } else {
      // POST request to create a new project
      await fetch("/api/projects", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(form),
      });
    }

    setForm({ title: "", description: "", tech_stack: "", live_url: "", github_url: "" });
    setEditingId(null);
    fetchProjects();
  }

  // DELETE a project by ID
  async function handleDelete(id: string) {
    await fetch(`/api/projects/${id}`, { method: "DELETE" });
    fetchProjects();
  }

  // Populate form with existing project data for editing
  function handleEdit(project: Project) {
    setEditingId(project.id);
    setForm({
      title: project.title,
      description: project.description,
      tech_stack: project.tech_stack,
      live_url: project.live_url || "",
      github_url: project.github_url || "",
    });
  }

  // Sign out and redirect to login
  async function handleLogout() {
    await supabase.auth.signOut();
    router.push("/login");
  }

  return (
    <main className="min-h-screen bg-white text-gray-900">
      <div className="max-w-4xl mx-auto px-6 py-10">
        <div className="flex justify-between items-center mb-8">
          <h1 className="text-2xl font-bold">Portfolio Editor</h1>
          <button
            onClick={handleLogout}
            className="text-sm text-gray-500 hover:text-gray-800"
          >
            Sign Out
          </button>
        </div>

        <form onSubmit={handleSubmit} className="space-y-3 mb-10 p-6 border border-gray-200 rounded-lg">
          <h2 className="text-lg font-medium">
            {editingId ? "Edit Project" : "Add New Project"}
          </h2>
          <input
            type="text"
            placeholder="Project Title"
            value={form.title}
            onChange={(e) => setForm({ ...form, title: e.target.value })}
            className="w-full px-4 py-2 border border-gray-300 rounded-md"
            required
          />
          <textarea
            placeholder="Description"
            value={form.description}
            onChange={(e) => setForm({ ...form, description: e.target.value })}
            className="w-full px-4 py-2 border border-gray-300 rounded-md"
            rows={3}
            required
          />
          <input
            type="text"
            placeholder="Tech Stack (e.g. React, Node.js, PostgreSQL)"
            value={form.tech_stack}
            onChange={(e) => setForm({ ...form, tech_stack: e.target.value })}
            className="w-full px-4 py-2 border border-gray-300 rounded-md"
          />
          <input
            type="url"
            placeholder="Live URL (optional)"
            value={form.live_url}
            onChange={(e) => setForm({ ...form, live_url: e.target.value })}
            className="w-full px-4 py-2 border border-gray-300 rounded-md"
          />
          <input
            type="url"
            placeholder="GitHub URL (optional)"
            value={form.github_url}
            onChange={(e) => setForm({ ...form, github_url: e.target.value })}
            className="w-full px-4 py-2 border border-gray-300 rounded-md"
          />
          <div className="flex gap-2">
            <button
              type="submit"
              className="px-4 py-2 bg-gray-900 text-white rounded-md hover:bg-gray-800"
            >
              {editingId ? "Update" : "Add Project"}
            </button>
            {editingId && (
              <button
                type="button"
                onClick={() => {
                  setEditingId(null);
                  setForm({ title: "", description: "", tech_stack: "", live_url: "", github_url: "" });
                }}
                className="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
              >
                Cancel
              </button>
            )}
          </div>
        </form>

        <div className="space-y-4">
          <h2 className="text-lg font-medium">Your Projects</h2>
          {projects.map((project) => (
            <div
              key={project.id}
              className="flex items-center justify-between p-4 border border-gray-200 rounded-lg"
            >
              <div>
                <h3 className="font-medium">{project.title}</h3>
                <p className="text-sm text-gray-500">{project.tech_stack}</p>
              </div>
              <div className="flex gap-2">
                <button
                  onClick={() => handleEdit(project)}
                  className="text-sm px-3 py-1 border border-gray-300 rounded-md hover:bg-gray-50"
                >
                  Edit
                </button>
                <button
                  onClick={() => handleDelete(project.id)}
                  className="text-sm px-3 py-1 border border-red-300 text-red-600 rounded-md hover:bg-red-50"
                >
                  Delete
                </button>
              </div>
            </div>
          ))}
        </div>
      </div>
    </main>
  );
}

What does this code do?

This is a single Client Component that handles all four CRUD operations by calling your REST API routes.

  • fetchProjects() calls GET /api/projects to load the project list.
  • handleSubmit() calls either POST /api/projects (to create) or PUT /api/projects/[id] (to update), depending on whether editingId is set.
  • handleDelete() calls DELETE /api/projects/[id] to remove a project.
  • handleLogout() signs the user out and redirects back to the login page.
  • Save app/editor/page.tsx.
  • Navigate to http://localhost:3000/login in your browser.
  • Enter the email and password for the user account you created in Supabase earlier.
  • Click Sign In. You should be redirected to /editor and see the "Portfolio Editor" heading with your test project listed below the form.

Redirected back to login instead of the editor?

  • Double check you are using the exact email and password from the user you created in the Supabase Authentication dashboard.
  • Confirm that proxy.ts is at the root of your project (not inside app/). This file must be in the same directory as package.json.
  • Make sure your .env.local values match the credentials from your Supabase project's Connect dialog.

Help me debug why I keep getting redirected to login.

  • Test creating a new project: fill in the title, description, and tech stack fields, then click Add Project.
  • Open http://localhost:3000 in a new tab. Verify your new project appears on the public portfolio page.

✔️ Awesome, I've got everything!

Great. Double check you have saved both app/login/page.tsx and app/editor/page.tsx.

ⓧ I'd like to double check the full code

Here is the complete app/login/page.tsx:

"use client";

import { useState } from "react";
import { createClient } from "@/lib/supabase/client";
import { useRouter } from "next/navigation";

export default function LoginPage() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");
  const router = useRouter();
  const supabase = createClient();

  async function handleLogin(e: React.FormEvent) {
    e.preventDefault();
    setError("");

    const { error } = await supabase.auth.signInWithPassword({
      email,
      password,
    });

    if (error) {
      setError(error.message);
    } else {
      router.push("/editor");
    }
  }

  return (
    <main className="min-h-screen flex items-center justify-center bg-white">
      <form onSubmit={handleLogin} className="w-full max-w-sm space-y-4">
        <h1 className="text-2xl font-bold text-center">Editor Login</h1>
        {error && <p className="text-red-500 text-sm text-center">{error}</p>}
        <input
          type="email"
          placeholder="Email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          className="w-full px-4 py-2 border border-gray-300 rounded-md"
          required
        />
        <input
          type="password"
          placeholder="Password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          className="w-full px-4 py-2 border border-gray-300 rounded-md"
          required
        />
        <button
          type="submit"
          className="w-full py-2 bg-gray-900 text-white rounded-md hover:bg-gray-800"
        >
          Sign In
        </button>
      </form>
    </main>
  );
}

Here is the complete app/editor/page.tsx:

"use client";

import { useState, useEffect } from "react";
import { createClient } from "@/lib/supabase/client";
import { useRouter } from "next/navigation";

interface Project {
  id: string;
  title: string;
  description: string;
  tech_stack: string;
  live_url: string;
  github_url: string;
}

export default function EditorPage() {
  const [projects, setProjects] = useState<Project[]>([]);
  const [editingId, setEditingId] = useState<string | null>(null);
  const [form, setForm] = useState({
    title: "",
    description: "",
    tech_stack: "",
    live_url: "",
    github_url: "",
  });
  const router = useRouter();
  const supabase = createClient();

  useEffect(() => {
    fetchProjects();
  }, []);

  async function fetchProjects() {
    const res = await fetch("/api/projects");
    const data = await res.json();
    setProjects(data);
  }

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();

    if (editingId) {
      await fetch(`/api/projects/${editingId}`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(form),
      });
    } else {
      await fetch("/api/projects", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(form),
      });
    }

    setForm({ title: "", description: "", tech_stack: "", live_url: "", github_url: "" });
    setEditingId(null);
    fetchProjects();
  }

  async function handleDelete(id: string) {
    await fetch(`/api/projects/${id}`, { method: "DELETE" });
    fetchProjects();
  }

  function handleEdit(project: Project) {
    setEditingId(project.id);
    setForm({
      title: project.title,
      description: project.description,
      tech_stack: project.tech_stack,
      live_url: project.live_url || "",
      github_url: project.github_url || "",
    });
  }

  async function handleLogout() {
    await supabase.auth.signOut();
    router.push("/login");
  }

  return (
    <main className="min-h-screen bg-white text-gray-900">
      <div className="max-w-4xl mx-auto px-6 py-10">
        <div className="flex justify-between items-center mb-8">
          <h1 className="text-2xl font-bold">Portfolio Editor</h1>
          <button
            onClick={handleLogout}
            className="text-sm text-gray-500 hover:text-gray-800"
          >
            Sign Out
          </button>
        </div>

        <form onSubmit={handleSubmit} className="space-y-3 mb-10 p-6 border border-gray-200 rounded-lg">
          <h2 className="text-lg font-medium">
            {editingId ? "Edit Project" : "Add New Project"}
          </h2>
          <input
            type="text"
            placeholder="Project Title"
            value={form.title}
            onChange={(e) => setForm({ ...form, title: e.target.value })}
            className="w-full px-4 py-2 border border-gray-300 rounded-md"
            required
          />
          <textarea
            placeholder="Description"
            value={form.description}
            onChange={(e) => setForm({ ...form, description: e.target.value })}
            className="w-full px-4 py-2 border border-gray-300 rounded-md"
            rows={3}
            required
          />
          <input
            type="text"
            placeholder="Tech Stack (e.g. React, Node.js, PostgreSQL)"
            value={form.tech_stack}
            onChange={(e) => setForm({ ...form, tech_stack: e.target.value })}
            className="w-full px-4 py-2 border border-gray-300 rounded-md"
          />
          <input
            type="url"
            placeholder="Live URL (optional)"
            value={form.live_url}
            onChange={(e) => setForm({ ...form, live_url: e.target.value })}
            className="w-full px-4 py-2 border border-gray-300 rounded-md"
          />
          <input
            type="url"
            placeholder="GitHub URL (optional)"
            value={form.github_url}
            onChange={(e) => setForm({ ...form, github_url: e.target.value })}
            className="w-full px-4 py-2 border border-gray-300 rounded-md"
          />
          <div className="flex gap-2">
            <button
              type="submit"
              className="px-4 py-2 bg-gray-900 text-white rounded-md hover:bg-gray-800"
            >
              {editingId ? "Update" : "Add Project"}
            </button>
            {editingId && (
              <button
                type="button"
                onClick={() => {
                  setEditingId(null);
                  setForm({ title: "", description: "", tech_stack: "", live_url: "", github_url: "" });
                }}
                className="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
              >
                Cancel
              </button>
            )}
          </div>
        </form>

        <div className="space-y-4">
          <h2 className="text-lg font-medium">Your Projects</h2>
          {projects.map((project) => (
            <div
              key={project.id}
              className="flex items-center justify-between p-4 border border-gray-200 rounded-lg"
            >
              <div>
                <h3 className="font-medium">{project.title}</h3>
                <p className="text-sm text-gray-500">{project.tech_stack}</p>
              </div>
              <div className="flex gap-2">
                <button
                  onClick={() => handleEdit(project)}
                  className="text-sm px-3 py-1 border border-gray-300 rounded-md hover:bg-gray-50"
                >
                  Edit
                </button>
                <button
                  onClick={() => handleDelete(project.id)}
                  className="text-sm px-3 py-1 border border-red-300 text-red-600 rounded-md hover:bg-red-50"
                >
                  Delete
                </button>
              </div>
            </div>
          ))}
        </div>
      </div>
    </main>
  );
}
Deploy to Vercel

Your portfolio works locally. Now you will push it to GitHub and connect it to Vercel for live hosting.

  • Go to github.com and create a new repository named my-portfolio. Leave it empty (no README, no .gitignore).
  • Back in your terminal inside the my-portfolio folder, add the remote and push your code by running these commands:
git add .
git commit -m "Portfolio with REST API and editor"
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/my-portfolio.git"]]
git push -u origin main

What does this do?

  • git add . stages all your project files.
  • git commit saves a snapshot of your code locally.
  • git remote add origin links your local repo to the GitHub repository.
  • git push uploads everything to GitHub.

Push rejected or permission denied?

  • If you see a "permission denied" error, make sure you are signed into git with your GitHub account. You may need to set up a personal access token or SSH key.
  • If the push is rejected because the branch already exists, try git push -u origin main --force since this is your first deploy.

Help me fix git push errors to GitHub.

Now connect the GitHub repository to Vercel for automatic deployments.

  • Open vercel.com and sign in with your GitHub account.
  • Click the Add New... button in the top right, then select Project.
  • Under Import Git Repository, find my-portfolio in the list and click Import.
  • Before deploying, expand the Environment Variables section.
  • Add NEXT_PUBLIC_SUPABASE_URL and paste your Supabase project URL as the value.
  • Add NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY and paste your Supabase publishable key as the value.
  • Click Deploy.

Why add environment variables in Vercel?

Your .env.local file is ignored by git (it is in .gitignore by default). Vercel needs its own copy of these values to connect to Supabase during the build and at runtime.

Vercel will install dependencies, build your Next.js app, and deploy it. This takes about a minute.

  • Once the deploy completes, Vercel shows a "Congratulations" page with your live URL.
  • Click the URL to open your live portfolio. You should see the same public page you had locally.
  • Navigate to /login on your live URL. Sign in with your Supabase credentials. Confirm the editor loads and you can add a project.

Deploy failed or site shows an error?

  • Check the Vercel build logs for the specific error. The most common issue is a missing environment variable.
  • Verify both NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY are set in the Vercel project settings under Settings then Environment Variables.
  • If authentication does not work on the live site, go to your Supabase project's Authentication settings and add your Vercel URL to the allowed redirect URLs.

Help me debug my Vercel deployment.

Secret mission

Add Image Uploads with Supabase Storage

Your portfolio entries are text-only right now. Real portfolio sites show screenshots and thumbnails that catch a recruiter's eye in seconds. In this secret mission, you will integrate Supabase Storage to let yourself upload project images through the editor and display them on the public portfolio.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on free-tier services, 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:

  • Supabase project (database, auth, and storage bucket).
  • Vercel deployment.
  • GitHub repository.
  • Local my-portfolio project folder.

Keep everything running

No action needed. Choose this if you want your portfolio to stay live for employers to visit.

  • Both Supabase free tier and Vercel free tier have generous limits for personal projects.
  • Your portfolio can stay live indefinitely at no cost.
  • Free-tier resources do not incur charges when idle.

Pause - I'll come back to this later

Shut down your local development server if it is still running. Your cloud resources will remain available at no cost.

  • In your terminal, press Ctrl+C to stop the Next.js dev server if it is running.
  • Your Supabase project and Vercel deployment will stay live on their free tiers with no action needed.
  • When you return, run npm run dev from the my-portfolio folder to start developing again.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow these steps to clean up everything you created.

  • Delete your Vercel project: open your Vercel dashboard, select the my-portfolio project, go to Settings, scroll to the bottom of the General page, and click Delete Project.
  • Delete your Supabase project: open your Supabase dashboard, select your project, go to Project Settings, then General, scroll to the bottom, and click Delete project. This removes your database, auth users, and the project-images storage bucket.
  • Delete your GitHub repository: go to your repository page, click Settings, scroll to the Danger Zone section at the bottom, and click Delete this repository.
  • Delete your local project folder by running this command from your Desktop:
rm -rf ~/Desktop/my-portfolio

On Windows, run this instead:

Remove-Item -Recurse -Force ~\Desktop\my-portfolio

Nice Work!

Nice Work!

Nice work! You just built and deployed a full-stack portfolio website with a RESTful API you designed from scratch, a private editor dashboard, and live deployment on Vercel.

You've learned how to:

  • Design and implement a RESTful API with proper HTTP methods (GET, POST, PUT, DELETE) using Next.js Route Handlers.
  • Set up a PostgreSQL database with Supabase including Row Level Security policies and email authentication to protect write operations.
  • Deploy a complete full-stack application to Vercel with a public portfolio page, a private CRUD editor, and environment variable configuration.
  • Secret Mission: Added image upload functionality using Supabase Storage to display project thumbnails on your portfolio cards.

Ready to quiz yourself?