Nextjs Project Dashboard 03
10) Partial Prerendering
In this chapter, let's learn how to combine static rendering, dynamic rendering, and streaming in the same route with Partial Prerendering (PPR).
Partial Prerendering is an experimental feature introduced in Next.js 14. The content of this page may be updated as the feature progresses in stability.
Static vs. Dynamic Routes
For most web apps built today, you either choose between static and dynamic rendering for your entire application, or for a specific route. And in Next.js, if you call a dynamic function in a route (like querying your database), the entire route becomes dynamic.
What is Partial Prerendering?
Next.js 14 introduced an experimental version of Partial Prerendering – a new rendering model that allows you to combine the benefits of static and dynamic rendering in the same route. For example:
When a user visits a route:
- A static route shell that includes the navbar and product information is served, ensuring a fast initial load.
- The shell leaves holes where dynamic content like the cart and recommended products will load in asynchronously.
- The async holes are streamed in parallel, reducing the overall load time of the page.
How does Partial Prerendering work?
意思就是还是和之前一样用 suspense,但是使用了 ppr 后,nextjs 框架会自动在编译期创建静态 shell。动态内容只有在用户请求时才生成。
把一个内容包在 suspense 里并不会使内容变为动态的,suspense 只是被用作代码中静态和动态的边界。
Partial Prerendering uses React's Suspense (which you learned about in the previous chapter)
The Suspense fallback is embedded into the initial HTML file along with the static content. At build time (or during revalidation), the static content is prerendered to create a static shell. The rendering of dynamic content is postponed until the user requests the route.
Wrapping a component in Suspense doesn't make the component itself dynamic, but rather Suspense is used as a boundary between your static and dynamic code.
Implementing Partial Prerendering
Enable PPR for your Next.js app by adding the ppr
option to your next.config.mjs
file:
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
ppr: 'incremental',
},
};
module.exports = nextConfig;
The 'incremental'
value allows you to adopt PPR for specific routes.
Next, add the experimental_ppr
segment config option to your dashboard layout:
import SideNav from '@/app/ui/dashboard/sidenav';
export const experimental_ppr = true;
// ...
That's it. You may not see a difference in your application in development, but you should notice a performance improvement in production. Next.js will prerender the static parts of your route and defer the dynamic parts until the user requests them.
The great thing about Partial Prerendering is that you don't need to change your code to use it. As long as you're using Suspense to wrap the dynamic parts of your route, Next.js will know which parts of your route are static and which are dynamic.
We believe PPR has the potential to become the default rendering model for web applications, bringing together the best of static site and dynamic rendering.
Summary
To recap, you've done a few things to optimize data fetching in your application:
- Created a database
- Fetched data on the server with React Server Components.
- Used SQL to only fetch the data you needed
- Parallelize data fetching with JavaScript
- promiseAll
- Implemented Streaming to prevent slow data requests from blocking your whole page, and to allow the user to start interacting with the UI without waiting for everything to load.
- Move data fetching down to the components that need it, thus isolating which parts of your routes should be dynamic.
11) Adding Search and Pagination
Now let's move on to the /invoices
page, and learn how to add search and pagination!
-
Learn how to use the Next.js APIs:
useSearchParams
,usePathname
, anduseRouter
. -
Implement search and pagination using URL search params.
Starting code
Inside your /dashboard/invoices/page.tsx
file, paste the following code:
import Pagination from '@/app/ui/invoices/pagination';
import Search from '@/app/ui/search';
import Table from '@/app/ui/invoices/table';
import { CreateInvoice } from '@/app/ui/invoices/buttons';
import { lusitana } from '@/app/ui/fonts';
import { InvoicesTableSkeleton } from '@/app/ui/skeletons';
import { Suspense } from 'react';
export default async function Page() {
return (
<div className="w-full">
<div className="flex w-full items-center justify-between">
<h1 className={`${lusitana.className} text-2xl`}>Invoices</h1>
</div>
<div className="mt-4 flex items-center justify-between gap-2 md:mt-8">
<Search placeholder="Search invoices..." />
<CreateInvoice />
</div>
{/* <Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
<Table query={query} currentPage={currentPage} />
</Suspense> */}
<div className="mt-5 flex w-full justify-center">
{/* <Pagination totalPages={totalPages} /> */}
</div>
</div>
);
}
Spend some time familiarizing yourself with the page and the components you'll be working with:
<Search/>
allows users to search for specific invoices.<Pagination/>
allows users to navigate between pages of invoices.<Table/>
displays the invoices.
Your search functionality will span the client and the server. When a user searches for an invoice on the client, the URL params will be updated, data will be fetched on the server, and the table will re-render on the server with the new data.
Why use URL search params?
As mentioned above, you'll be using URL search params to manage the search state. This pattern may be new if you're used to doing it with client side state.
Adding the search functionality
These are the Next.js client hooks that you'll use to implement the search functionality:
useSearchParams
- Allows you to access the parameters of the current URL. For example, the search params for this URL/dashboard/invoices?page=1&query=pending
would look like this:{page: '1', query: 'pending'}
.usePathname
- Lets you read the current URL's pathname. For example, for the route/dashboard/invoices
,usePathname
would return'/dashboard/invoices'
.useRouter
- Enables navigation between routes within client components programmatically. There are multiple methods you can use.
Here's a quick overview of the implementation steps:
- Capture the user's input.
- Update the URL with the search params.
- Keep the URL in sync with the input field.
- Update the table to reflect the search query.
1. Capture the user's input
Create a new handleSearch
function, and add an onChange
listener to the <input>
element. onChange
will invoke handleSearch
whenever the input value changes.
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
export default function Search({ placeholder }: { placeholder: string }) {
function handleSearch(term: string) {
console.log(term);
}
return (
<div className="relative flex flex-1 flex-shrink-0">
<label htmlFor="search" className="sr-only">
Search
</label>
<input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
placeholder={placeholder}
onChange={(e) => {
handleSearch(e.target.value);
}}
/>
<MagnifyingGlassIcon className="absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
</div>
);
}
Test that it's working correctly by opening the console in your Developer Tools, then type into the search field. You should see the search term logged to the console.
2. Update the URL with the search params
Import the useSearchParams
hook from 'next/navigation'
, and assign it to a variable:
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams } from 'next/navigation';
export default function Search() {
const searchParams = useSearchParams();
function handleSearch(term: string) {
console.log(term);
}
// ...
}
Inside handleSearch,
create a new URLSearchParams
instance using your new searchParams
variable.
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams } from 'next/navigation';
export default function Search() {
const searchParams = useSearchParams();
function handleSearch(term: string) {
const params = new URLSearchParams(searchParams);
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
}
// ...
}
URLSearchParams
is a Web API that provides utility methods for manipulating the URL query parameters. Instead of creating a complex string literal, you can use it to get the params string like ?page=1&query=a
.
Now that you have the query string. You can use Next.js's useRouter
and usePathname
hooks to update the URL.
Import useRouter
and usePathname
from 'next/navigation'
, and use the replace
method from useRouter()
inside handleSearch
:
'use client';
import { useSearchParams, usePathname, useRouter } from 'next/navigation';
export default function Search() {
//...
const pathname = usePathname();
const { replace } = useRouter();
function handleSearch(term: string) {
//...
replace(`${pathname}?${params.toString()}`);
}
}
3. Keeping the URL and input in sync
To ensure the input field is in sync with the URL and will be populated when sharing, you can pass a defaultValue
to input by reading from searchParams
:
<input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
placeholder={placeholder}
onChange={(e) => {
handleSearch(e.target.value);
}}
defaultValue={searchParams.get('query')?.toString()}
/>
defaultValue
vs.value
/ Controlled vs. UncontrolledIf you're using state to manage the value of an input, you'd use the
value
attribute to make it a controlled component. This means React would manage the input's state.However, since you're not using state, you can use
defaultValue
. This means the native input will manage its own state. This is okay since you're saving the search query to the URL instead of state.
4. Updating the table
Finally, you need to update the table component to reflect the search query.
Navigate back to the invoices page.
Page components accept a prop called searchParams
, so you can pass the current URL params to the <Table>
component.
With these changes in place, go ahead and test it out. If you search for a term, you'll update the URL, which will send a new request to the server, data will be fetched on the server, and only the invoices that match your query will be returned.
When to use the
useSearchParams()
hook vs. thesearchParams
prop?You might have noticed you used two different ways to extract search params. Whether you use one or the other depends on whether you're working on the client or the server.
<Search>
is a Client Component, so you used theuseSearchParams()
hook to access the params from the client.<Table>
is a Server Component that fetches its own data, so you can pass thesearchParams
prop from the page to the component.As a general rule, if you want to read the params from the client, use the
useSearchParams()
hook as this avoids having to go back to the server.
Best practice: Debouncing
为什么不用 search button 呢?不过 debouncing 的好处就是比较动态了吧
You're updating the URL on every keystroke, and therefore querying your database on every keystroke!
Debouncing is a programming practice that limits the rate at which a function can fire. In our case, you only want to query the database when the user has stopped typing.
How Debouncing Works:
- Trigger Event: When an event that should be debounced (like a keystroke in the search box) occurs, a timer starts.
- Wait: If a new event occurs before the timer expires, the timer is reset.
- Execution: If the timer reaches the end of its countdown, the debounced function is executed.
You can implement debouncing in a few ways, including manually creating your own debounce function. To keep things simple, we'll use a library called use-debounce.
Install use-debounce
:
pnpm i use-debounce
In your <Search>
Component, import a function called useDebouncedCallback
:
// ...
import { useDebouncedCallback } from 'use-debounce';
// Inside the Search Component...
const handleSearch = useDebouncedCallback((term) => {
console.log(`Searching... ${term}`);
const params = new URLSearchParams(searchParams);
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
replace(`${pathname}?${params.toString()}`);
}, 300);
This function will wrap the contents of handleSearch
, and only run the code after a specific time once the user has stopped typing (300ms).
By debouncing, you can reduce the number of requests sent to your database, thus saving resources.
Adding pagination
After introducing the search feature, you'll notice the table displays only 6 invoices at a time. This is because the fetchFilteredInvoices()
function in data.ts
returns a maximum of 6 invoices per page.
Navigate to the <Pagination/>
component and you'll notice that it's a Client Component. You don't want to fetch data on the client as this would expose your database secrets (remember, you're not using an API layer)
In /dashboard/invoices/page.tsx
, import a new function called fetchInvoicesPages
and pass the query
from searchParams
as an argument:
import { fetchInvoicesPages } from '@/app/lib/data';
const totalPages = await fetchInvoicesPages(query);
Summary
Congratulations! You've just implemented search and pagination using URL Params and Next.js APIs.
To summarize, in this chapter:
- You've handled search and pagination with URL search parameters instead of client state.
- You've fetched data on the server.
- You're using the
useRouter
router hook for smoother, client-side transitions.
These patterns are different from what you may be used to when working with client-side React, but hopefully, you now better understand the benefits of using URL search params and lifting this state to the server.
12) Mutating Data, CRUD
Let's continue working on the Invoices page by adding the ability to create, update, and delete invoices!
What are Server Actions?
React Server Actions allow you to run asynchronous code directly on the server. They eliminate the need to create API endpoints to mutate your data. Instead, you write asynchronous functions that execute on the server and can be invoked from your Client or Server Components.
Security is a top priority for web applications, as they can be vulnerable to various threats. This is where Server Actions come in.Server Actions achieve this through techniques like POST requests, encrypted closures, strict input checks, error message hashing, and host restrictions, all working together to significantly enhance your app's safety.
Using forms with Server Actions
In React, you can use the action
attribute in the <form>
element to invoke actions. The action will automatically receive the native FormData object, containing the captured data.
// Server Component
export default function Page() {
// Action
async function create(formData: FormData) {
'use server';
// Logic to mutate data...
}
// Invoke the action using the "action" attribute
return <form action={create}>...</form>; // <=== here
}
An advantage of invoking a Server Action within a Server Component is progressive enhancement - forms work even if JavaScript is disabled on the client.
Next.js with Server Actions
Server Actions are also deeply integrated with Next.js caching. When a form is submitted through a Server Action, not only can you use the action to mutate data, but you can also revalidate the associated cache using APIs like revalidatePath
and revalidateTag
.
Creating an invoice
- Create a form to capture the user's input.
- Create a Server Action and invoke it from the form.
- Inside your Server Action, extract the data from the
formData
object. - Validate and prepare the data to be inserted into your database.
- Insert the data and handle any errors.
- Revalidate the cache and redirect the user back to invoices page.
1) Create a form to capture the user's input.
Navigate to the <Form>
component, and you'll see that the form:
- Has one
<select>
(dropdown) element with a list of customers. - Has one
<input>
element for the amount withtype="number"
. - Has two
<input>
elements for the status withtype="radio"
. - Has one button with
type="submit"
.
On http://localhost:3000/dashboard/invoices/create, you should see the following UI:
2) Create a Server Action
In your actions.ts
file, create a new async function that accepts formData
:
'use server';
export async function createInvoice(formData: FormData) {}
Then, in your <Form>
component, import the createInvoice
from your actions.ts
file. Add a action
attribute to the <form>
element, and call the createInvoice
action.
import { customerField } from '@/app/lib/definitions';
import Link from 'next/link';
import {
CheckIcon,
ClockIcon,
CurrencyDollarIcon,
UserCircleIcon,
} from '@heroicons/react/24/outline';
import { Button } from '@/app/ui/button';
import { createInvoice } from '@/app/lib/actions';
export default function Form({
customers,
}: {
customers: customerField[];
}) {
return (
<form action={createInvoice}>
// ...
)
}
Behind the scenes, Server Actions create a
POST
API endpoint. This is why you don't need to create API endpoints manually when using Server Actions.
3) Extract the data from formData
Back in your actions.ts
file, you'll need to extract the values of formData
, there are a couple of methods you can use. For this example, let's use the .get(name)
method.
4) Validate and prepare the data
Before sending the form data to your database, you want to ensure it's in the correct format and with the correct types. If you remember from earlier in the course, your invoices table expects data in the following format:
For your example, we'll use Zod, a TypeScript-first validation library that can simplify this task for you.
在这段话中,"TypeScript-first" 指的是 Zod 这个库在设计和实现时优先考虑 TypeScript 的特点和需求。这意味着它在以下几个方面表现出对 TypeScript 的强烈支持:
- 类型安全:Zod 充分利用 TypeScript 的类型系统,确保在编写代码时能提供更好的类型检查和自动补全,从而减少运行时错误。
- 类型推导:Zod 可以根据验证规则自动推导出 TypeScript 类型,使开发者不需要手动编写冗长的类型定义。
- 类型互操作性:Zod 和 TypeScript 的类型系 统紧密集成,允许在验证数据的同时确保数据符合预期的类型。
通过这些特性,Zod 可以让使用 TypeScript 的开发者更加轻松地进行数据验证,同时充分利用 TypeScript 提供的类型优势。
简单来说,"TypeScript-first" 意味着 Zod 是专门为 TypeScript 生态系统设计的,并在这个过程中优先考虑了 TypeScript 用户的需求和开发体验。
In your actions.ts
file, import Zod and define a schema that matches the shape of your form object. This schema will validate the formData
before saving it to a database.
'use server';
import { z } from 'zod';
const FormSchema = z.object({
id: z.string(),
customerId: z.string(),
amount: z.coerce.number(),
status: z.enum(['pending', 'paid']),
date: z.string(),
});
const CreateInvoice = FormSchema.omit({ id: true, date: true });
export async function createInvoice(formData: FormData) {
const { customerId, amount, status } = CreateInvoice.parse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
// ...
}
5) Inserting the data into your database
Now that you have all the values you need for your database, you can create an SQL query to insert the new invoice into your database and pass in the variables:
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
6) Revalidate cache and redirect
Next.js has a Client-side Router Cache that stores the route segments in the user's browser for a time. Along with prefetching, this cache ensures that users can quickly navigate between routes while reducing the number of requests made to the server.
Since you're updating the data displayed in the invoices route, you want to clear this cache and trigger a new request to the server. You can do this with the revalidatePath
function from Next.js:
import { revalidatePath } from 'next/cache';
export async function createInvoice(formData: FormData) {
//...
revalidatePath('/dashboard/invoices');
}
At this point, you also want to redirect the user back to the /dashboard/invoices
page. You can do this with the redirect
function from Next.js:
import { redirect } from 'next/navigation';
export async function createInvoice(formData: FormData) {
// ...
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
Updating an invoice
The updating invoice form is similar to the create an invoice form, except you'll need to pass the invoice id
to update the record in your database. Let's see how you can get and pass the invoice id
.
These are the steps you'll take to update an invoice:
- Create a new dynamic route segment with the invoice
id
. - Read the invoice
id
from the page params. - Fetch the specific invoice from your database.
- Pre-populate the form with the invoice data.
- Update the invoice data in your database.
1) Create a Dynamic Route Segment with the invoice id
Next.js allows you to create Dynamic Route Segments when you don't know the exact segment name and want to create routes based on data. You can create dynamic route segments by wrapping a folder's name in square brackets. For example, [id]
, [post]
or [slug]
.
In your /invoices
folder, create a new dynamic route called [id]
, then a new route called edit
with a page.tsx
file. Your file structure should look like this:
In your <Table>
component, notice there's a <UpdateInvoice />
button that receives the invoice's id
from the table records.
export default async function InvoicesTable({
query,
currentPage,
}: {
query: string;
currentPage: number;
}) {
return (
// ...
<td className="flex justify-end gap-2 whitespace-nowrap px-6 py-4 text-sm">
<UpdateInvoice id={invoice.id} /> // <=== here
<DeleteInvoice id={invoice.id} />
</td>
// ...
);
}
2) Read the invoice id from page params
In addition to searchParams
, page components also accept a prop called params
which you can use to access the id
. Update your <Page>
component to receive the prop:
import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers } from '@/app/lib/data';
export default async function Page({ params }: { params: { id: string } }) {
const id = params.id;
// ...
}
3) Fetch the specific invoice
Then:
- Import a new function called
fetchInvoiceById
and pass theid
as an argument. - Import
fetchCustomers
to fetch the customer names for the dropdown.
You can use Promise.all
to fetch both the invoice and customers in parallel:
import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchInvoiceById, fetchCustomers } from '@/app/lib/data';
export default async function Page({ params }: { params: { id: string } }) {
const id = params.id;
const [invoice, customers] = await Promise.all([
fetchInvoiceById(id),
fetchCustomers(),
]);
// ...
}
The URL should also be updated with an id
as follows: http://localhost:3000/dashboard/invoice/uuid/edit
http://localhost:3000/dashboard/invoices/22842d1a-1bea-4a12-b921-ac20105d1847/edit
UUIDs vs. Auto-incrementing Keys
We use UUIDs instead of incrementing keys (e.g., 1, 2, 3, etc.). This makes the URL longer; however, UUIDs eliminate the risk of ID collision, are globally unique, and reduce the risk of enumeration attacks - making them ideal for large databases.
However, if you prefer cleaner URLs, you might prefer to use auto-incrementing keys.
4) Pass the id to the Server Action
You cannot pass the id
as an argument like so:
// Passing an id as argument won't work
<form action={updateInvoice(id)}>
Instead, you can pass id
to the Server Action using JS bind
. This will ensure that any values passed to the Server Action are encoded.
// ...
import { updateInvoice } from '@/app/lib/actions';
export default function EditInvoiceForm({
invoice,
customers,
}: {
invoice: InvoiceForm;
customers: CustomerField[];
}) {
const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);
return (
<form action={updateInvoiceWithId}>
<input type="hidden" name="id" value={invoice.id} />
</form>
);
}
Similarly to the createInvoice
action, here you are:
- Extracting the data from
formData
. - Validating the types with Zod.
- Converting the amount to cents.
- Passing the variables to your SQL query.
- Calling
revalidatePath
to clear the client cache and make a new server request. - Calling
redirect
to redirect the user to the invoice's page.
Test it out by editing an invoice. After submitting the form, you should be redirected to the invoices page, and the invoice should be updated.
Deleting an invoice
To delete an invoice using a Server Action, wrap the delete button in a <form>
element and pass the id
to the Server Action using bind
:
import { deleteInvoice } from '@/app/lib/actions';
// ...
export function DeleteInvoice({ id }: { id: string }) {
const deleteInvoiceWithId = deleteInvoice.bind(null, id);
return (
<form action={deleteInvoiceWithId}>
<button type="submit" className="rounded-md border p-2 hover:bg-gray-100">
<span className="sr-only">Delete</span>
<TrashIcon className="w-4" />
</button>
</form>
);
}
Inside your actions.ts
file, create a new action called deleteInvoice
.
export async function deleteInvoice(id: string) {
await sql`DELETE FROM invoices WHERE id = ${id}`;
revalidatePath('/dashboard/invoices');
}
Since this action is being called in the /dashboard/invoices
path, you don't need to call redirect
. Calling revalidatePath
will trigger a new server request and re-render the table.
13) Handling Errors
Let's see how you can handle errors gracefully using JavaScript's try/catch
statements and Next.js APIs.
- How to use the special
error.tsx
file to catch errors in your route segments, and show a fallback UI to the user. - How to use the
notFound
function andnot-found
file to handle 404 errors (for resources that don’t exist).
Adding try/catch to Server Actions
First, let's add JavaScript's try/catch
statements to your Server Actions to allow you to handle errors gracefully.
If you know how to do this, spend a few minutes updating your Server Actions, or you can copy the code below:
try {
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
} catch (error) {
return {
message: 'Database Error: Failed to Create Invoice.',
};
}
Note how
redirect
is being called outside of thetry/catch
block. This is becauseredirect
works by throwing an error, which would be caught by thecatch
block. To avoid this, you can callredirect
aftertry/catch
.redirect
would only be reachable iftry
is successful.
what happens when an error is thrown in your Server Action
Handling all errors with error.tsx
You also want to show errors to the user to avoid an abrupt failure and allow your application to continue running. This is where Next.js error.tsx
file comes in.
The error.tsx
file can be used to define a UI boundary for a route segment. It serves as a catch-all for unexpected errors and allows you to display a fallback UI to your users.
Inside your /dashboard/invoices
folder, create a new file called error.tsx
and paste the following code:
'use client';
import { useEffect } from 'react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Optionally log the error to an error reporting service
console.error(error);
}, [error]);
return (
<main className="flex h-full flex-col items-center justify-center">
<h2 className="text-center">Something went wrong!</h2>
<button
className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400"
onClick={
// Attempt to recover by trying to re-render the invoices route
() => reset()
}
>
Try again
</button>
</main>
);
}
There are a few things you'll notice about the code above:
- "use client" -
error.tsx
needs to be a Client Component. - It accepts two props:
error
: This object is an instance of JavaScript's nativeError
object.reset
: This is a function to reset the error boundary. When executed, the function will try to re-render the route segment.
When you try to delete an invoice again, you should see the following UI:
Handling 404 errors with the notFound
func and not-found.tsx
import { fetchInvoiceById, fetchCustomers } from '@/app/lib/data';
import { updateInvoice } from '@/app/lib/actions';
import { notFound } from 'next/navigation'; // return 404 specifically
export default async function Page({ params }: { params: { id: string } }) {
const id = params.id;
const [invoice, customers] = await Promise.all([
fetchInvoiceById(id),
fetchCustomers(),
]);
if (!invoice) {
notFound();
}
// ...
}
Perfect! <Page>
will now throw an error if a specific invoice is not found. To show an error UI to the user. Create a not-found.tsx
file inside the /edit
folder.
Then, inside the not-found.tsx
file, paste the following the code:
import Link from 'next/link';
import { FaceFrownIcon } from '@heroicons/react/24/outline';
export default function NotFound() {
return (
<main className="flex h-full flex-col items-center justify-center gap-2">
<FaceFrownIcon className="w-10 text-gray-400" />
<h2 className="text-xl font-semibold">404 Not Found</h2>
<p>Could not find the requested invoice.</p>
<Link
href="/dashboard/invoices"
className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400"
>
Go Back
</Link>
</main>
);
}
That's something to keep in mind, notFound
will take precedence over error.tsx
, so you can reach out for it when you want to handle more specific errors!
visit this: http://localhost:3000/dashboard/invoices/2e94d1ed-d220-449f-9f11-f0bbceed9645/edit
will see 404 not found page
To learn more about error handling in Next.js, check out the following documentation:
14) Improving Accessibility
- How to use
eslint-plugin-jsx-a11y
with Next.js to implement accessibility best practices. - How to implement server-side form validation.
- How to use the React
useActionState
hook to handle form errors, and display them to the user.
Using the ESLint accessibility plugin in Next.js
Next.js includes the eslint-plugin-jsx-a11y
plugin in its ESLint config to help catch accessibility issues early.
Optionally, if you would like to try this out, add next lint
as a script in your package.json
file:
"scripts": {
"build": "next build",
"dev": "next dev",
"start": "next start",
"lint": "next lint"
},
Then run pnpm lint
in your terminal:
╰─$ pnpm lint
> @ lint /Users/fenglyulin/guzheng/github/project/nextjs-dashboard
> next lint
✔ No ESLint warnings or errors
Improving form accessibility
There are three things we're already doing to improve accessibility in our forms:
- Semantic HTML: Using semantic elements (
<input>
,<option>
, etc) instead of<div>
. This allows assistive technologies (AT) to focus on the input elements - Labelling: Including
<label>
and thehtmlFor
attribute ensures that each form field has a descriptive text label. - Focus Outline: The fields are properly styled to show an outline when they are in focus. .You can verify this by pressing
tab
.
Form validation
Go to http://localhost:3000/dashboard/invoices/create, and submit an empty form. What happens?
Client-Side validation
There are a couple of ways you can validate forms on the client. The simplest would be to rely on the form validation provided by the browser by adding the required
attribute to the <input>
and <select>
elements in your forms. For example:
<input
id="amount"
name="amount"
type="number"
placeholder="Enter USD amount"
className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
required
/>
Server-Side validation [TODO]
15) Adding Authentication
What is authentication?
Authentication is a key part of many web applications today. It's how a system checks if the user is who they say they are.
A secure website often uses multiple ways to check a user's identity. For instance, after entering your username and password, the site may send a verification code to your device or use an external app like Google Authenticator. This 2-factor authentication (2FA) helps increase security
Authentication vs. Authorization
In web development, authentication and authorization serve different roles:
- Authentication is about making sure the user is who they say they are. You're proving your identity with something you have like a username and password.
- Authorization is the next step. Once a user's identity is confirmed, authorization decides what parts of the application they are allowed to use.
Creating the login route
Start by creating a new route in your application called /login
and paste the following code:
import AcmeLogo from '@/app/ui/acme-logo';
import LoginForm from '@/app/ui/login-form';
export default function LoginPage() {
return (
<main className="flex items-center justify-center md:h-screen">
<div className="relative mx-auto flex w-full max-w-[400px] flex-col space-y-2.5 p-4 md:-mt-32">
<div className="flex h-20 w-full items-end rounded-lg bg-blue-500 p-3 md:h-36">
<div className="w-32 text-white md:w-36">
<AcmeLogo />
</div>
</div>
<LoginForm />
</div>
</main>
);
}
NextAuth.js
We will be using NextAuth.js to add authentication to your application. NextAuth.js abstracts away much of the complexity involved in managing sessions, sign-in and sign-out, and other aspects of authentication. While you can manually implement these features, the process can be time-consuming and error-prone. NextAuth.js simplifies the process, providing a unified solution for auth in Next.js applications.
Install NextAuth.js by running the following command in your terminal:
pnpm i next-auth@beta
Here, you're installing the beta
version of NextAuth.js, which is compatible with Next.js 14.
Next, generate a secret key for your application. This key is used to encrypt cookies, ensuring the security of user sessions. You can do this by running the following command in your terminal:
openssl rand -base64 32
Then, in your .env
file, add your generated key to the AUTH_SECRET
variable:
AUTH_SECRET=your-secret-key
For auth to work in production, you'll need to update your environment variables in your Vercel project too. Check out this guide on how to add environment variables on Vercel.
Adding the pages option
Adding the Credentials provider
providers
is an array where you list different login options such as Google or GitHub. For this course, we will focus on using the Credentials provider only.
The Credentials provider allows users to log in with a username and a password.
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
import Credentials from 'next-auth/providers/credentials';
export const { auth, signIn, signOut } = NextAuth({
...authConfig,
providers: [Credentials({})],
});
Good to know:
Although we're using the Credentials provider, it's generally recommended to use alternative providers such as OAuth or email providers. See the NextAuth.js docs for a full list of options.
Adding the sign in functionality
16) Adding Metadata
Metadata is crucial for SEO and shareability. In this chapter, we'll discuss how you can add metadata to your Next.js application.
Metadata is not visible to the users visiting the page.
it works behind the scenes, embedded within the page's HTML, usually within the <head>
element. This hidden information is crucial for search engines and other systems that need to understand your webpage's content better.
Why is metadata important?
Metadata plays a significant role in enhancing a webpage's SEO,
- making it more accessible and understandable for search engines and social media platforms.
- Proper metadata helps search engines effectively index webpages, improving their ranking in search results.
- Additionally, metadata like Open Graph improves the appearance of shared links on social media, making the content more appealing and informative for users.
Next Steps
Congratulations! You've completed the Next.js dashboard course where you learned about the main features of Next.js and best practices for building web applications.
But this is just the beginning—Next.js has many other features. It's designed to help you build small side projects, your next startup idea, or even large-scale applications with your team.
Here are some resources to continue exploring Next.js: