Full Stack Development with Next.js 13

1. Introduction


2. Design

2. Design the UI Mock Up

3. Design the API Routes



citext表示不区分大小写

3. Setup

1. Windows Users Set Up Git Bash

2. Initialize NextJS Project

npx create-next-app@latest

3. Create and add SSH Key to GitHub

4. Push Project Repo to GitHub

5. Deploy Project to Vercel

6. Source Code

4. Home Page

1. Home Page

// app/page.tsx
import Link from "next/link";

export default function Home() {
    return (
        

strings

Sign In
Sign Up
) }

2. Deploy Home Page

5. Database Development

1. Create Initial SQL Migration

-- sql/1u.sql
-- 向上迁移
create database strings_app;

create extension if not exists citext;

create table if not exists public.users
(
    id         bigserial primary key,
    username   citext unique not null,
    password   text,
    avatar     text,
    is_admin   boolean    default false,
    created_at timestamp default now(),
    updated_at timestamp default now()
);

create table if not exists public.posts
(
    id         bigserial primary key,
    user_id    bigint references public.users (id),
    content    text,
    created_at timestamp default now(),
    updated_at timestamp default now()
);

create table if not exists public.follows
(
    user_id     bigint not null references public.users (id),
    follower_id bigint not null references public.users (id),
    created_at  timestamp default now(),
    updated_at  timestamp default now(),
    
    unique(user_id, follower_id)
);

-- 创建索引
create index posts_user_id_index on public.posts (user_id);
create index follows_user_id_index on public.follows (user_id);
create index follows_follower_id_index on public.follows (follower_id);
-- sql/1d.sql
-- 向下迁移
drop table follows;
drop table posts;
drop table users;
drop extension citext;

2. SQL and TypeScript Intro

// scripts/load-fake-data.ts
function loadFakeData(numUsers: number = 10) {
    console.log(`${numUsers}`)
}

loadFakeData()
tsc .\scripts\load-fake-data.ts
node .\scripts\load-fake-data.js

// or
tsx .\scripts\load-fake-data.ts

3. Establish DB Connection

pnpm install pg @next/env
pnpm install @types/pg --save-dev

// scripts/load-fake-data.ts
import {Client} from "pg";
import {loadEnvConfig} from "@next/env";

const projectDir = process.cwd()
loadEnvConfig(projectDir)

async function loadFakeData(numUsers: number = 10) {
    console.log(`executing load fake data. generating ${numUsers} users.`)

    const client = new Client({
        user: process.env.POSTGRES_USER,
        host: process.env.POSTGRES_HOST,
        database: process.env.POSTGRES_NAME,
        password: process.env.POSTGRES_PASSWORD,
        port: parseInt(process.env.POSTGRES_PORT!)
    })

    await client.connect()

    const res = await client.query("select 1")
    console.log(res)

    await client.end()
}

loadFakeData()
# .env.local
POSTGRES_HOST=localhost
POSTGRES_USER=postgres
POSTGRES_PASSWORD=123456
POSTGRES_NAME=strings_app
POSTGRES_PORT=5432

4. Load Fake Users

pnpm i --save-dev @faker-js/faker
// scripts/load-fake-data.ts
import {Client} from "pg";
import {loadEnvConfig} from "@next/env";
import {faker} from '@faker-js/faker'

const projectDir = process.cwd()
loadEnvConfig(projectDir)

async function loadFakeData(numUsers: number = 10) {
    console.log(`executing load fake data. generating ${numUsers} users.`)

    const client = new Client({
        user: process.env.POSTGRES_USER,
        host: process.env.POSTGRES_HOST,
        database: process.env.POSTGRES_NAME,
        password: process.env.POSTGRES_PASSWORD,
        port: parseInt(process.env.POSTGRES_PORT!)
    })

    await client.connect()

    try {
        await client.query("begin")

        for (let i = 0; i < numUsers; i++) {
            await client.query(
                "insert into public.users " +
                "(username, password, avatar) values ($1, $2, $3)",
                [
                    faker.internet.userName(),
                    "password",
                    faker.image.avatar()
                ]
            )
        }

        await client.query("commit")
    } catch (e) {
        await client.query("rollback")
        throw e
    } finally {
        await client.end()
    }
}

const numUsers = parseInt(process.argv[2]) || 10
loadFakeData(numUsers)

5. Load Fake Posts

// scripts/load-fake-data.ts
import {Client} from "pg";
import {loadEnvConfig} from "@next/env";
import {faker} from '@faker-js/faker'

const projectDir = process.cwd()
loadEnvConfig(projectDir)

async function loadFakeData(numUsers: number = 10) {
    // ...

    await client.connect()

    try {
        await client.query("begin")

        // ... 

        const res = await client.query(
            "select id from public.users order by created_at desc limit $1",
            [numUsers]
        )

        for (let row of res.rows) {
            for (let i = 0; i < Math.ceil(Math.random() * 50); i++) {
                await client.query(
                    "insert into public.posts (user_id, content) values ($1, $2)",
                    [row.id, faker.lorem.sentence()]
                )
            }
        }

        await client.query("commit")
    } catch (e) {
        await client.query("rollback")
        throw e
    } finally {
        await client.end()
    }
}

const numUsers = parseInt(process.argv[2]) || 10
loadFakeData(numUsers)

6. Load Fake Follows

// scripts/load-fake-data.ts
import {Client} from "pg";
import {loadEnvConfig} from "@next/env";
import {faker} from '@faker-js/faker'

const projectDir = process.cwd()
loadEnvConfig(projectDir)

async function loadFakeData(numUsers: number = 10) {
    console.log(`executing load fake data. generating ${numUsers} users.`)

    const client = new Client({
        user: process.env.POSTGRES_USER,
        host: process.env.POSTGRES_HOST,
        database: process.env.POSTGRES_NAME,
        password: process.env.POSTGRES_PASSWORD,
        port: parseInt(process.env.POSTGRES_PORT!)
    })

    await client.connect()

    try {
        await client.query("begin")

        // generate users
        for (let i = 0; i < numUsers; i++) {
            await client.query(
                "insert into public.users " +
                "(username, password, avatar) values ($1, $2, $3)",
                [
                    faker.internet.userName(),
                    "password",
                    faker.image.avatar()
                ]
            )
        }

        const res = await client.query(
            "select id from public.users order by created_at desc limit $1",
            [numUsers]
        )

        // generate posts
        for (let row of res.rows) {
            for (let i = 0; i < Math.ceil(Math.random() * 50); i++) {
                await client.query(
                    "insert into public.posts (user_id, content) values ($1, $2)",
                    [row.id, faker.lorem.sentence()]
                )
            }
        }

        // generate followers
        for (let row1 of res.rows) {
            for (let row2 of res.rows) {
                if (row1.id != row2.id) {
                    if (Math.random() > 0.5) {
                        await client.query(
                            "insert into follows " +
                            "(user_id, follower_id) values ($1, $2)",
                            [row1.id, row2.id]
                        )
                    }
                }
            }
        }

        await client.query("commit")
    } catch (e) {
        await client.query("rollback")
        throw e
    } finally {
        await client.end()
    }
}

const numUsers = parseInt(process.argv[2]) || 10
loadFakeData(numUsers)

7. bcrypt

pnpm i bcrypt
pnpm i @types/bcrypt --save-dev
const saltRounds = 10 // 盐
const hash = await bcrypt.hash('strings123', saltRounds)

await client.query(
    "insert into public.users " +
    "(username, password, avatar) values ($1, $2, $3)",
    [
        faker.internet.userName(),
        hash,
        faker.image.avatar()
    ]
)

8. Load Admin User

// scripts/load-admin-user.ts
import bcrypt from 'bcrypt'
import {getClient} from "@/db";

