מדריך next.js חלק 8 - העלאה לשרת

17/12/2023

נסיים את המדריך בחיבור עם ורסל לצורך העלאת האתר שכתבנו לאינטרנט.

1. תיקון שגיאות בניה

לפני שאפשר להעלות את הקוד לשרת צריך לוודא שהוא נבנה בלי שגיאות, ואם עקבתם אחרי המדריך עד כאן תצטערו לשמוע שזה לא המקרה. במהלך העבודה עשיתי מספר קיצורי דרך שאומנם היו קלים יותר לכתיבה והסבר אבל הרגיזו את next. אלה הבעיות המרכזיות:

  1. בכל הקומפוננטות ייצאתי פונקציה אנונומית. נקסט אוהב שיש שם לכל קומפוננטה ולכן אני מחליף כל יצירת קומפוננטה לפונקציה ממש עם שם, כלומר במקום לכתוב:
export default () => { ... }

אני כותב:

export default function AboutPage() { ... }
  1. בקומפוננטת התפריט היה לי מערך של פריטים שכללו נתיב על השרת. ההגדרה המקורית של המערך היתה:
items: Array<{href: string, text: string}>

אבל נקסט רוצה לעשות בדיקת טיפוסים גם על הנתיבים שאני מעביר לאלמנט Link ולכן ה href לא יכול להיות כל string אלא צריך להיות מחרוזת שהיא גם נתיב תקני על השרת. בשביל לתקן את שגיאת הבניה החלפתי את ההגדרה ל:

export default function Menu<T extends string>({ items }: {
  items: Array<{href: Route<T>, text: string}>
}) {
  1. הוספתי מאפיין key לכל רשימה.

זו רשימת השגיאות המלאה שתיקנתי:

diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx
index cf82120..f697b7c 100644
--- a/src/app/about/page.tsx
+++ b/src/app/about/page.tsx
@@ -1,8 +1,8 @@
-export default () => {
+export default function About() {
   return (
     <div>
       <h1>About Us</h1>
-      <p>This is an example lesson for using next.js router</p>
+      <p>This is an example lesson for using next.js</p>
     </div>
   )
 }
\ No newline at end of file
diff --git a/src/app/components/menu.tsx b/src/app/components/menu.tsx
index 936ca44..468dfcc 100644
--- a/src/app/components/menu.tsx
+++ b/src/app/components/menu.tsx
@@ -1,16 +1,17 @@
 "use client";
 import Link from 'next/link';
+import type { Route } from 'next'
 import { usePathname } from 'next/navigation'


-export default ({ items }: {
-  items: Array<{href: String, text: String}>
-}) => {
+export default function Menu<T extends string>({ items }: {
+  items: Array<{href: Route<T>, text: String}>
+}) {
   const pathname = usePathname()
   return (
     <nav className='flex my-4 border-4 border-indigo-200 border-l-indigo-500'>
       {items.map(item => (
-      <div className='flex-1 px-2'>
+      <div className='flex-1 px-2' key={item.href}>
       <Link href={item.href} >{item.text}</Link>
       {pathname == item.href &&
       <span className="ml-2 w-2 h-2 bg-blue-500 rounded-full inline-block"></span>
diff --git a/src/app/counter.tsx b/src/app/counter.tsx
index 50da28f..c4bb5a4 100644
--- a/src/app/counter.tsx
+++ b/src/app/counter.tsx
@@ -2,7 +2,7 @@
 import { useState } from 'react';
 import Header from './header';

-export default () => {
+export default function Counter() {
   const [count, setCount] = useState(0);
   console.log('counter');
   return (
diff --git a/src/app/header.tsx b/src/app/header.tsx
index 0ff3c50..a9e22f5 100644
--- a/src/app/header.tsx
+++ b/src/app/header.tsx
@@ -1,4 +1,4 @@
-export default () => {
+export default function Header() {
   console.log('header');
   return <h1>Counter Header</h1>
 }
\ No newline at end of file
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 5cd02da..b27557b 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -2,7 +2,7 @@ import type { Metadata } from 'next'
 import { UserProvider } from '@auth0/nextjs-auth0/client';

 import { Inter } from 'next/font/google'
-import TopMenu from './servermenu';
+import TopMenu from './menu';
 import './globals.css'

 const inter = Inter({ subsets: ['latin'] })
diff --git a/src/app/menu.tsx b/src/app/menu.tsx
index 1e40b9c..2043559 100644
--- a/src/app/menu.tsx
+++ b/src/app/menu.tsx
@@ -2,7 +2,7 @@ import Menu from './components/menu';
 import { readdir } from 'node:fs/promises';
 import { existsSync } from 'node:fs';

-export default async () => {  
+export default async function ServerMenu() {  
   const pages = (await readdir('./src/app', { withFileTypes: true }))
   .filter(f => f.isDirectory)
   .filter(f => existsSync(`${f.path}/${f.name}/page.tsx`))
diff --git a/src/app/posts/newpost.tsx b/src/app/posts/newpost.tsx
index 2cc0068..e41f184 100644
--- a/src/app/posts/newpost.tsx
+++ b/src/app/posts/newpost.tsx
@@ -4,7 +4,7 @@ import { useRouter } from 'next/navigation';
 import { createPost
  } from "../db/posts"

-export default () => {
+export default function NewPost() {
   const router = useRouter();
   const textFieldRef = useRef<HTMLInputElement>(null);

diff --git a/src/app/posts/page.tsx b/src/app/posts/page.tsx
index 1aabcf9..074b9ba 100644
--- a/src/app/posts/page.tsx
+++ b/src/app/posts/page.tsx
@@ -2,7 +2,7 @@ import { PrismaClient } from '@prisma/client'
 import NewPost from './newpost';
 const prisma = new PrismaClient()

-export default async () => {
+export default async function PostsPage() {
   const posts = await prisma.post.findMany();

   return (
@@ -10,7 +10,7 @@ export default async () => {
       <NewPost />
       <ul>
         {posts.map(post => (
-          <li><b>{post.author}</b> {post.text}</li>
+          <li key={post.id}><b>{post.author}</b> {post.text}</li>
         ))}
       </ul>
     </main>
diff --git a/src/app/servermenu.tsx b/src/app/servermenu.tsx
index 392b0f2..1068c53 100644
--- a/src/app/servermenu.tsx
+++ b/src/app/servermenu.tsx
@@ -2,7 +2,7 @@ import { readdir } from 'node:fs/promises';
 import Link from 'next/link';
 import { existsSync } from 'node:fs';

-export default async () => {  
+export default async function ServerMenu() {  
   const pages = [
     {href: '/', text: 'home'},
     ...(await readdir('./src/app', { withFileTypes: true }))
@@ -16,7 +16,7 @@ export default async () => {
   return (
     <nav className='flex my-4 border-4 border-indigo-200 border-l-indigo-500'>
       {pages.map(page => (
-        <div className='flex-1 px-2'>
+        <div className='flex-1 px-2' key={page.href}>
           <Link href={page.href} >{page.text}</Link>
         </div>))}
     </nav>
diff --git a/src/app/users/client_user.tsx b/src/app/users/client_user.tsx
index 245ca30..44c61d5 100644
--- a/src/app/users/client_user.tsx
+++ b/src/app/users/client_user.tsx
@@ -12,7 +12,7 @@ export default function ProfileClient() {
     user && (
       <div>
         <h1>User Info from Client Component</h1>
-        <img src={user.picture} alt={user.name} />
+        <img src={user.picture!} alt={user.name!} />
         <h2>{user.name}</h2>
         <p>{user.email}</p>
       </div>
diff --git a/src/app/users/page.tsx b/src/app/users/page.tsx
index eb205f8..338473b 100644
--- a/src/app/users/page.tsx
+++ b/src/app/users/page.tsx
@@ -1,7 +1,7 @@
 import ClientUser from './client_user';
 import ServerUser from './server_user';

-export default () => {
+export default function UsersPage() {
   return (
     <div>
       <h1>User Page</h1>

לאחר התיקון אני מפעיל:

npm run build

ומוודא שהפלט ללא שגיאות (אזהרות זה בסדר).

2. חיבור הפרויקט ל Vercel

בשביל להעלות את הפרויקט לרשת אנחנו רוצים קודם כל לשמור אותו על Github Repository, אחרי זה לפתוח חשבון ב Vercel ואז לחבר את Vercel לגיטהאב שלנו כדי שיוכל למשוך את הפרויקט ולהפעיל אותו על השרתים שלהם. ורסל זו חברת הענן שיצרה את Next.JS והיא מספקת תשתיות ענן לפרויקטים, בהתחלה בחינם ואז כשהפרויקט גדל הם יתחילו לקחת כסף.

נכנס לאתר של Vercel ונפתח חשבון בכתובת https://vercel.com/.

לאחר מכן בלוח הבקרה נבחר Add New ואז Project, ואז נבחר לחבר את החשבון לגיטהאב ואת הפרויקט החדש שהעלינו לשם. חפשו את פקודת הבניה ושנו אותה ל:

npx prisma generate && next build

בשביל שפריזמה לא יתלונן. הוא עדיין יתלונן ויגיד לכם במהלך הבניה ש:

error: Environment variable not found: DATABASE_URL

אבל זה הגיוני כי אנחנו לא רוצים שיעבוד עם בסיס הנתונים של הפיתוח.

3. מעבר לבסיס נתונים פרודקשן על Vercel

אחרי שהבניה נכשלה נלך להוסיף בסיס נתונים. אני חוזר למסך לוח הבקרה של ורסל ושם בוחר Add New ואז Storage ואז PostgreSQL Database. אני קורא לו posts-db. השלב הבא הוא כבר חיסרון של פריזמה - לפריזמה יש בעיה לתמוך בבסיסי נתונים שונים לסביבות שונות, כלומר SQLite לפיתוח ו Postgres לייצור. יש כל מיני פיתרונות אבל הכי קל זה להשתמש בפוסטגרס גם בפיתוח וגם בייצור, פשוט בייצור להשתמש בבסיס הנתונים שיצרנו על ורסל ובפיתוח להריץ פוסטגרס על דוקר בסביבה המקומית. בשביל להשאיר את המדריך פשוט אני אעביר את כל האפליקציה למצב ייצור שתעבוד מול בסיס הנתונים שיצרנו ב Vercel, ואשאיר לכם כתרגיל להריץ פוסטגרס על המחשב המקומי בשביל לבנות סביבת פיתוח שונה.

בשביל לחבר את פריזמה לבסיס הנתונים ב Vercel צריך להעביר את משתני הסביבה שלנו לענן. אני נכנס להגדרות הפרויקט, שם לתפריט Environment Variables ובוחר שם לעשות Import לקובץ משתני הסביבה שלי. אני מעלה את הקובץ:

.env.local

אחרי השמירה אני נכנס לתפריט Storage עדיין בתוך מסך הפרויקט (זה בין Logs ל Settings) ושם בוחר Connect ליד בסיס הנתונים. בחירה זו מוסיפה את משתני הסביבה הדרושים להפעלת הפרויקט מול בסיס הנתונים על ורסל. אחרי החיבור אני לוחץ על שם בסיס הנתונים נכנס לטאב Prisma ומעתיק את הקוד משם לקובץ prisma/schema.prisma אצלי בפרויקט. התוכן אצלי הוא:

datasource db {
  provider = "postgresql"
  url = env("POSTGRES_PRISMA_URL") // uses connection pooling
  directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection
}

אחר כך אני רוצה לסנכרן את הקובץ המקומי שלי עם מה שנשמר ב Vercel ולכן משורת הפקודה אני מפעיל:

npx vercel env pull .env.local

זה לא יעבוד והוא יתלונן שאתם לא מחוברים ושהפרויקט לא מקושר לפרויקט ב vercel. תלכו לפי ההוראות שהוא מדפיס למסך כדי לחבר את הפרויקט לפרויקט שיצרתם ב vercel. אחרי שהכל יעבוד נפעיל שוב את פקודת משיכת הסביבה ואז נקבל עדכון לקובץ .env.local עם כמה משתנים חדשים של ורסל וכל המשתנים הישנים שהיו לנו.

אחרי שהצלחתם למשוך את משתני הסביבה הנכונים תרצו לבקש מפריזמה לאתחל את בסיס הנתונים באמצעות הפעלת הפקודה:

npx dotenv-cli -e .env.local -- npx prisma db push

אם הכל יעבוד בלי שגיאות תוכלו לעשות קומיט ו push לאיזשהו שינוי לפרויקט ואז אוטומטית הגירסה החדשה תיבנה בענן של ורסל ובמסך הפרויקט תקבלו את הנתיב בו תוכלו לראות את הפרויקט. אצלי הקישור הוא:

https://nextjs-demo-jmgj9gfsp-ynonp.vercel.app

מוזמנים להיכנס לקישור ולשחק עם המערכת.

4. עדכון Auth0 עם הנתיבים החדשים

מנגנון המשתמשים בפרויקט הוא הרכיב האחרון שדורש עדכון - בגלל שהעברנו את השרת לענן אנחנו צריכים לשנות את ההגדרות של auth0 כדי להפנות לנתיבים החדשים. בעולם האמיתי יהיו לי שתי סביבות על auth0 אחת לפיתוח ואחת לייצור, ובעזרת משתני סביבה אבחר ביניהן. פה במדריך בשביל לשמור על פשטות אני בונה רק סביבת ייצור.

אני נכנס למסך הניהול של auth0 ושם בשתי התיבות שכבר כתבתי localhost:3000 אני מוסיף פסיק ואז את הדומיין שיש לי על ורסל, במקרה שלי זה נראה ככה:

http://localhost:3000/api/auth/callback, https://nextjs-demo-ynonp.vercel.app/api/auth/callback

בנוסף במסך משתני הסביבה של ורסל אני מעדכן את המשתנה AUTH0_BASE_URL לערך https://nextjs-demo-ynonp.vercel.app.

אחרי העדכון אני משנה גירסה בקובץ package.json ודוחף אותו למאגר כדי לבצע Deployment חדש, כי עדכון משתני סביבה משפיע רק על ה Deployment הבא.

5. ביטול מטמון בדף הפוסטים

האפליקציה כמעט עובדת בבדיקה מדוקדקת יותר נוכל לראות בעיה אחרונה - אחרי יצירת פוסט חדש רשימת הפוסטים לא מתעדכנת. מה שיותר גרוע, אפילו אחרי מעבר בין דפים הרשימה לא תתעדכן. הדרך היחידה לרענן את הרשימה היא לבצע Deploy חדש למערכת.

הסיבה לבעיה היא מנגנון המטמון של הדפים בסביבת הייצור של ורסל. מנגנון זה שומר את תוצאת הרינדור של רוב הדפים כדי לחסוך במשאבים. הבעיה שבמקרה שלנו דף רשימת הפוסטים הוא דינמי ולכן שמירת התוצאה שלו במטמון היא פשוט טעות.

בשביל להגיד ל next שאנחנו רוצים לרענן את הקומפוננטה בכל פניה עליי לייצא בדף הקומפוננטה משתנה בשם revalidate ולתת לו את הערך 0. אני מעדכן את הקוד של posts/page.tsx לגירסה הבאה:

import { PrismaClient } from '@prisma/client'
import NewPost from './newpost';
const prisma = new PrismaClient()

export const revalidate = 0

export default async function PostsPage() {
  console.log('rendering posts page');
  const posts = await prisma.post.findMany();

  return (
    <main className='p-2'>
      <NewPost />
      <p>Found {posts.length} posts</p>
      <ul>
        {posts.map(post => (
          <li key={post.id}><b>{post.author}</b> {post.text}</li>
        ))}
      </ul>
    </main>
  )
}

והרשימה מתחיל להתעדכן אחרי כל פוסט חדש.

אתם יכולים למצוא את הקוד המלא של הפרויקט שנבנה בכל המדריך במאגר: https://github.com/ynonp/nextjs-demo

פה אנחנו נפרדים מ next.js עם אפליקציה באוויר הכוללת מנגנון ניהול משתמשים ובסיס נתונים. מקווה שנהניתם מהסידרה וממחר הבלוג יחזור למתכונת הישנה עם פוסטים יומיים בנושאים משתנים.