I recently had to do a proof of concept site using NextJS 14. I hadn’t used NextJS since version 11, and who knew how much could change in 3 major versions?
The big change is the use of Server Actions, and then the App Router / /app
directory, as opposed to the older client server interaction with the /pages
model.
Where this caught me out was with forms.
More specifically, how to put existing data back into a form for the Update journey.
There’s actually a really neat way to use forms in NextJS 14 that makes use of Server Actions. There’s also a bit of head-scratching involved if you’re used to the ‘old fashioned’ way of pages with API calls and what have you. As ever with these changes, some things are easier, and some things are made more difficult.
Let’s dive into the example app.
The Example NextJS App Setup
We’re going to have a simple CRUD app. This app will make use of NextJS 14 (obvs), and the latest Postgres version via Docker. You can set up your persistence however you like, it’s really not the focus of this. But we do need some kind of persistence in order to illustrate CRUD.
You can create a new thing, and this will be persisted off to the things
table inside Postgres. Each thing will have a name and a quantity – this gives both a string
and a number
data type, just for a bit of variety.
We will have a list of things. This will initially be an empty list, and then as we create new things they will be added to the list. This covers one other potential ‘gotchas’ in the form of NextJS’s aggressive multi level caching.
From the list, you can edit a thing. This is the interesting bit, as far as this post is concerned, because this bit doesn’t seem to be anywhere on the docs.
And lastly you can delete a thing. Because we might as well go the whole hog.
You can clone this app, or just poke around the code over on GitHub.
I’ll walk through the relevant parts that take this beyond the default install of NextJS 14.
Here’s the first step:
➜ ~ cd Development
➜ Development npx create-next-app@latest
✔ What is your project named? … nextjs-14-example-crud-app
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes
Creating a new Next.js app in /home/chris/Development/nextjs-14-example-crud-app.
Using npm.
Initializing project with template: app-tw
Installing dependencies:
- react
- react-dom
- next
Installing devDependencies:
- typescript
- @types/node
- @types/react
- @types/react-dom
- autoprefixer
- postcss
- tailwindcss
- eslint
- eslint-config-next
added 362 packages, and audited 363 packages in 28s
128 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
Initialized a git repository.
Success! Created nextjs-14-example-crud-app at /home/chris/Development/nextjs-14-example-crud-app
Code language: Shell Session (shell)
Then we can go into the directory and start up the server, and see that things look good and ready to roll:
➜ Development cd nextjs-14-example-crud-app
➜ nextjs-14-example-crud-app git:(main) npm run dev
> nextjs-14-example-crud-app@0.1.0 dev
> next dev
▲ Next.js 14.0.4
- Local: http://localhost:3000
✓ Ready in 774ms
✓ Compiled /favicon.ico in 121ms (433 modules)
✓ Compiled / in 373ms (510 modules)
Code language: CSS (css)
Which gives us the basic landing page:
Which as it says at the top, we can edit by making changes to src/app/page.tsx
, so let’s do just that.
I’m going to delete everything in that file, and replace it with this:
export default function Home() {
return (
<main className="p-4">
<p>hello</p>
</main>
)
}
Code language: TypeScript (typescript)
I’m going to delete everything out of globals.css
and replace it with this:
@tailwind base;
@tailwind components;
@tailwind utilities;
Code language: CSS (css)
And with that we should have a blank canvas to work from.
Database Setup
Next, we need a database.
That sounded like I was talking to my installation. Given how rapidly AI is progressing, that’s probably what we will be doing by the end of 2024.
However, for now, this is a manual process.
I’m going to use Docker, and specifically, docker-compose.yaml
:
version: "3.7"
services:
db:
container_name: nextjs-example-crud-app-db
image: postgres:16-alpine
ports:
- "5684:5432"
environment:
POSTGRES_DB: app_db
POSTGRES_USER: app_user
POSTGRES_PASSWORD: password
volumes:
- "./db/volumes/pgdata:/var/lib/postgresql/data"
command: [ "postgres", "-c", "log_statement=all" ]
Code language: JavaScript (javascript)
Then start this from another terminal window with:
➜ nextjs-14-example-crud-app git:(main) ✗ docker-compose up
Creating network "nextjs-14-example-crud-app_default" with the default driver
Creating nextjs-example-crud-app-db ... done
Attaching to nextjs-example-crud-app-db
nextjs-example-crud-app-db | The files belonging to this database system will be owned by user "postgres".
nextjs-example-crud-app-db | This user must also own the server process.
# lots of gumph removed
nextjs-example-crud-app-db | 2024-01-05 18:28:05.452 UTC [53] LOG: database system was shut down at 2024-01-05 18:28:05 UTC
nextjs-example-crud-app-db | 2024-01-05 18:28:05.456 UTC [1] LOG: database system is ready to accept connections
Code language: PHP (php)
Cool, we have a database.
There are a bunch of ways to interact with your database. JetBrains provide several including the mighty DataGrip application.
You could also use the CLI.
However I’m going to use the built-in DB explorer tool that comes with WebStorm. It’s pretty much identical to DataGrip for my needs.
Start by opening the database tab:
Then select Postgres.
This probably won’t be in the Recent tab for you, unless you happen to use Postgres a lot like this… in which case you can likely skip this bit anyway.
Next you need to configure the data source.
Sounds complex, but really it’s just a case of copying the same stuff we added to the Docker Compose file earlier:
It’s worthwhile clicking the Test Connection thing just so you know everything is working before you click OK to save and close the dialog box.
Once that’s done, your new connection should show in the Database pane:
We now have a database server, and a database schema. But no tables.
Click on the Query Console icon – it looks like a little terminal, and is in the top left of the context menu in the screenshot below.
Then click Open Default Console.
That brings up the query console, into which we need to paste the following SQL statements:
DROP TABLE IF EXISTS things CASCADE;
CREATE TABLE things (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
quantity INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Code language: SQL (Structured Query Language) (sql)
Paste that lot into the query console, highlight it all (important), and click Execute:
It’s important to highlight everything as the query console can be used to selectively run queries, based on what you highlight. So you can execute things independently, and more than once, which is useful but not for this right now.
Once done, your new database table should be visible:
Perfect. That’s our database setup complete.
Postgres Database Connectivity From NextJS
We’re going to need a way for our Node App – our NextJS application – to talk with our database.
For that, I am going to use pg
.
You can use any other method, but I like pg
so that’s what I’m going with. It really isn’t that important how we interact with the DB in some regards. Just that we can.
OK, let’s get the necessary installed.
Stop your NextJS web server, and from the same console run:
npm install pg
# and then
npm i -D @types/pg
# or if you're lazy, like me
npm i pg && npm i -D @types/pg
Code language: PHP (php)
Very good. Now start up the NextJS dev server again with:
npm run dev
And let’s carry on.
We will keep things somewhat realistic here by doing three things:
- Creating a TypeScript type for our
Config
- Putting our
config
object in a standalone file - Using the config to configure our DB pool
OK, so the type we will need:
// src/types/index.ts
export type AppConfig = {
db: {
connectionString: string;
poolSize: number;
};
};
Code language: TypeScript (typescript)
And the config object:
// src/config.ts
import { AppConfig } from "./types";
const config: AppConfig = {
db: {
connectionString: `postgres://app_user:password@0.0.0.0:5684/app_db`,
poolSize: 4
},
};
export default config;
Code language: TypeScript (typescript)
Then we need to configure the DB connection pool
A connection pool is a cache of database connections that are reused, rather than being opened and closed for each new request to the database. This helps improve the efficiency and performance of database operations.
Here’s our pool configuration:
// src/db/pool.ts
import { Pool } from "pg";
import config from "../config";
const getDbPool = new Pool({
connectionString: config.db.connectionString,
max: config.db.poolSize,
});
export default getDbPool;
Code language: JavaScript (javascript)
You can read more about all of this on the docs for pg
.
Right, that is us done for initial setup, let’s actually get into what this post was supposed to be about.
Reading Things – The R in CRUD
When doing CRUD, there are two intuitive places to start.
We can either present a list of things we have.
Or we can give our user a way of adding a new thing.
It’s a touch “chicken and the egg”, because we can’t list things if we don’t have any things. But equally if we create a new thing, we don’t have a good way to show the user what they just created.
So we have to make a choice.
And as I’m driving, I’m going to say we start with listing things, which means Reading stuff (or things) from the database. As we don’t have any initial things to show, we will make use of a React-ism to show a ‘you don’t have any things‘ message to begin with.
Enough waffle.
Where To Put Our Things?
NextJS gives us that initial landing page. If you remember, we took all their hard work and put it in the bin. We replaced it with this:
// src/app/page.tsx
export default function Home() {
return (
<main className="p-4">
<p>hello</p>
</main>
)
}
Code language: TypeScript (typescript)
And that’s fine.
That’s our root route. That’s two ways of saying ‘root’ if you speak the Queens King’s English, or two entirely different words if you speak with a funny accent 🇺🇸
As this is NextJS 14, and things might be different to what you’re used too, we are going to create a whole new route, which is a page, but is no longer a page
(how confusing!) just to illustrate the difference.
Again, I’m not going into the details of this because the NextJS docs are the canonical source of truth and frankly, it’s already becoming a long article that is hurting my fingers to type.
Let’s just create the file and see what happens, shall we?
// src/app/things/page.tsx
export default function Things() {
return (
<main className="p-4">
<p>our things!</p>
</main>
);
}
Code language: TypeScript (typescript)
You can use the older pages
approach by, quite literally, using the pages
directory.
But the new approach is to use to the app
directory, and that gives us the App router behind the scenes. That has a bunch of conventions we have to follow, one of which is that any sub-directory of app
with a file called page.tsx
will automagically become a route at the sub-directory name.
Or, in simple terms, we just made /things
:
Our /things
route will be our List view. Or our Read view, from the CRUD point of view.
Therefore we need to Read the contents of the things
table from our Postgres database, and display those items on the page.
You may be thinking we can do this with getServerSideProps
. And you can. But this becomes even more streamlined with Server Actions.
Common Setup
First let’s define a TypeScript type to describe a Thing
:
// src/types/index.ts
export type Thing = {
id: number;
name: string;
quantity: number;
}
Code language: TypeScript (typescript)
And a database query to get all our Things:
// src/db/things/get-all.ts
import pgPool from "../pool";
import { Thing } from "@/types";
const getAll = async () => {
const client = await pgPool.connect();
try {
const result = await client.query("SELECT * FROM things");
const things: Thing[] = result.rows;
return things;
} catch (e) {
console.error(e);
} finally {
client.release();
}
};
export default getAll;
Code language: TypeScript (typescript)
Regardless of whether we go with the older Pages Router approach, or the newer App Router method, we will make use of the common typescript Thing
type, and the getAll
query.
The Older Pages Router Approach
Let’s quickly look at how you are probably used to solving this problem.
Note: you cannot have both a page and an equivalent app route at the same path. So what I am about to show you won’t work simultaneously.
Here define a page that is available at /things
:
// src/pages/things.tsx
import React from "react";
import { GetServerSideProps } from "next";
import getAll from "@/db/things/get-all";
import { Thing } from "@/types";
interface ThingsPageProps {
things: Thing[];
}
export const getServerSideProps: GetServerSideProps<
ThingsPageProps
> = async () => {
const things = await getAll();
return {
props: {
things: !things ? [] : things,
},
};
};
const ThingsPage: React.FC<ThingsPageProps> = ({ things }) => {
return (
<main className="p-4">
<h1 className="text-xl mb-2">List of Things</h1>
{things.length === 0 && <p>There are no things.</p>}
<ul>
{things.map((thing) => (
<li key={thing.id}>
{thing.name} - Quantity: {thing.quantity}
</li>
))}
</ul>
</main>
);
};
export default ThingsPage;
Code language: TypeScript (typescript)
The Same Page Using The App Router Approach
And here is the same page defined using the App Router approach:
// src/app/things/page.tsx
import getAll from "@/db/things/get-all";
import React from "react";
async function getThings() {
const things = await getAll();
return !things ? [] : things;
}
export default async function Things() {
const things = await getThings();
return (
<main className="p-4">
<h1 className="text-xl mb-2">List of Things</h1>
{things.length === 0 && <p>There are no things.</p>}
<ul>
{things.map((thing) => (
<li key={thing.id}>
{thing.name} - Quantity: {thing.quantity}
</li>
))}
</ul>
</main>
);
}
Code language: TypeScript (typescript)
It’s a ten line saving, and I’d happily agree it’s therefore easier to grasp what is happening.
However this comes at the cost of more stuff happening behind the scenes that is abstracted away from us, but is also likely quite complex and opts us in to using React Server Components, which are (as best I understand it) still an Experimental Feature.
OK, so as long as we are comfortable with those points, we can continue on.
Right now we have a simple page that makes a successful database call, finds no results, and shows the ‘fallback’ messaging:
Creating Things – The C in CRUD
Next we will make a new page that contains a form to add a new Thing to our database.
This showcases a couple of things… err, I should have chosen a better word.
Firstly we will see how forms got simpler.
Second we will see how caching got more aggressive.
Common Setup
Like before, we will have a look at how we might have done this using Pages, and then one way we can do it using the App Router approach.
For both, we will end up with a new Thing we want to add to the database.
// src/db/things/add-one.ts
import pgPool from "../pool";
import { Thing } from "@/types";
const addOne = async (
name: string,
quantity: number,
): Promise<Thing | null> => {
const client = await pgPool.connect();
try {
const result = await client.query(
"INSERT INTO things (name, quantity) VALUES ($1, $2) RETURNING *",
[name, quantity],
);
// Return the newly inserted thing
return result.rows[0];
} catch (e) {
console.error(e);
return null;
} finally {
client.release();
}
};
export default addOne;
Code language: JavaScript (javascript)
Now we can crack on with the implementation.
The Older Pages Router Approach
With the Pages Router, you need to manually create API endpoints to handle securely mutating data on the server.
This is a pain, and involves a lot of boiler plate.
This is the first significant saving that Server Actions noticeably bring. But let’s see the old way, anyway.
// pages/add-thing.tsx
import React, { useState } from "react";
import { useRouter } from "next/router";
const AddThingPage: React.FC = () => {
const router = useRouter();
const [name, setName] = useState("");
const [quantity, setQuantity] = useState("");
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
};
const handleQuantityChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuantity(e.target.value);
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// Validate form inputs
if (!name || !quantity) {
alert("Please fill in all fields");
return;
}
// Convert quantity to a number
const parsedQuantity = parseInt(quantity, 10);
const response = await fetch("/api/add-thing", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name,
quantity: parsedQuantity,
}),
});
// Handle response if necessary
const data = await response.json();
console.log(`data`, data);
// Redirect back to the things list page
await router.push("/things");
};
return (
<main className="p-4">
<h1 className="text-xl mb-2">Add a New Thing</h1>
<form onSubmit={handleSubmit}>
<label>
Name:
<input type="text" value={name} onChange={handleNameChange} />
</label>
<br />
<label>
Quantity:
<input
type="number"
value={quantity}
onChange={handleQuantityChange}
/>
</label>
<br />
<button type="submit">Add Thing</button>
</form>
</main>
);
};
export default AddThingPage;
Code language: TypeScript (typescript)
That’s 74 lines… (only 72 if I remove the comment), and that doesn’t really handle validation in any realistic way, nor add styling.
Oh, and I forgot to add the API endpoint! (well, not really… I did that for dramatic effect):
// pages/api/add-thing.ts
import { NextApiRequest, NextApiResponse } from "next";
import addOne from "@/db/things/add-one";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method Not Allowed" });
}
const { name, quantity } = req.body;
if (!name || !quantity) {
return res.status(400).json({ error: "Name and quantity are required" });
}
try {
const parsedQuantity = parseInt(quantity, 10);
const newThing = await addOne(name, parsedQuantity);
if (newThing) {
return res.status(201).json(newThing);
} else {
return res.status(500).json({ error: "Failed to insert the new thing" });
}
} catch (error) {
console.error(error);
return res.status(500).json({ error: "Internal Server Error" });
}
}
Code language: TypeScript (typescript)
That’s another 30+ lines.
But that’s the way it was done. And on the plus side you were dog fooding your own API. If you wanted to expose that API to other people, you could. That’s quite nice.
OK, so that’s the way we knew. What about using the App Router?
The Same Page Using The App Router Approach
This is the first big noticeable change.
I’m gong to keep this unstyled pretty much, as frankly I hate styling stuff and in this case it just adds cruft that masks the things we actually care about. However as tailwinds strips most form styling I will have to add a little bit just to make this not totally suck.
Here’s what we need (yes, all of it):
// src/app/things/add/page.tsx
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import addOne from "@/db/things/add-one";
import React from "react";
const addThing = async (formData: FormData) => {
"use server";
const thing = await addOne(
formData.get("name") as string,
parseInt(formData.get("quantity") as string, 10),
);
console.log(`thing`);
revalidatePath("/things");
redirect(`/things`);
};
export default function AddThing() {
return (
<form action={addThing} className="flex flex-col place-items-baseline">
<h1 className="text-xl mb-2">Add a New Thing</h1>
<label>
Name:
<input name="name" type="text" required className="border" />
</label>
<label>
Quantity:
<input
name="quantity"
type="number"
required
min="0"
className="border"
/>
</label>
<button type="submit">Add thing</button>
</form>
);
}
Code language: TypeScript (typescript)
That’s a night and day difference. We’re talking 60+ lines saved here.
But we do lose the API… which is probably not that big of a deal, as in the real world we were probably the only consumers of our own API anyway, I’d argue. If not, we can always spin up an API in something a bit more robust.
There’s a bunch of things to cover here, but to quickly show we can add new things, and they show up in the list, here we have something added from the old page, and from the new:
Again, the NextJS docs do a good job of explaining this new stuff.
They cover forms, and hint at needing to flush the cache when mutating.
The use of use server
tells NextJS that this code runs server side. That is why we are allowed to run a DB call inside a React component. It’s safe. It happens on the server only. This would not work on the Page approach, because it would try to run that on the client’s machine … which in development is your machine (it still wouldn’t work) but in production is some random old geezer’s mobile phone … so no wonder that doesn’t work properly.
The form stuff feels a bit … well, needing some love.
Everything is a string – and we have to cast that anyway as TypeScript has no idea about what the data represents:
const thing = await addOne(
formData.get("name") as string,
parseInt(formData.get("quantity") as string, 10),
);
Code language: TypeScript (typescript)
Then because everything is a string, we first need to cast then parseInt
if working with numbers. It’s a bit messy, but we can get by.
Lastly, we need to tell NextJS specifically what route(s) to refetch data for, after a mutation:
import { revalidatePath } from "next/cache";
// ...
revalidatePath("/things");
Code language: TypeScript (typescript)
This isn’t an issue on the Page Router approach. But with the App Router if we don’t do this, we won’t actually see the new data show up on the /things
list / read view until we either wait for the cache to expire / timeout, or do a hard / full page refresh forcing a new fetch.
As such we have to use the function that NextJS provides for us, to explicitly tell it which page(s) we want to force to refresh after this action completes. I actually quite like the granularity of it. This, again, will only work server side. You can’t call that from a client component.
We will come back to forms shortly, and likely in a follow up post as I have something more to cover on a more complicated example.
Deleting Things – The D in CRUD
Don’t worry, I haven’t forgotten about Updating things.
Deleting things is easier, so we will cover that next.
We have basically covered everything we need to know about a delete.
It’s a server action, and it involves forcing a cache refresh.
I’m not going to show the older approach, because basically it’s the same as what we just saw. Instead I will just show the App Router approach.
Deleting Things With Server Actions
To make this work we will have a delete button next to our Things on the List view.
You can do this however you want, but I’m keeping it simple as, again, Delete is here really for completeness rather than being particularly interesting.
We have a basic query – you might want to go more advanced:
// src/db/things/delete-one.ts
import pgPool from "../pool";
const deleteOne = async (thingId: number): Promise<boolean> => {
const client = await pgPool.connect();
try {
await client.query("DELETE FROM things WHERE id = $1", [thingId]);
return true;
} catch (error) {
console.error(error);
return false;
} finally {
client.release();
}
};
export default deleteOne;
Code language: TypeScript (typescript)
And then a way to trigger this from the page:
// src/app/things/page.tsx
import getAll from "@/db/things/get-all";
import React from "react";
import DeleteThing from "@/Components/DeleteThing";
async function getThings() {
const things = await getAll();
return !things ? [] : things;
}
export default async function Things() {
const things = await getThings();
return (
<main className="p-4">
<h1 className="text-xl mb-2">List of Things</h1>
{things.length === 0 && <p>There are no things.</p>}
<ul>
{things.map((thing) => (
<li key={thing.id}>
{thing.name} - Quantity: {thing.quantity}{" "}
<DeleteThing thing={thing} />
</li>
))}
</ul>
</main>
);
}
Code language: JavaScript (javascript)
And the DeleteThing
component:
// src/Components/DeleteThing.tsx
import { Thing } from "@/types";
import deleteOne from "@/db/things/delete-one";
import { revalidatePath } from "next/cache";
type DeleteThing = {
thing: Thing;
};
export default function DeleteThing({ thing }: DeleteThing) {
const deleteThing = async () => {
"use server";
await deleteOne(thing.id);
revalidatePath("/things");
};
return (
<form>
<button formAction={deleteThing} className="m-8">
Delete me
</button>
</form>
);
}
Code language: TypeScript (typescript)
I have to explicitly add the use server
inside the deleteThing
function on line 13 otherwise Next blows up with a tricky error:
Unhandled Runtime Error
Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server".
<button formAction={function} className=... children=...>
^^^^^^^^^^
Code language: HTML, XML (xml)
I experienced this one a lot when migrating from 11 to 14. Typically I used it as a guide to get me to better understand how I should be doing stuff instead.
Because the deleteThing
function is defined inside the DeleteThing
component, we can make use of closure to reference thing
directly inside the function, without having to explicitly pass it in as an argument.
Anyway, that works also, and this stuff is covered in more depth on the official NextJS docs, so let’s move on to Update, which kinda isn’t.
Updating Things – The U in CRUD
Right then, 3000+ words later, we finally got to the bit I actually intended to write this post about.
This was the big pain I hit when working with this new approach – and it has a surprisingly simple solution, you will be happy to know.
OK, so the pain is this: how do I reload / repopulate my Forms when using NextJS 14?
Google was not my friend here. Neither was the React docs, nor the NextJS docs.
But like I say, a surprisingly simple solution. Here we go.
Common Setup
Like before, we will have shared queries regardless of the approach.
We will need a way to fetch a single Thing. Here’s one way to do that:
// src/db/things/get-one.ts
import pgPool from "../pool";
import { Thing } from "@/types";
const getOne = async (thingId: Thing['id']): Promise<Thing | null> => {
const client = await pgPool.connect();
try {
const result = await client.query("SELECT * FROM things WHERE id = $1", [
thingId,
]);
if (result.rows.length === 0) {
console.error(`Thing with id ${thingId} not found`);
return null;
}
return result.rows[0];
} catch (error) {
console.error(error);
return null;
} finally {
client.release();
}
};
export default getOne;
Code language: TypeScript (typescript)
We will also need a query to update an existing Thing.
It will always set a new name, and a new quantity… so we always need to pass them as arguments
// src/db/things/update-one.ts
import pgPool from "../pool";
import { Thing } from "@/types";
const updateOne = async (
thingId: Thing["id"],
newName: Thing["name"],
newQuantity: Thing["quantity"],
): Promise<Thing | null> => {
const client = await pgPool.connect();
try {
const result = await client.query(
'UPDATE things SET name = $1, quantity = $2 WHERE id = $3 RETURNING *',
[newName, newQuantity, thingId],
);
return result.rows[0];
} catch (error) {
console.error(error);
return null;
} finally {
client.release();
}
};
export default updateOne;
Code language: TypeScript (typescript)
Those are the two shared queries.
We will also update the List view to allow editing a single Thing:
import Link from "next/link";
// whatever implementation ...
return (
<main className="p-4">
<h1 className="text-xl mb-2">List of Things</h1>
{things.length === 0 && <p>There are no things.</p>}
<ul>
{things.map((thing) => (
<li key={thing.id}>
<Link href={`/things/update/${thing.id}`}>
{thing.name} - Quantity: {thing.quantity}{" "}
</Link>
<DeleteThing thing={thing} />
</li>
))}
</ul>
</main>
);
}
Code language: TypeScript (typescript)
This will make it so you can click on a Thing in the list, and be taken to the Update view for that specific Thing.
The Older Pages Router Approach
As we have already seen, with the Page Router approach we need two things:
- A page
- A couple of API endpoints
GET
oneUPDATE
one
Here’s the API endpoint for getting a single thing:
// pages/api/get-thing.ts
import { NextApiRequest, NextApiResponse } from "next";
import getOne from "@/db/things/get-one";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "GET") {
return res.status(405).json({ error: "Method Not Allowed" });
}
const { id } = req.query;
if (!id) {
return res.status(400).json({ error: "ID is required" });
}
const requestedId = Array.isArray(id) ? id[0] : id;
try {
const thing = await getOne(parseInt(requestedId, 10));
return res.status(200).json(thing);
} catch (error) {
console.error(error);
return res.status(500).json({ error: "Internal Server Error" });
}
}
Code language: TypeScript (typescript)
And here’s the API endpoint for updating a thing:
// pages/api/update-thing.ts
import { NextApiRequest, NextApiResponse } from "next";
import updateOne from "@/db/things/update-one";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "PUT") {
return res.status(405).json({ error: "Method Not Allowed" });
}
const { id, name, quantity } = req.body;
if (!id || !name || !quantity) {
return res.status(400).json({ error: "ID, name, and quantity are required" });
}
try {
const parsedId = parseInt(id, 10);
const parsedQuantity = parseInt(quantity, 10);
const updatedThing = await updateOne(parsedId, name, parsedQuantity);
if (updatedThing) {
return res.status(200).json(updatedThing);
} else {
return res.status(404).json({ error: `Thing with ID ${id} not found` });
}
} catch (error) {
console.error(error);
return res.status(500).json({ error: "Internal Server Error" });
}
}
Code language: TypeScript (typescript)
You can probably already imagine that Server Actions remove both of these.
The page needs to take the id
of the Thing we want to update. This uses the parameterised routing feature that NextJS provides, indicated by the name of the page in the comment on the first line:
// pages/update-things/[id].tsx
import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
const UpdateThingPage: React.FC = () => {
const router = useRouter();
const { id } = router.query; // Get the thing ID from the query parameters
const [name, setName] = useState("");
const [quantity, setQuantity] = useState("");
useEffect(() => {
// Fetch the existing thing data when the component mounts
if (id) {
fetch(`/api/get-thing?id=${id}`)
.then((response) => response.json())
.then((data) => {
setName(data.name);
setQuantity(data.quantity.toString());
})
.catch((error) => console.error(error));
}
}, [id]);
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
};
const handleQuantityChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuantity(e.target.value);
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// Validate form inputs
if (!name || !quantity) {
alert("Please fill in all fields");
return;
}
try {
const parsedQuantity = parseInt(quantity, 10);
// Update the thing using the API endpoint
const response = await fetch("/api/update-thing", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id,
name,
quantity: parsedQuantity,
}),
});
// Handle response if necessary
if (response.ok) {
// Redirect back to the things list page or any other page
await router.push("/things");
} else {
// Handle non-successful response (e.g., display an error message)
console.error(`Failed to update thing. Status: ${response.status}`);
}
} catch (error) {
// Handle fetch errors
console.error("Error during fetch:", error);
// Display an error message to the user or handle it in an appropriate way
}
};
return (
<div>
<h1>Update Thing</h1>
<form onSubmit={handleSubmit}>
<label>
Name:
<input type="text" value={name} onChange={handleNameChange} />
</label>
<br />
<label>
Quantity:
<input
type="number"
value={quantity}
onChange={handleQuantityChange}
/>
</label>
<br />
<button type="submit">Update Thing</button>
</form>
</div>
);
};
export default UpdateThingPage;
Code language: TypeScript (typescript)
That is a pretty crazy amount of code.
But hey, it works.
It does a lot of stuff:
- It displays a form
- It fetches the requested Thing
- It repopulates the form with the Thing, if found
- It allows the user to make changes to the Thing
- It submits the Thing for an Update action
- It redirects on success
- It even does a bit of rudimentary validation
Now, times that by as many pages as you have that can be updated. And it’s not my experience that many pages have as few as two simple fields.
But like I say, it works.
So let’s see this with the App Router and Server Actions.
The Same Page Using The App Router Approach
This is by far the biggest difference.
Here’s all we need to replicate the same behaviour as above:
import { Thing } from "@/types";
import getOne from "@/db/things/get-one";
import { redirect } from "next/navigation";
import updateOne from "@/db/things/update-one";
import { revalidatePath } from "next/cache";
type UpdateThingPage = {
params: { thingId: Thing["id"] };
};
export default async function UpdateThingPage({ params }: UpdateThingPage) {
const thing = await getOne(params.thingId);
if (!thing) {
return redirect("/not-found");
}
const updateThing = async (formData: FormData) => {
"use server";
await updateOne(
thing.id,
formData.get("name") as string,
parseInt(formData.get("quantity") as string, 10),
);
revalidatePath("/things");
redirect("/things");
};
return (
<div>
<h1>Update Thing</h1>
<form action={updateThing}>
<label>
Name:
<input
type="text"
name="name"
defaultValue={thing.name}
className="border"
/>
</label>
<br />
<label>
Quantity:
<input
type="number"
name="quantity"
defaultValue={thing.quantity}
className="border"
/>
</label>
<br />
<button type="submit">Update Thing</button>
</form>
</div>
);
}
Code language: TypeScript (typescript)
There’s some interesting things here that may not be initially jumping out.
OK, first and foremost, to answer the biggest question I had when starting this process?
How do I put data back in to my form fields?
I had done all the hard work, setting up the DB, creating the query, redirecting if the user requested a bad Thing, but now that I had retrieved a valid Thing from the database and had it sitting there in memory, how on Earth could I put that data as the existing value of my form field?
I was so used to the useState
approach I was just left scratching my head.
But it’s really, really easy:
<input
type="number"
name="quantity"
defaultValue={thing.quantity}
className="border"
/>
Code language: TypeScript (typescript)
It’s just the default value of the form field.
Maybe that is obvious to everyone except me, but that took me a ridiculous amount of time to figure out.
So yeah, problem solved – and hopefully that saves at least one other person a good bit of headscratching.
The second interesting thing is that the updateThing
function isn’t a hook. In fact, we aren’t using any hooks here.
Look at this more closely:
export default async function UpdateThingPage({ params }: UpdateThingPage) {
const thing = await getOne(params.thingId);
if (!thing) {
return redirect("/not-found");
}
const updateThing = async (formData: FormData) => {
"use server";
await updateOne(
thing.id,
formData.get("name") as string,
parseInt(formData.get("quantity") as string, 10),
);
revalidatePath("/things");
redirect("/things");
};
Code language: TypeScript (typescript)
Because this isn’t a hook, I can define this function after the first return
statement.
Here’s something similar in the Page approach:
Now, it’s not that special that I define the function itself after the first return. That is permissable in React / the Page Router approach in NextJS.
But line 2, the await getOne
call is, for all my intents and purposes, effectively identical to if I had used the useEffect
hook approach.
And so if I put the updateThing
function before the conditional check on line 4, then TypeScript cannot guarantee thing
is not null. This screenshot illustrates this better:
So we can move the code around to get the benefits of type safety and closure, in a simple manner. That’s really neat.
Wrapping Up
I have to say I found the migration / relearning path from NextJS 11 to NextJS 14 to be pretty much like relearning the fundamentals.
And I don’t profess to coming close to understanding all that is happening, behind the scenes, when using use client
or use server
.
But being able to run code on the server, such as in the Update example above, is a major win from my position as an “in the trenches” dev, just wanting a framework that gets stuff done and gets out of my way.
Switching to using FormData
however, I think is not as big a win as it initially appears. In my case, in my real app, I had a form where you could dynamically add / remove multiple fields from the form, and that kinda kerplodes the whole FormData
thing.
I’m going to cover that in a follow on post.
Maybe I’m missing another trick.
This is a better explanation than the docs by a long way. Thank you sir.
You’re welcome Joshua, glad I could help