async function loadAdminUser(username: string, password: string) {
    console.log(`executing loading admin user ${username} pw ${password}`)

    const saltRounds = 10
    const hash = await bcrypt.hash(password, saltRounds)

    const client = await getClient()
    await client.connect()

    await client.query(
        "insert into public.users " +
        "(username, password, is_admin) values ($1, $2, $3)",
        [username, hash, true]
    )

    await client.end()
}

const username = process.argv[2]
const password = process.argv[3]
loadAdminUser(username, password)
// db.ts
import {loadEnvConfig} from "@next/env";
import {Client} from "pg";

const projectDir = process.cwd()
loadEnvConfig(projectDir)

export async function getClient() {
    const client = new Client({
        user: process.env.POSTGRES_USER,
        host: process.env.POSTGRES_HOST,
        database: process.env.POSTGRES_NAME,
        password: process.env.POSTGRES_PASSWORD,
        port: parseInt(process.env.POSTGRES_PORT!)
    })
    return client
}
tsx .\scripts\load-admin-user.ts admin strings123

9. Refactor DB Connection

10. Check In Code

6. Sign In Page

2. Install Jose

pnpm i jose

3. Log In Endpoint

// app/api/login/route.ts
import {NextResponse} from "next/server";
import {getClient} from "@/db";

export async function POST(request: Request) {
    const json = await request.json()

    const client = await getClient()
    await client.connect()
    const res = await client.query(
        "select id, username, password from users " +
        "where username ilike $1",
        [json.username]
    )
    await client.end()

    return NextResponse.json({data: res.rows[0]})
}
POST http://localhost:3000/api/login
Content-Type: application/json

{
  "username": "admin"
}

4. Refactor DB Helper

// db.ts
// ...

export async function sql(sql: string, values?: Array<any>)
    : Promise<QueryResult<any>> {
    const client = await getClient()
    await client.connect()
    const res = await client.query(sql, values)
    await client.end()
    return res
}

5. Generate JWT Token

// app/api/login/route.ts
import {NextResponse} from "next/server";
import {sql} from "@/db";
import bcrypt from "bcrypt";
import {SignJWT} from "jose";

export async function POST(request: Request) {
    try {
        const json = await request.json()

        const res = await sql(
            "select id, username, password from users " +
            "where username ilike $1",
            [json.username]
        )

        if (res.rowCount == 0) {
            return NextResponse.json(
                {error: "user not found"},
                {status: 404}
            )
        }

        const user = res.rows[0]
        const match = await bcrypt.compare(json.password, user.password)
        if (!match) {
            return NextResponse.json(
                {error: "invalid credentials"},
                {status: 401}
            )
        }

        const token = await new SignJWT({})
            .setProtectedHeader({alg: "HS256"})
            .setSubject(user.id)
            .setIssuedAt()
            .setExpirationTime("2w") // 2 week
            .sign(new TextEncoder().encode('my-jwt-secret'))

        const response =
            NextResponse.json({msg: 'login success'})
        // 防止跨站请求漏洞
        response.cookies.set('jwt-token', token, {
            sameSite: 'strict',
            httpOnly: true,
            secure: true
        })
        return response
    } catch (err) {
        return NextResponse.json(
            {error: "InternalServerError"},
            {status: 500}
        )
    }
}

6. Test With Postman

7. Sign In And Feed Page Stubs

next13支持路由组(图中的(public)不会被解析为路由/public)+自定义布局
我们约定(private)里的内容(名称是自定义的)只有登录才能看

// app/(private)/layout.tsx
import React from "react";

export default function PrivateLayout(
    {
        children,
    }: {
        children: React.ReactNode
    }
) {
    return 
{children}
}
// app/(public)/signin/page.tsx
export default async function SignIn() {
    return (
        

Sign In

) }
// app/(private)/feed/page.tsx
export default async function Feed() {
    return (
        

Feed

) }
// app/(public)/layout.tsx
export default function PublicLayout(
    {
        children,
    }: {
        children: React.ReactNode
    }
) {
    return 
{children}
}

