muni-logo

API routes, caching, revalidation, server actions, database

In this lesson, we'll look at Next.js again in more depth. Specifically, we'll discuss the ability to create custom API endpoints. We'll learn what route segment configs are and discuss caching and revalidation options for individual cache layers. We'll introduce the possibility of completely avoiding writing API routes and, thus, how to use server actions. Finally, we'll look at the options for working with the database in Typescript.

API routes

In addition to the defined files named page, loading, error and so on, there is a special file named route which is used to define the API endpoint. Specifically, in this file, we can create a request handler that works with Web Request and Response objects. We call these handlers Route Handler, where we can define the HTTP method the handler should handle.

We must declare these route.ts files inside the app directory, as we did with other page and similar files. We conventionally put these files in the app/api/* directory to separate our defined api endpoints from the classic application pages. We then call these endpoints at the URL <domain>/api/*.

The most common HTTP methods are GET, POST, PUT, DELETE and PATCH. Other supported methods are HEAD and OPTIONS. The given route handler will throw us the HTTP status 405 Method Not Allowed for an unsupported method.

// app/api/route.ts
 
export const GET = () => {
  return Response.json(
    { message: 'Hello world from GET request' },
    { status: 200 }
  );
};

Of course, the route handler code runs on the Server where our Next.js application is running. This code is not executed on the Client (for example, in a browser), nor is this code sent to the Client. This is very similar to React Server Components, which are also executed on the Server, and only the result of the function is sent to the Client. In the case of the route handler, the result must be the Response function. It is not possible for a route handler to return nothing or to return something other than a response.

Next.js also prepared wrappers over the web Request and Response types and named them NextRequest and NetxResponse, respectively, for more straightforward and more advanced usage.

In pure React, we typically apply the application architecture as Server/Client, with the Client fetching data typically via API endpoints that the Server exposes. In the case of Next.js, which allows us to retrieve data directly on the Server using the React Server Component, it is not necessary or even correct to call from the RSC API endpoint to retrieve data.

If our Next.js application communicates with an external API, it is of course fine to call that API endpoint from RSC. It is wrong to call API endpoints that are defined within the same Next.js application.

// app/api/route.ts
 
import { getMessage } from '@src/server';
 
export const GET = async () => {
  const text = await getMessage();
 
  return Response.json({ message: text }, { status: 200 });
};
// src/server/index.ts
 
import { promises as fs } from 'node:fs';
 
export const getMessage = async () => {
  const text = await fs.readFile('src/server/text.txt', 'utf-8');
 
  return text;
};
// src/components/server-text.tsx
 
export const ServerText = async () => {
  const response = await fetch('http://localhost:3000/api');
  const json = await response.json();
 
  return <div>{json.message}</div>;
};
 

This is an inappropriate way to use the GET route handler. Like the RSC code, the route handler code runs on the server. In this case, it is useless to call the actual API endpoint to execute code that will execute on the same server from which the endpoint is called.

// app/api/route.ts
 
// ❌ no need to define extra route handler
// src/server/index.ts
import { promises as fs } from 'node:fs';
 
export const getMessage = async () => {
  const text = await fs.readFile('src/server/text.txt', 'utf-8');
 
  return text;
};
// src/components/server-text.tsx
import { getMessage } from '../server';
 
export const ServerText = async () => {
  const message = await getMessage();
 
  return <div>{message}</div>;
};

Instead of calling the route handler to execute code that must run on the server, we can call this code (specifically the getMessage function) directly in the RSC. The route handler would only be necessary if we needed to call this code from the client component.

Submitting data via API routes

Until recently, the only way to create, edit or delete data in a database or other data source was by using the HTTP methods POST, PUT, DELETE and PATCH that we defined in the route handler. In the case of creating new data, we typically need to get individual information from the Client about the entity we want to create later. This code cannot run on the Server since the user enters this data through the Client, for example, a form in a browser.

// app/api/event/route.ts
 
export const POST = async (request: Request) => {
  const body = await request.json();
 
  try {
    const data = createEventSchema.parse(body);
    const event = await database.event.create(data);
 
    return Response.json(
      {
        message: 'Event created successfully',
        data: event
      },
      { status: 201 }
    );
  } catch (e) {
    return Response.json({ message: 'Something went wrong' }, { status: 500 });
  }
};

This is what a POST route handler might look like, which creates a new event based on the data that came in the request. Just as we validated the data from the backend to the client using zod, we need to validate the data from the client to the server. The createEventSchema takes care of this, which defines the valid shape of the object we expect from the client. We can then directly access the database and thus create a new event.

Route resolution

For route handlers, the same rules apply as far as the directory structure is concerned. If we want to create a route handler on the url POST /api/events, we need to create a route.ts file in the app/api/events directory and create a route handler POST in it.

Dynamic route segment

To create a dynamic route segment, i.e., a route handler that can take a dynamic slug in a URL, the same convention applies to creating dynamic pages.

// app/api/event/[slug]/route.ts
 
type Params = {
  slug: string;
};
 
export const GET = (_request: Request, { params }: { params: Params }) => {
  const slug = params.slug;
 
  return Response.json({
    message: `Called with slug ${slug}`
  });
};

Route handlers caching

Next.js caches our API endpoints by default. Specifically, this means that once we build and run our application in production mode, our defined API endpoint will return the same result every time. The GET function was run during the build, and its result was cached. It will be returned every time the endpoint is called. This behavior is the default and is intended to reduce server load.

export const GET = () => {
  return Response.json({
    time: new Date().toISOString()
  });
};
 
// This will always return the same date, anytime this route handler is called.
   Creating an optimized production build ...
Compiled successfully
Linting and checking validity of types
Collecting page data
Generating static pages (2/2)
Collecting build traces
Finalizing page optimization
 
Route (app)                              Size     First Load JS
┌ ○ /                                    137 B          84.3 kB
├ ○ /api                                 0 B                0 B
+ First Load JS shared by all            84.2 kB
chunks/625-81c4c6b2dd2ff566.js       28.9 kB
chunks/b9b32b16-e4bf812aff9e0cd7.js  53.4 kB
other shared chunks (total)          1.86 kB
 
 
○  (Static)   prerendered as static content

This is how the build log of our application might look like. Route /api is marked as static, meaning its result will be cached and returned every time the endpoint is called. This behavior may not be desirable in all cases, and we can easily change it.

Cookies and headers

One possibility is to use the so-called dynamic functions cookies and headers. These two functions can be called from route handlers or RSCs and, relatively logically, are only available when the application is running, and their result varies based on what the request looks like. Therefore, it is impossible to cache these route handlers and treat them as static. For this reason, these route handlers will be executed every time the endpoint is called.

import { cookies } from 'next/headers';
 
export const GET = () => {
  const cookiesValues = cookies();
 
  return Response.json({
    time: new Date().toISOString()
  });
};
 
// This route handler will run every time endpoint is called.
import { headers } from 'next/headers';
 
export const GET = () => {
  const headersList = headers();
  const referer = headersList.get('referer');
 
  return (
    Response.json({ message: 'Hello from the API' }), { headers: { referer } }
  );
};

URL Query Parameters

In some cases, it is appropriate to use URL query search parameters. This is the case, for example, when we want to explicitly insert a parameter into the URL that will affect the result of the route handler.

// app/api/route.ts
 
import { type NextRequest } from 'next/server';
 
export const GET = (request: NextRequest) => {
  const searchParams = request.nextUrl.searchParams;
  const color = searchParams.get('color');
 
  return Response.json({
    message: `Called with color: ${color}`
  });
};
 
// http://localhost:3000/api?color=black

In this case, we add the color parameter to the URL query search, which will affect the route handler's result. Again, since this is a dynamic parameter, it is not possible to cache this route handler, and therefore, it will be executed every time the endpoint is called.

Route segment config

The Route Segment options allows you configure the behavior of a page, layout or route. These configs are available:

export const dynamic = "auto" | "force-dynamic" | "error" | "force-static";
export const dynamicParams = true | false;
export const revalidate = false | "force-cache" | 0 | number;
export const fetchCache =
  "auto" |
  "default-cache" |
  "only-cache" |
  "force-cache" |
  "force-no-store" |
  "default-no-store" |
  "only-no-store";
 
export const runtime = "nodejs" | "edge";
export const preferredRegion = "auto" | "global" | "home" | string | string[];
export const maxDuration = number;

dynamic & revalidate

Some of the most essential configurations are revalidate and dynamic. Next.js is generally very aggressive about caching, and all page, layout, and route are cached by default. This is not often desired in a typical dynamic application, so it is possible to change this configuration.

If we set export const dynamic = "force-dynamic" in app/page.tsx, it means that every time we access route /, Next.js will always re-render the page on-demand on the server.

This behavior is proper if we access the page directly, i.e. via a URL. If we access our application first on, for example, the /users page, and navigate to / from that page, the first time we access / the page will be rendered on-demand, but any subsequent moves in our application will leave the page cached due to Router caching, which we will learn about later in this lesson.

Route segment revalidate allows us to generate the page statically on the server at build time and also specify the time when it must be revalidated at the latest. This means that if we set export const revalidate = 3600 on the app/page.tsx page, it means that if we build the application and make it available in production mode, all users who access / will be served a statically generated page for at least one hour. If someone accesses the / page after more than one hour, the page will be generated on the server on that request, updated in the cache, and the same generated page will be served again for another hour.

This approach is called Incremental Static Regeneration (ISR) and was already available in Next.js version 12. Later, there was a way to generate an on-demand page based on an action, which can be much more practical in several ways.

A typical example might be a statically generated page that lists all users. New users are not often added, so this page can be statically generated, which also optimizes database usage. However, we can re-generate this page on demand if a new user is added.

fetch

With the release of Next.js v13, we don't have to manage this revalidation or caching at the page level, but at the level of individual API, endpoint calls via the fetch function. Next.js has extended the capabilities of the native web function fetch to allow you to control caching behavior directly at the request level.

options.cache

The cache is not a new property added by the Nect team. This property is available in the native fetch web function. These two basic settings are available:

export default async function Page() {
  const staticData = await fetch('https://api.example.com', {
    cache: "force-cache",
  });
 
  const dynamicData = await fetch('https://api.example.com', {
    cache: "no-store",
  });
 
  return (
    ...
  );
}
 

options.next

Next.js adds the next option, to which it adds two options, namely revalidate and tags. The revalidate option is virtually identical to what the revalidate route segment offers, except we allow it at the individual request level.

The tags option is used to allow us to assign a unique identifier to a request for possible on-demand revalidation.

export default async function Page() {
  const response = fetch('https://api.example.com', {
    next: { revalidate: 3600, tags: ['api', 'example'] },
  });
 
  return (
    ...
  );
}
 

Server actions

One of the newest principles that Next.js is developing is server actions. It's a way we can call a function from the client that will run on the server, but we don't need to create a route handler to do it. We can even create forms this way, where we can use server actions to send all the data that the user fills in to the server, where the server function is run and then the result is returned to the client.

Differentiation from the traditional endpoint API

Using server actions is quite different from the traditional way of calling the classic POST/PUT/DELETE API endpoint in the onSubmit event handler of the <form> tag.

API POST route handler

Let's take an example of a POST API endpoint to create a new event. The user fills out a form on the client and clicks a button that invokes this POST API endpoint. Code is executed inside the route handler, a new event is created, and the newly created object is typically returned in the response. However, now the user needs to provide new data where all events are shown, for example. Therefore, the client (browser) has to send another request to the server to get the updated list of events, where in the response this list is returned to the client and can be rendered on the screen.

POST server action

The beginning is practically the same - the user fills in the form and clicks the button to create a new event. This triggers a POST request to the server again, sending the data that the user has filled in. This request, however, executes the server action we defined. The code to add the new event is executed, and because we are in a server action, it is possible to call a special function to revalidate either the entire page or just an endpoint. This allows the page to be re-rendered and prepared on the server with the new data already in it - the new event list, including the newly added and reverted original POST request, returns a response that already contains the new UI for the client, which just displays it on the screen. No need to perform another extra request to the server to get the new data.

Get started - forms

When using server action, we must use action, which is defined on the <form> tag. As action, we pass a function that takes the formData as its parameter, which is part of the form that is being submitted. This action can either be in a separate file, or defined within the same component where the form resides. The function must be marked as use server.

// src/app/server-action/page.tsx
 
import { addTodoAction } from '@src/server-actions';
import { readTodos } from '@src/server';
import { SubmitFormButton } from './submit-form-button';
 
export default async function Page() {
  const data = await readTodos();
 
  return (
    <div>
      <h1>Home</h1>
      <ul>
        {data.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
      <form action={addTodoAction}>
        <SubmitFormButton />
      </form>
    </div>
  );
}
 
// src/server-actions/index.ts
 
'use server';
 
import { revalidatePath } from 'next/cache';
import { addTodo } from '../server';
 
export addTodoAction = async () => {
	await addTodo();
 
	revalidatePath('/server-action');
}
// src/app/server-action/submit-form-button.tsx
 
'use client';
 
import { useFormStatus } from 'react-dom';
 
export const SubmitFormButton = () => {
  const formStatus = useFormStatus();
 
  return (
    <button type="submit">
      {formStatus.pending ? 'Adding todo...' : 'Add todo'}
    </button>
  );
}

In this case we have the server action extracted into its own file, which is marked as use server, which means that this code will safely run only on the server and not even sent to the client.

import { revalidatePath } from 'next/cache';
import { addTodo, readTodos } from '@src/server';
import { SubmitFormButton } from './submit-form-button';
 
export default async function Page() {
  const data = await readTodos();
 
  const addTodoAction = async () => {
    'use server';
 
    await addTodo();
    revalidatePath('/server-action');
  };
 
  return (
    <div>
      <h1>Home</h1>
      <ul>
        {data.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
      <form action={addTodoAction}>
        <SubmitFormButton />
      </form>
    </div>
  );
}
 

In this case, we have a server action defined directly within the component where the form that invokes the action occurs.

Type safety

One of the drawbacks of server actions is that the formData that are implicitly sent to the server action is not typed, and we have to validate this data ourselves, for example, using zod.

Revalidation

We can notice that within the server action we not only create a new todo, but we call the revalidatePath function. This function makes sure that if the page we pass to the function (in the previous example, the /server-action page) has an asynchronous component that is getting data, that component will be revalidated. This goes against the principle of never revalidating from RSC, which is still preserved. By calling revalidatePath, we guarantee that the action server will deliver not only the result of the action itself but also the new UI for the specified page.

In the traditional server/client approach, without using server actions when creating new data, this data flow was typical:

Server action vs POST request

So now we have two options to create, update and delete data from the client. We will mention the two main differences in the two approaches:

Need for JavaScript

Revalidation

The latter approach is how the vast majority of applications work today. Server actions is an entirely new pattern that is gradually gaining popularity.

Server actions called from client

So far, we have been working with server actions being called from the form via action. However, we can also call server actions from the client component, by default from the form's onSubmit event handler. Thus, We can guarantee the benefits of server actions, such as RSC revalidation in the server action and type checking the parameters we send to the action. At the same time, the whole code can be made slightly clearer, as we don't need to define extra POST route handlers, we can just define an asynchronous function that will be a server action from any file that is marked as use server.

// src/app/server-action/page.tsx
 
import { readTodos } from '@src/server';
import { AddTodoForm } from '../components/add-todo-form';
 
export default async function Page() {
  const data = await readTodos();
 
  return (
    <div>
      <h1>Home</h1>
      <ul>
        {data.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
      <AddTodoForm />
    </div>
  );
}
 
// use client;
 
import { useState } from 'react';
import { addTodoFromClientAction } from '@src/server-actions';
 
export function AddTodoForm() {
  const [name, setName] = useState('');
 
  return (
    <form onSubmit={async (e) => {
      e.preventDefault();
      await addTodoFromClientAction({ name });
      setName('');
    }}>
      <input
        name="name"
        onChange={(e) => setName(e.target.value)}
        type="text"
        value={name}
      />
      <button type="submit">Submit</button>
    </form>
  );
}
 
// use server;
 
import { revalidatePath } from 'next/cache';
import { addTodo } from '../server';
 
export async function addTodoFromClientAction(input: { name: string }) {
  // NOTE: There should still be input validation, for example via zod
  await addTodo(input.name);
 
  revalidatePath('/server-action-client');
}

The library next-safe-action may be of interest for using this approach.

For more information and an explanation of all possible uses of server actions, we recommend this video.

Caching

Next.js has a very aggressive caching model that tries to reduce costs and improve the performance of our application. Caching occurs on several layers and you can say that this model is very complex to understand. Most of the caching mechanisms can be opt out. This means that if we want to turn off a cache layer, we can do so.

What adds even more difficulty to understanding how caching works in Next.js is the fact that most caching is not visible in the development environment. It only manifests itself when the application is run in production mode, which makes it much harder to debug.

Request Memoization

As we've discussed in this lesson, Next.js extends the behavior of the native fetch function with several options. Among other things, it makes sure that all fetch requests that are called on the server with the same URL or the same associated tag are memoized. In practice, this means that if a Parent component needs to fetch data and calls the appropriate fetch in the RSC, then any nested Child component that needs the same data can call that fetch again. Still, in reality the fetch is no longer executed, but the cached data is provided.

import { Suspense } from 'react';
import { z } from 'zod';
 
export const dynamic = 'force-dynamic';
const url = 'https://api-example.com/users';
 
async function fetchData() {
  const response = await fetch(url);
  const data = z
    .array(z.object({ id: z.string() }))
    .parse(await response.json());
 
  return data;
}
 
async function Child() {
  const data = await fetchData();
  const first = data[0];
 
  if (!first) {
    return null;
  }
 
  return (
    <div>
      <h1>This is first ID: {first.id}</h1>
    </div>
  );
}
 
async function Parent() {
  const data = await fetchData();
 
  return (
    <div>
      <div className="flex gap-x-2">
        {data.slice(0, 2).map((user) => (
          <div key={user.id}>{user.id}</div>
        ))}
      </div>
      <Suspense fallback={<div>Child loading...</div>}>
        <Child />
      </Suspense>
    </div>
  );
}
 
export default function Page() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Parent />
    </Suspense>
  );
}
 

In this case the custom fetch is executed only once, inside the Parent component. Because of this, we will see a Loading fallback that is defined inside Page. Once Parent gets the data from the API, it starts rendering, where it finds the Child component, which is also asynchronous. Therefore, it first starts rendering the Child loading... fallback, which is hardly noticeable on the page since the Child component, although asynchronous, occupies the same fetch as Parent and thus gets the data almost immediately from the cache.

A few rules apply to make request memoization work properly:

Data cache

Data cache is again related to fetch request, where we can specify how the requests will be cached. By default, Next.js caches all fetch requests, which is something we very likely don't want. Typically, we have a site where you need to get the most recent data. Suppose we leave the default behavior when we build the application. In that case, Next.js will call these fetch requests and generate a component with the correct data already, expecting all clients to be provided with this result. This means that if the data changes, it will only change at the next build of the application.

We use the aforementioned cache and next.revalidate options to modify this behavior.

Full route cache

Next.js tries to render each page statically at build time so it doesn't have to render at runtime. Of course, this doesn't work for all pages needing actual data retrieved at runtime or request time. Despite this, Next.js renders everything static on the server, and then this static is served to the client, with dynamic content being fetched at runtime. This dynamic content is called a React Server Component Payload, a special form of JSON sent to the client, which can process this payload and modify the DOM. Once this RSC Payload is processed on the client, it is cached in what is called the Router cache. If we leave to another page within the application and then come back, Next.js will detect that this RSC Payload is already in the Router cache, and therefore, there is no need to send a request to the server. This behavior can cause problems because if the RSC Payload may have changed (for example, a new entity was created), we will be shown the old RSC Payload. Therefore, it is necessary to use revalidatePath or revalidateTag to prevent this cache from being used.

Router cache

This cache is purely client-side. The difference from Full Route Cache is that Full Route Cache stores RSC Payload and HTML on the server and serves all users of our application. The router cache is purely for a single user. If a user moves within the application, all retrieved RSC Payloads are stored in the Router cache. If the user returns to a page that has already been visited, the RSC Payload is retrieved from the Router cache, and there is no need to send a request to the server.

This cache can only be invalidated in two ways:

Database

Since Next.js is (by default) running fully fledged Node.js environment in the server code, it can server your app as a complete backend. One of the things that backends usually take care of is a database access. Just like with any other feature, there are multiple options to pick from in javascript, that allow you to easily access your database and many of them even provide amazing tools that let you infer types from tables and use optimized type safe functions instead of writing raw SQL.

Drizzle

Drizzle is an Object-Relational Mapping (ORM) library designed for use with TypeScript and environments like Next.js. ORM tools are crucial in the development process as they provide an efficient way to interact with databases by mapping database tables to objects in code, making database operations more intuitive and reducing the amount of SQL code developers need to write and maintain.

Nowadays, prisma is a trendy technology. However, we have chosen Drizzle for this course as there is no need to learn a specific prisma language. We only need TypeScript to use Drizzle.

At the same time, we will be using Turso, which is a database provider that provides a free tier for creating a database. Therefore, we also recommend it for your final project. It also includes good support for ORMs like Drizzle.

npm i drizzle-orm @libsql/client
npm i -D drizzle-kit

We will use SQLite, and it is recommended for your projects. It's a quick and easy way to get started with prism and databases. In case you eventually need a different database, prisma makes it easy to achieve this by changing only the configuration.

We will need one instance of the database that we can use throughout the application. We can create this instance, for example, in src/db/index.ts

// src/db/index.ts
 
import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@libsql/client';
 
const client = createClient({
  url: process.env.DATABASE_URL!,
  authToken: process.env.AUTH_TOKEN
});
 
export const db = drizzle(client);

The db instance represents a drizzle client communicating with our database and acting simply as an ORM.

To work with the database, we need to define our tables (models, schemas). We can create these in src/db/schema/* files, where we define one file for each model.

// src/db/schema/users.ts
 
import { text, integer, sqliteTable } from 'drizzle-orm/sqlite-core';
 
export const users = sqliteTable('users', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  name: text('name')
});

In this way we create tables that we will then use within our application. For more information about all the possible data types supported in SQlite, we recommend drizzle documentation.

Turso

Turso is a database provider that provides a free database creation tier. To create a database, you need to create an account on their site and then create a database. After making the database, we get a DATABASE_URL which we can use in our application.

We recommend Turso documentation for basic Turso setup.

Local development

It is not necessary to interact with the actual database during development. Still, because we use SQlite, Turso allows you to create your local database via CLI and work with it during development. Once we want to use the deployed, actual database, we just need to change the DATABASE_URL in the .env file.

Migrations

To apply the schemas we have created, we need to create migrations. Drizzle provides us with a drizzle-kit tool that we can use to enter schema changes into the database.

To start, we need to create a drizzle.config.ts file where we define the path to our schemas and migrations. This file must exist in the root directory of our application (next to package.json for example).

// drizzle.config.ts
 
import type { Config } from 'drizzle-kit';
 
export default {
  schema: './src/db/schema',
  driver: 'turso',
  dbCredentials: {
    url: process.env.DATABASE_URL!,
    authToken: process.env.AUTH_TOKEN
  },
  out: '.drizzle'
} satisfies Config;

Then we can create the first migration using the CLI npx drizzle-kit push:sqlite. To create it, we need to have the database running, of course.

Any time we change the schema, we have to call this CLI command again otherwise the changes will not be reflected in the database.

Complete example - server actions, database

Now we have all the means to create a CRUD application, that is, to define an entity that we can create, delete, edit, and display. For the sake of demonstration, we will only show the acquisition and creation of a new user.

import { db } from '@src/db';
import { users } from '@src/db/schema';
import { AddPersonForm } from '../components/add-person-form';
 
export default async function Page() {
  const data = await db.select().from(users).all();
 
  return (
    <div>
      {data.map((user) => (
        <div key={user.id}>{user.name}</div>
      ))}
      <AddPersonForm />
    </div>
  );
}
 
'use client';
 
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { addPerson } from '@src/server-actions';
 
const formSchema = z.object({
  name: z.string(),
});
 
type FormSchema = z.infer<typeof formSchema>;
 
export function AddPersonForm() {
  const form = useForm<FormSchema>({
    resolver: zodResolver(formSchema),
  });
 
  const onSubmit = async (values: FormSchema) => {
    await addPerson(values);
    form.reset();
  };
 
  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      <input {...form.register('name')} type="text" />
      <button type="submit">Submit</button>
    </form>
  );
}
 
'use server';
 
import { revalidatePath } from 'next/cache';
import { db } from '@src/db';
import { users } from '@src/db/schema/users';
 
export async function addPerson({ name }: { name: string }) {
  await db.insert(users).values({ name });
 
  revalidatePath('/server-action-client-person');
}

Next lecture

This final lesson, we'll delve into user authentication, leveraging metadata for better visibility, and explore simple deployment options with Vercel. We'll also demonstrate practical authentication implementation using the next-auth library with GitHub.

Assignment

This week, modify the 6th task to use server actions and React Server Components for data management, ensuring persistent user sessions with server-side storage. Replace API calls with server actions, and manage data using a local sqlite database via Drizzle ORM.