8. Form Component HTML

// app/(public)/signin/page.tsx
import {Form} from "@/app/(public)/signin/form";

export default async function SignIn() {
    return (
        
) }
// app/(public)/signin/form.tsx
/**
 * 如果只是渲染, 用服务端组件就行
 * 如果需要处理事件, 可以用客户端组件
 */
'use client'
import {useRouter} from "next/navigation";
import React, {FormEvent, useState} from 'react'

export function Form() {
    const router = useRouter()
    const [username, setUsername] =
        useState('')
    const [password, setPassword] =
        useState('')

    async function handleSubmit(e: FormEvent) {
        e.preventDefault()
        const res = await fetch('/api/login', {
            method: 'post',
            body: JSON.stringify({username, password})
        })
        if (res.ok) {
            router.push('/feed')
        } else {
            alert('log in failed')
        }
    }

    return (
        
            

Sign In


setUsername(e.target.value)} />
setPassword(e.target.value)} />
) }

9. Testing The Form

登录成功有jwt-token记录

10. Style Sign In Page

// app/(public)/layout.tsx
export default function PublicLayout(
    {
        children,
    }: {
        children: React.ReactNode
    }
) {
    return 
{children}
}
'use client'
// app/(public)/signin/form.tsx
/**
 * 如果只是渲染, 用服务端组件就行
 * 如果需要处理事件, 可以用客户端组件
 */
import {useRouter} from "next/navigation";
import React, {FormEvent, useState} from 'react'

export default function Form() {
    // ...

    return (
        

Sign In


setUsername(e.target.value)} />
setPassword(e.target.value)} />
) }

11. Check In Code

7. Sign Up Page

1. Sign Up Endpoint

// app/api/signup/route.ts
import {sql} from '@/db'
import bcrypt from "bcrypt";
import {NextResponse} from "next/server";

export async function POST(request: Request) {
    try {
        const json = await request.json()

        const res = await sql(
            "select id, username from users where username ilike $1",
            [json.username]
        )

        if (res.rowCount! > 0) {
            return NextResponse.json(
                {error: 'user already exists'},
                {status: 400}
            )
        }

        const saltRounds = 10
        const hash = await bcrypt.hash(json.password, saltRounds)

        await sql(
            "insert into users (username, password) values ($1, $2)",
            [json.username, hash]
        )

        return NextResponse.json(
            {msg: "registration success"},
            {status: 201}
        )
    } catch (err) {
        return NextResponse.json(
            {error: "InternalServerError"},
            {status: 500}
        )
    }
}
### 注册
POST http://localhost:3000/api/signup
Content-Type: application/json

{
  "username": "admin1",
  "password": "123"
}

### 注册失败: 用户已存在
POST http://localhost:3000/api/signup
Content-Type: application/json

{
  "username": "admin"
}

2. Check In Sign Up Endpoint

3. Sign Up Page

'use client'
// app/(public)/signup/form.tsx

import React, {FormEvent, useState} from "react";

function Form() {
    const [username, setUsername] =
        useState('')
    const [password, setPassword] =
        useState('')
    const [confirmPassword, setConfirmPassword] =
        useState('')
    const [errors, setErrors] = useState([])

    async function handleSubmit(e: FormEvent) {
        e.preventDefault()
        setErrors([])

        if (password != confirmPassword) {
            const newErrs = []
            newErrs.push("Passwords do not match.")
            setErrors(newErrs)
            return
        }

        const res = await fetch('/api/signup', {
            method: 'post',
            body: JSON.stringify({username, password})
        })
        if (res.ok) {
            window.location.href = '/signin'
        } else {
            alert('sign up failed')
        }
    }

    return (
        

Sign Up


setUsername(e.target.value)} />
setPassword(e.target.value)} />
setConfirmPassword(e.target.value)} />
) } export default Form
// app/(public)/signup/page.tsx
import Form from "@/app/(public)/signup/form";

export default async function SignUp() {
    return (
        
) }

4. Check In Sign Up Page

5. Set Up Production DB

7. Confirm Password Error

'use client'
// app/(public)/signup/form.tsx

import React, {FormEvent, useState} from "react";

function Form() {
    // ...    

    return (
        
            {/**/}
            
            {errors.map((err) => {
                return (
                    
{err}
) })} ) } export default Form

8. Env Example File

8. Authentication and Private Layout

1. Private Layout

// app/(private)/footer.tsx
export default function Footer() {
    return (
        
Footer
) } // app/(private)/header.tsx export default function Header() { return (
Header
) } // app/(private)/navbar.tsx export default function NavBar() { return ( ) } // app/(private)/layout.tsx import React from "react"; import Footer from "@/app/(private)/footer"; import Header from "@/app/(private)/header"; import NavBar from "@/app/(private)/navbar"; export default function PrivateLayout( { children, }: { children: React.ReactNode } ) { return (
{children}
) }

2. JWT Verification

// app/api/users/profile/route.ts
import {getJWTPayload} from "@/app/util/auth";
import {NextResponse} from "next/server";
import {sql} from "@/db";

export async function GET(request: Request) {
    // get currently logged in user
    const jwtPayload = await getJWTPayload()

    // fetch user data
    const res = await sql(
        "select id, username, avatar from users where id=$1",
        [jwtPayload.sub]
    )
    const user = res.rows[0]

    // return user data
    return NextResponse.json({data: user})
}
// app/util/auth.ts
import {cookies} from "next/headers";
import {jwtVerify} from "jose";

export async function getJWTPayload() {
    const cookieStore = cookies()
    const token = cookieStore.get("jwt-token")
    const secret = new TextEncoder().encode(process.env.JWT_SECRET!)
    // @ts-ignore
    const {payload, protectedHeader} = await jwtVerify(token?.value, secret)
    return payload
}
# .env.local 
# ...
JWT_SECRET=my-jwt-secret

3. Reset DB

4. Auth Middleware

// middleware.ts
import {NextRequest, NextResponse} from "next/server";
import {jwtVerify} from "jose";

export async function middleware(request: NextRequest) {
    const pathname = request.nextUrl.pathname

    // 身份验证 路由 匹配条件
    const authenticatedAPIRoutes = [
        pathname.startsWith("/api/users")
    ]

    // authenticatedAPIRoutes [true, false, ...]
    if (authenticatedAPIRoutes.includes(true)) {
        const cookie = request.cookies.get('jwt-token')

        if (!cookie || !cookie?.value) {
            return NextResponse.json(
                {error: 'unauthenticated'},
                {status: 401}
            )
        }

        try {
            const secret = new TextEncoder().encode(process.env.JWT_SECRET!)
            await jwtVerify(cookie.value, secret)
        } catch (err) {
            console.log(err)
            return NextResponse.json(
                {error: 'internal server error'},
                {status: 500}
            )
        }
    }
}

export const config = {
    matcher: '/:path*'
}

6. SWR Fetcher

pnpm i swr
// app/(private)/header.tsx
'use client' // useSWR要客户端组件才能用, 否则报错useSWR is not a function
import useSWR from "swr";

const fetcher = async (url: RequestInfo | URL) => {
    const res = await fetch(url)
    if (!res.ok) {
        const info = await res.json()
        const status = res.status
        console.error(info, status)
        const msg = "An error occurred while fetching the data."
        throw new Error(msg)
    }
    return res.json()
}

export default function Header() {
    // 请求/api/users/profile, 如果成功就返回data, 失败返回error
    const {data, error, isLoading} = useSWR('/api/users/profile', fetcher)

    if (error) return 
failed to load
if (isLoading) return
loading...
return (
{data.data.username}
) }

7. Refactor Fetcher

// app/(private)/header.tsx
'use client' // useSWR要客户端组件才能用, 否则报错useSWR is not a function
import useSWR from "swr";

export default function Header() {
    // 请求/api/users/profile, 如果成功就返回data, 失败返回error
    const {data, error, isLoading} = useSWR('/api/users/profile')

    // ...
}
// app/(private)/layout.tsx
import React from "react";
import Footer from "@/app/(private)/footer";
import Header from "@/app/(private)/header";
import NavBar from "@/app/(private)/navbar";
import {SWRConfig} from "swr";
import fetcher from "@/app/util/fetcher";

export default function PrivateLayout(
    {
        children,
    }: {
        children: React.ReactNode
    }
) {
    return (
        // 配置后子元素swr使用不需要再导入fetcher
        
            
{children}
) }
// app/util/fetcher.ts
const fetcher = async (url: RequestInfo | URL) => {
    const res = await fetch(url)
    if (!res.ok) {
        const info = await res.json()
        const status = res.status
        console.error(info, status)
        const msg = "An error occurred while fetching the data."
        throw new Error(msg)
    }
    return res.json()
}

export default fetcher

8. SWR Must Use Client

// app/(private)/layout.tsx
'use client'
// ...

9. Check In SWR Config

10. Styling the Header

// app/(private)/header.tsx
'use client' // useSWR要客户端组件才能用, 否则报错useSWR is not a function
import useSWR from "swr";

export default function Header() {
    // 请求/api/users/profile, 如果成功就返回data, 失败返回error
    const {data, error, isLoading} = useSWR('/api/users/profile')

    if (error) return 
failed to load
if (isLoading) return
loading...
return (

Strings

) }
// app/components/user.tsx
function User({user, href}: { user: UserI, href?: string }) {
    return (
        
) } export default User

11. Display User Avatar

将图片的域名添加到配置, 允许请求

// app/components/user.tsx
import {UserI} from "@/app/types";
import Link from "next/link";
import React from "react";
import Image from 'next/image'

function User({user, href}: { user: UserI, href?: string }) {
    return (
        
{user.avatar && ( {user.username} )} {!user.avatar && (
)}
{user.username}
) } export default User
/** @type {import('next').NextConfig} */
const nextConfig = {
        images: {
            // 允许载入的图片域名
            remotePatterns: [
                {
                    protocol: 'https',
                    hostname: 't.mwm.moe',
                    port: '',
                    pathname: '/**',
                },
            ],
        }
    }

module.exports = nextConfig
// app/types.ts
export interface UserI {
    id: number
    username: string
    avatar: string
}
// app/(private)/header.tsx
'use client' // useSWR要客户端组件才能用, 否则报错useSWR is not a function
import useSWR from "swr";
import User from "@/app/components/user";

export default function Header() {
    // ...

    return (
        

Strings

) }

13. Center the Private Layout

// app/(private)/layout.tsx
'use client'
import React from "react";
import Footer from "@/app/(private)/footer";
import Header from "@/app/(private)/header";
import NavBar from "@/app/(private)/navbar";
import {SWRConfig} from "swr";
import fetcher from "@/app/util/fetcher";

export default function PrivateLayout(
    {
        children,
    }: {
        children: React.ReactNode
    }
) {
    return (
        // 配置后子元素swr使用不需要再导入fetcher
        
            
{children}
) }
// app/(private)/navbar.tsx
import Link from "next/link";

export default function NavBar() {
    return (
        
    )
}
// app/(private)/footer.tsx
export default function Footer() {
    return (
        
© Strings {new Date().getFullYear()}
) }

15. Page Stubs for Private Layout

// app/(private)/profile/page.tsx
export default async function Profile() {
    return (
        

Profile

) } // app/(private)/following/page.tsx export default async function Following() { return (

Following

) } // app/(private)/followers/page.tsx export default async function Followers() { return (

Followers

) }
// app/(private)/navbar.tsx
import Link from "next/link";
import {usePathname} from "next/navigation";

export default function NavBar() {
    const pathname = usePathname()

    return (
        
    )
}

9. Feed Page


  目录