Build a Full-Stack Next.js Starter Template with PostgreSQL, Prisma, Material UI, and Auth.js


Tags: nextjs postgresql prisma full-stack deployment guide authjs material-ui server-actions tutorial

What You’ll Build

A production-ready Next.js starter template featuring:

  • Next.js 15 with App Router and Server Actions
  • PostgreSQL database hosted on Railway
  • Prisma ORM for type-safe database access
  • Auth.js v5 with Credentials authentication (username/password)
  • User management: Registration, login, logout, and profile editing
  • Item CRUD: Full create, read, update, delete operations
  • Material UI: Polished, responsive components
  • Dashboard: Stats and recent items overview
  • Toast notifications for user feedback
  • Pagination for managing items

Prerequisites

Before starting, make sure you have:

  • Node.js 18+ installed (download here)
  • Git installed and configured
  • Git Bash (all codeblocks in this guide are designed to be pasted into a Git Bash terminal)
  • GitHub account for code hosting
  • Railway account (sign up with our referral link for $20 free credits)

Create Your Next.js Project

Initialize Next.js Application

Terminal window
npx create-next-app@latest next-pg-prisma --typescript --eslint --app --src-dir --import-alias "@/*" --turbopack --react-compiler --no-tailwind

This command creates a new Next.js project with TypeScript, ESLint, App Router, Turbopack, and src/ directory pre-configured.

Terminal window
cd next-pg-prisma

Install Dependencies

Install all required packages for the project:

Terminal window
npm install @prisma/client @mui/material @mui/icons-material @emotion/react @emotion/styled @mui/material-nextjs @emotion/cache next-auth@beta pg @prisma/adapter-pg sonner
Terminal window
npm install -D prisma @types/pg

Initialize Prisma

Terminal window
npx prisma init --datasource-provider postgresql

This creates a prisma/schema.prisma file with PostgreSQL configured as the provider, a .env file, and a prisma.config.ts file with environment variable support pre-configured.

Configure Material UI

Next.js App Router requires a specific setup to work with Material UI’s CSS-in-JS engine.

Create Theme Registry

Create src/components/ThemeRegistry/ThemeRegistry.tsx to handle the cache and theme provider:

Terminal window
mkdir -p src/components/ThemeRegistry && cat > src/components/ThemeRegistry/ThemeRegistry.tsx << 'EOF'
"use client";
import * as React from "react";
import { createTheme, ThemeProvider } from "@mui/material/styles";
import CssBaseline from "@mui/material/CssBaseline";
import { AppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter";
import { Toaster } from "sonner";
import { Roboto } from "next/font/google";
const roboto = Roboto({
weight: ["300", "400", "500", "700"],
subsets: ["latin"],
display: "swap",
});
const theme = createTheme({
typography: {
fontFamily: roboto.style.fontFamily,
},
palette: {
mode: "dark",
primary: {
main: "#90caf9",
},
secondary: {
main: "#f48fb1",
},
},
});
export default function ThemeRegistry({ children }: { children: React.ReactNode }) {
return (
<AppRouterCacheProvider>
<ThemeProvider theme={theme}>
<CssBaseline />
<Toaster position="top-center" richColors />
{children}
</ThemeProvider>
</AppRouterCacheProvider>
);
}
EOF

Update Root Layout

Wrap your application with the ThemeRegistry in src/app/layout.tsx:

Terminal window
cat > src/app/layout.tsx << 'EOF'
import type { Metadata } from "next";
import ThemeRegistry from "@/components/ThemeRegistry/ThemeRegistry";
import { NavBar } from "@/components/NavBar";
export const metadata: Metadata = {
title: "Next.js + Railway + Auth.js",
description: "Full Stack Starter with Auth, Prisma, and MUI",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<ThemeRegistry>
<NavBar />
{children}
</ThemeRegistry>
</body>
</html>
);
}
EOF

Set Up Railway and PostgreSQL

Install Railway CLI

Terminal window
npm install -g @railway/cli

Restart your terminal after installation.

Login to Railway

Terminal window
railway login

Create Railway Project

Terminal window
railway init

When prompted:

  1. Select a workspace: Choose your workspace
  2. Project Name: Empty Project for a randomly generated name or next-pg-prisma

Add PostgreSQL Database

Terminal window
railway add -d postgres

This creates a PostgreSQL database in your Railway project.

Terminal window
railway link

When prompted, make the following selections:

  1. Select a workspace: Choose your workspace
  2. Select a project: Choose the project you just created
  3. Select an environment: Choose production
  4. Select a service: Choose Postgres

Note: If you don’t see “Select a service” appear, you may need to run railway link again to ensure the Postgres service is properly linked.

Configure Prisma

Create Prisma Schema

Replace prisma/schema.prisma with example models by running this command (copy/paste entire code block into terminal):

Terminal window
cat > prisma/schema.prisma << 'EOF'
generator client {
provider = "prisma-client-js"
previewFeatures = ["driverAdapters"]
}
datasource db {
provider = "postgresql"
}
model User {
id String @id @default(cuid())
name String?
email String @unique
password String
emailVerified DateTime?
image String?
bio String?
items Item[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Item {
id String @id @default(cuid())
name String
description String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
}
EOF

Configure Build Script

To ensure Prisma Client is generated during deployment, add a postinstall script to your package.json:

Terminal window
npm pkg set scripts.postinstall="prisma generate"

Configure Local Database Connection

Railway’s internal database URL (postgres.railway.internal) isn’t accessible from your local machine. You need to use the public TCP proxy URL instead.

Get your database’s public connection string:

Terminal window
railway variables --service Postgres --json | grep DATABASE_PUBLIC_URL

Once you have the DATABASE_PUBLIC_URL, update your .env file:

Terminal window
cat > .env << 'EOF'
DATABASE_URL="<paste your DATABASE_PUBLIC_URL here>?sslmode=require"
EOF

Replace <paste your DATABASE_PUBLIC_URL here> with the actual URL. It should look like:

DATABASE_URL="postgresql://postgres:password@region.proxy.rlwy.net:PORT/railway"

Note: The ?sslmode=require parameter is added in the command above and is required for secure connections.

Generate Migration Files

Now generate the migration files using your local environment variable:

Terminal window
npx prisma migrate dev --name init

This command:

  • Uses the DATABASE_URL you just set
  • Creates a prisma/migrations directory with SQL migration files
  • Applies the migration to your Railway database
  • Generates Prisma Client

Note: If you get a “Can’t reach database server” error (P1001), your Railway PostgreSQL service may have gone to sleep due to inactivity. Wait 10-20 seconds for it to resume, then run the command again.

Create Prisma Client Singleton

Next.js uses hot reloading in development, which can create multiple instances of Prisma Client. This singleton pattern ensures you reuse the same instance across hot reloads, preventing database connection exhaustion.

Create src/lib/prisma.ts:

Terminal window
mkdir -p src/lib && cat > src/lib/prisma.ts << 'EOF'
import { Pool } from 'pg'
import { PrismaPg } from '@prisma/adapter-pg'
import { PrismaClient } from '@prisma/client'
const connectionString = process.env.DATABASE_URL
const pool = new Pool({ connectionString })
const adapter = new PrismaPg(pool)
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient({ adapter })
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
EOF

This file exports a single prisma instance that you’ll import in your API routes and Server Components to query the database.

Configure Authentication (Auth.js)

We’ll use Auth.js v5 (beta) for secure authentication with username/password credentials.

Install Password Hashing Library

Terminal window
npm install bcryptjs
Terminal window
npm install -D @types/bcryptjs

Create Password Utility

Create src/lib/password.ts for password hashing:

Terminal window
mkdir -p src/lib && cat > src/lib/password.ts << 'EOF'
import bcrypt from "bcryptjs";
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12);
}
export async function verifyPassword(
password: string,
hashedPassword: string
): Promise<boolean> {
return bcrypt.compare(password, hashedPassword);
}
EOF

Configure Auth.js

Create the main auth configuration file auth.ts:

Terminal window
cat > src/auth.ts << 'EOF'
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
import { prisma } from "@/lib/prisma"
import { verifyPassword } from "@/lib/password"
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
authorize: async (credentials) => {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user || !user.password) {
return null;
}
const isValid = await verifyPassword(
credentials.password as string,
user.password
);
if (!isValid) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
};
},
}),
],
session: {
strategy: "jwt",
},
callbacks: {
jwt: ({ token, user }) => {
if (user) {
token.id = user.id;
}
return token;
},
session: ({ session, token }) => ({
...session,
user: {
...session.user,
id: token.id as string,
},
}),
},
pages: {
signIn: "/login",
},
})
EOF

Create TypeScript Type Extensions

Auth.js doesn’t include the user id in the session type by default. Create type extensions to add it:

Terminal window
mkdir -p src/types && cat > src/types/next-auth.d.ts << 'EOF'
import { DefaultSession } from "next-auth"
declare module "next-auth" {
interface Session {
user: {
id: string
} & DefaultSession["user"]
}
}
EOF

This ensures TypeScript recognizes session.user.id throughout your application.

Create Route Handler

Create the API route to handle auth requests:

Terminal window
mkdir -p src/app/api/auth/[...nextauth] && cat > src/app/api/auth/[...nextauth]/route.ts << 'EOF'
import { handlers } from "@/auth"
export const { GET, POST } = handlers
EOF

Add Proxy for Route Protection

Create src/proxy.ts to protect routes:

Terminal window
cat > src/proxy.ts << 'EOF'
export { auth as proxy } from "@/auth"
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
}
EOF

Why proxy.ts instead of middleware.ts? Railway’s deployment platform uses a custom proxy system for handling Next.js applications. Using proxy.ts with export { auth as proxy } is required for Railway deployments. Standard Next.js middleware (middleware.ts) will cause build failures on Railway.

Implement Server Actions

Create Server Actions

Instead of API routes, we’ll use Server Actions to handle data mutations directly from our components.

Create src/app/actions.ts:

Terminal window
cat > src/app/actions.ts << 'EOF'
"use server";
import { auth, signIn } from "@/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { hashPassword } from "@/lib/password";
import { redirect } from "next/navigation";
export type ActionResult = {
success: boolean;
error?: string;
};
export async function register(formData: FormData): Promise<ActionResult> {
try {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const password = formData.get("password") as string;
if (!name || !email || !password) {
return { success: false, error: "All fields are required" };
}
if (password.length < 6) {
return { success: false, error: "Password must be at least 6 characters" };
}
const existingUser = await prisma.user.findUnique({
where: { email },
});
if (existingUser) {
return { success: false, error: "Email already registered" };
}
const hashedPassword = await hashPassword(password);
await prisma.user.create({
data: {
name,
email,
password: hashedPassword,
},
});
return { success: true };
} catch (error) {
return { success: false, error: "Failed to create account" };
}
}
export async function createItem(formData: FormData): Promise<ActionResult> {
try {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: "Not authenticated" };
}
const name = formData.get("name") as string;
const description = formData.get("description") as string;
if (!name) {
return { success: false, error: "Name is required" };
}
await prisma.item.create({
data: {
name,
description,
userId: session.user.id,
},
});
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
return { success: false, error: "Failed to create item" };
}
}
export async function updateItem(formData: FormData): Promise<ActionResult> {
try {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: "Not authenticated" };
}
const itemId = formData.get("itemId") as string;
const name = formData.get("name") as string;
const description = formData.get("description") as string;
if (!name) {
return { success: false, error: "Name is required" };
}
const item = await prisma.item.findUnique({
where: { id: itemId },
});
if (item?.userId !== session.user.id) {
return { success: false, error: "Unauthorized" };
}
await prisma.item.update({
where: { id: itemId },
data: { name, description },
});
revalidatePath("/dashboard");
revalidatePath(`/items/${itemId}`);
return { success: true };
} catch (error) {
return { success: false, error: "Failed to update item" };
}
}
export async function deleteItem(formData: FormData): Promise<ActionResult> {
try {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: "Not authenticated" };
}
const itemId = formData.get("itemId") as string;
const item = await prisma.item.findUnique({
where: { id: itemId },
});
if (item?.userId !== session.user.id) {
return { success: false, error: "Unauthorized" };
}
await prisma.item.delete({
where: { id: itemId },
});
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
return { success: false, error: "Failed to delete item" };
}
}
export async function updateProfile(formData: FormData): Promise<ActionResult> {
try {
const session = await auth();
if (!session?.user?.id) {
return { success: false, error: "Not authenticated" };
}
const name = formData.get("name") as string;
const bio = formData.get("bio") as string;
if (!name) {
return { success: false, error: "Name is required" };
}
await prisma.user.update({
where: { id: session.user.id },
data: { name, bio },
});
revalidatePath("/profile");
return { success: true };
} catch (error) {
return { success: false, error: "Failed to update profile" };
}
}
EOF

Build the Application

Create Navigation Bar

Create a shared navigation bar component that appears on all pages:

Terminal window
cat > src/components/NavBar.tsx << 'EOF'
import { AppBar, Toolbar, Typography, Button, Box, Avatar } from "@mui/material";
import { auth, signOut } from "@/auth";
import Link from "next/link";
export async function NavBar() {
const session = await auth();
return (
<AppBar position="static" sx={{ mb: 4 }}>
<Toolbar>
<Link href="/" style={{ textDecoration: "none", color: "inherit", flexGrow: 1 }}>
<Typography
variant="h6"
sx={{
fontWeight: "bold",
}}
>
Next.js Starter
</Typography>
</Link>
<Box sx={{ display: "flex", gap: 2, alignItems: "center" }}>
{session ? (
<>
<Link href="/dashboard" passHref legacyBehavior>
<Button color="inherit">
Dashboard
</Button>
</Link>
<Link href="/profile" passHref legacyBehavior>
<Button color="inherit">
Profile
</Button>
</Link>
<Avatar
src={session.user.image || undefined}
alt={session.user.name || "User"}
sx={{ width: 32, height: 32 }}
/>
<form
action={async () => {
"use server";
await signOut({ redirectTo: "/" });
}}
>
<Button color="inherit" type="submit" size="small">
Sign Out
</Button>
</form>
</>
) : (
<>
<Link href="/login" passHref legacyBehavior>
<Button color="inherit">
Login
</Button>
</Link>
<Link href="/register" passHref legacyBehavior>
<Button color="inherit" variant="outlined">
Register
</Button>
</Link>
</>
)}
</Box>
</Toolbar>
</AppBar>
);
}
EOF

Create Public Landing Page

Create src/app/page.tsx as a public marketing page:

Terminal window
cat > src/app/page.tsx << 'EOF'
import { Container, Typography, Button, Stack } from "@mui/material";
import { auth } from "@/auth";
import Link from "next/link";
import { redirect } from "next/navigation";
export default async function LandingPage() {
const session = await auth();
if (session) {
redirect("/dashboard");
}
return (
<Container maxWidth="md" sx={{ py: 8, textAlign: "center" }}>
<Typography variant="h2" component="h1" gutterBottom fontWeight="bold">
Next.js Full-Stack Starter
</Typography>
<Typography variant="h5" color="text.secondary" paragraph sx={{ mb: 4 }}>
A production-ready foundation with Next.js 15, PostgreSQL, Prisma, Auth.js with Credentials, and Material UI.
</Typography>
<Stack direction="row" spacing={2} justifyContent="center" flexWrap="wrap">
<Link href="/register" style={{ textDecoration: "none" }}>
<Button variant="contained" size="large">
Get Started
</Button>
</Link>
<Link href="/login" style={{ textDecoration: "none" }}>
<Button variant="outlined" size="large">
Login
</Button>
</Link>
</Stack>
</Container>
);
}
EOF

Create Registration Page

Create src/app/register/page.tsx for user registration:

Terminal window
mkdir -p src/app/register && cat > src/app/register/page.tsx << 'EOF'
import { Container, Typography, TextField, Button, Box, Paper, Alert } from "@mui/material";
import { register } from "@/app/actions";
import { redirect } from "next/navigation";
import Link from "next/link";
import { auth } from "@/auth";
export default async function RegisterPage() {
const session = await auth();
if (session) {
redirect("/dashboard");
}
return (
<Container maxWidth="sm" sx={{ py: 8 }}>
<Paper elevation={3} sx={{ p: 4 }}>
<Typography variant="h4" component="h1" gutterBottom textAlign="center">
Create Account
</Typography>
<Box
component="form"
action={async (formData: FormData) => {
"use server";
const result = await register(formData);
if (result.success) {
redirect("/login?registered=true");
}
}}
sx={{ mt: 3 }}
>
<TextField
fullWidth
label="Name"
name="name"
required
margin="normal"
autoComplete="name"
/>
<TextField
fullWidth
label="Email"
name="email"
type="email"
required
margin="normal"
autoComplete="email"
/>
<TextField
fullWidth
label="Password"
name="password"
type="password"
required
margin="normal"
autoComplete="new-password"
helperText="At least 6 characters"
/>
<Button
type="submit"
fullWidth
variant="contained"
size="large"
sx={{ mt: 3, mb: 2 }}
>
Register
</Button>
<Box textAlign="center">
<Link href="/login" style={{ textDecoration: "none" }}>
<Typography variant="body2" color="primary">
Already have an account? Login
</Typography>
</Link>
</Box>
</Box>
</Paper>
</Container>
);
}
EOF

Create Login Page

Create src/app/login/page.tsx for user login:

Terminal window
mkdir -p src/app/login && cat > src/app/login/page.tsx << 'EOF'
import { Container, Typography, TextField, Button, Box, Paper, Alert } from "@mui/material";
import { signIn, auth } from "@/auth";
import { redirect } from "next/navigation";
import Link from "next/link";
export default async function LoginPage({
searchParams,
}: {
searchParams: { registered?: string; error?: string };
}) {
const session = await auth();
if (session) {
redirect("/dashboard");
}
return (
<Container maxWidth="sm" sx={{ py: 8 }}>
<Paper elevation={3} sx={{ p: 4 }}>
<Typography variant="h4" component="h1" gutterBottom textAlign="center">
Login
</Typography>
{searchParams.registered && (
<Alert severity="success" sx={{ mt: 2 }}>
Account created! Please login.
</Alert>
)}
{searchParams.error && (
<Alert severity="error" sx={{ mt: 2 }}>
Invalid email or password
</Alert>
)}
<Box
component="form"
action={async (formData: FormData) => {
"use server";
try {
await signIn("credentials", {
email: formData.get("email"),
password: formData.get("password"),
redirectTo: "/dashboard",
});
} catch (error) {
redirect("/login?error=true");
}
}}
sx={{ mt: 3 }}
>
<TextField
fullWidth
label="Email"
name="email"
type="email"
required
margin="normal"
autoComplete="email"
/>
<TextField
fullWidth
label="Password"
name="password"
type="password"
required
margin="normal"
autoComplete="current-password"
/>
<Button
type="submit"
fullWidth
variant="contained"
size="large"
sx={{ mt: 3, mb: 2 }}
>
Login
</Button>
<Box textAlign="center">
<Link href="/register" style={{ textDecoration: "none" }}>
<Typography variant="body2" color="primary">
Don't have an account? Register
</Typography>
</Link>
</Box>
</Box>
</Paper>
</Container>
);
}
EOF

Create Protected Dashboard

We’ll build a dashboard where users can manage their items. This involves two parts: a client component for interactive actions (edit, delete) and the main dashboard page.

1. Create Item Actions Component

Create src/components/ItemActions.tsx to handle client-side interactions:

First, create a reusable SubmitButton component that shows loading state:

Terminal window
cat > src/components/SubmitButton.tsx << 'EOF'
"use client";
import { useFormStatus } from "react-dom";
import { Button, CircularProgress } from "@mui/material";
import type { ButtonProps } from "@mui/material";
interface SubmitButtonProps extends Omit<ButtonProps, "type"> {
children: React.ReactNode;
loadingText?: string;
}
export function SubmitButton({
children,
loadingText = "Loading...",
disabled,
...props
}: SubmitButtonProps) {
const { pending } = useFormStatus();
return (
<Button
type="submit"
disabled={pending || disabled}
{...props}
>
{pending ? (
<>
<CircularProgress size={16} sx={{ mr: 1 }} />
{loadingText}
</>
) : children}
</Button>
);
}
EOF

Now create the ItemActions component with toast notifications:

Terminal window
cat > src/components/ItemActions.tsx << 'EOF'
"use client";
import { useState } from "react";
import {
Button,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Stack,
Box,
} from "@mui/material";
import { Edit, Delete } from "@mui/icons-material";
import { toast } from "sonner";
import { updateItem, deleteItem } from "@/app/actions";
import { SubmitButton } from "./SubmitButton";
interface Item {
id: string;
name: string;
description: string | null;
}
export function ItemActions({ item }: { item: Item }) {
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
async function handleUpdate(formData: FormData) {
const result = await updateItem(formData);
if (result.success) {
toast.success("Item updated successfully");
setEditOpen(false);
} else {
toast.error(result.error || "Failed to update item");
}
}
async function handleDelete(formData: FormData) {
const result = await deleteItem(formData);
if (result.success) {
toast.success("Item deleted successfully");
setDeleteOpen(false);
} else {
toast.error(result.error || "Failed to delete item");
}
}
return (
<Box sx={{ display: "flex", gap: 1, alignItems: "center", mt: 2 }}>
<IconButton
size="small"
onClick={() => setEditOpen(true)}
aria-label="Edit item"
>
<Edit fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => setDeleteOpen(true)}
aria-label="Delete item"
>
<Delete fontSize="small" />
</IconButton>
{/* Edit Dialog */}
<Dialog open={editOpen} onClose={() => setEditOpen(false)} maxWidth="sm" fullWidth>
<form action={handleUpdate}>
<input type="hidden" name="itemId" value={item.id} />
<DialogTitle>Edit Item</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField
name="name"
label="Name"
defaultValue={item.name}
required
fullWidth
autoFocus
inputProps={{ minLength: 1 }}
helperText="Required"
/>
<TextField
name="description"
label="Description"
defaultValue={item.description ?? ""}
multiline
rows={4}
fullWidth
helperText="Optional"
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setEditOpen(false)}>
Cancel
</Button>
<SubmitButton variant="contained" loadingText="Saving...">
Save Changes
</SubmitButton>
</DialogActions>
</form>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteOpen} onClose={() => setDeleteOpen(false)}>
<form action={handleDelete}>
<input type="hidden" name="itemId" value={item.id} />
<DialogTitle>Delete Item?</DialogTitle>
<DialogContent>
Are you sure you want to delete "{item.name}"? This action cannot be undone.
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteOpen(false)}>
Cancel
</Button>
<SubmitButton color="error" variant="contained" loadingText="Deleting...">
Delete
</SubmitButton>
</DialogActions>
</form>
</Dialog>
</Box>
);
}
EOF

2. Create Dashboard Page

Create the Dashboard page:

Terminal window
mkdir -p src/app/dashboard && cat > src/app/dashboard/page.tsx << 'EOF'
import { prisma } from "@/lib/prisma";
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import {
Container,
Typography,
Box,
Card,
CardContent,
Grid,
Paper
} from "@mui/material";
import { ItemActions } from "@/components/ItemActions";
import { CreateItemForm } from "@/components/CreateItemForm";
import { Pagination } from "@/components/Pagination";
const ITEMS_PER_PAGE = 10;
interface PageProps {
searchParams: Promise<{ page?: string }>;
}
export default async function Dashboard({ searchParams }: PageProps) {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
const params = await searchParams;
const currentPage = Math.max(1, parseInt(params.page || "1"));
const [items, totalCount, user] = await Promise.all([
prisma.item.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: "desc" },
skip: (currentPage - 1) * ITEMS_PER_PAGE,
take: ITEMS_PER_PAGE,
}),
prisma.item.count({ where: { userId: session.user.id } }),
prisma.user.findUnique({
where: { id: session.user.id },
select: { name: true, email: true },
}),
]);
const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
return (
<Container maxWidth="md" sx={{ py: 4 }}>
<Box sx={{ mb: 4 }}>
<Typography variant="h4" component="h1" fontWeight="bold" gutterBottom>
Dashboard
</Typography>
<Typography variant="body1" color="text.secondary">
Welcome back, {user?.name || "User"}!
</Typography>
</Box>
<Grid container spacing={3} sx={{ mb: 4 }}>
<Grid size={{ xs: 12, sm: 6 }}>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" color="text.secondary" gutterBottom>
Total Items
</Typography>
<Typography variant="h3" fontWeight="bold">
{totalCount}
</Typography>
</Paper>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" color="text.secondary" gutterBottom>
Recent Items
</Typography>
<Typography variant="h3" fontWeight="bold">
{items.slice(0, 5).length}
</Typography>
</Paper>
</Grid>
</Grid>
<Card sx={{ mb: 6, p: 2 }}>
<CardContent>
<Typography variant="h6" gutterBottom>Create a New Item</Typography>
<CreateItemForm />
</CardContent>
</Card>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 3 }}>
<Typography variant="h5">Your Items</Typography>
<Typography variant="body2" color="text.secondary">
{totalCount} {totalCount === 1 ? "item" : "items"}
</Typography>
</Box>
<Grid container spacing={3}>
{items.map((item) => (
<Grid size={12} key={item.id}>
<Card>
<CardContent>
<Typography variant="h6">{item.name}</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{item.description || "No description"}
</Typography>
<Typography variant="caption" display="block" sx={{ mt: 2, color: "text.disabled" }}>
{new Date(item.createdAt).toLocaleDateString()}
</Typography>
<ItemActions item={item} />
</CardContent>
</Card>
</Grid>
))}
{items.length === 0 && (
<Grid size={12}>
<Typography color="text.secondary">No items yet. Create your first one above!</Typography>
</Grid>
)}
</Grid>
{totalPages > 1 && (
<Pagination currentPage={currentPage} totalPages={totalPages} baseUrl="/dashboard" />
)}
</Container>
);
}
EOF

Create the CreateItemForm client component with toast feedback:

Terminal window
cat > src/components/CreateItemForm.tsx << 'EOF'
"use client";
import { useRef } from "react";
import { TextField, Stack } from "@mui/material";
import { toast } from "sonner";
import { createItem } from "@/app/actions";
import { SubmitButton } from "./SubmitButton";
export function CreateItemForm() {
const formRef = useRef<HTMLFormElement>(null);
async function handleSubmit(formData: FormData) {
const result = await createItem(formData);
if (result.success) {
toast.success("Item created successfully!");
formRef.current?.reset();
} else {
toast.error(result.error || "Failed to create item");
}
}
return (
<form ref={formRef} action={handleSubmit}>
<Stack spacing={2}>
<TextField
name="name"
label="Name"
required
fullWidth
inputProps={{ minLength: 1 }}
helperText="Required"
/>
<TextField
name="description"
label="Description"
multiline
rows={3}
fullWidth
helperText="Optional"
/>
<SubmitButton variant="contained" size="large" loadingText="Creating...">
Create Item
</SubmitButton>
</Stack>
</form>
);
}
EOF

Create UI Components

Now let’s create the remaining UI components for the application.

Create Pagination Component

Create a reusable Pagination component for dashboard:

Terminal window
cat > src/components/Pagination.tsx << 'EOF'
"use client";
import { Box, Button, Typography } from "@mui/material";
import { useRouter, useSearchParams } from "next/navigation";
interface PaginationProps {
currentPage: number;
totalPages: number;
baseUrl: string;
}
export function Pagination({ currentPage, totalPages, baseUrl }: PaginationProps) {
const router = useRouter();
const searchParams = useSearchParams();
if (totalPages <= 1) return null;
const createPageUrl = (page: number) => {
const params = new URLSearchParams(searchParams.toString());
params.set("page", page.toString());
return `${baseUrl}?${params.toString()}`;
};
return (
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", gap: 2, mt: 4 }}>
<Button
variant="outlined"
disabled={currentPage <= 1}
onClick={() => router.push(createPageUrl(currentPage - 1))}
>
Previous
</Button>
<Typography>
Page {currentPage} of {totalPages}
</Typography>
<Button
variant="outlined"
disabled={currentPage >= totalPages}
onClick={() => router.push(createPageUrl(currentPage + 1))}
>
Next
</Button>
</Box>
);
}
EOF

Create Profile Page

Create src/app/profile/page.tsx for users to view and edit their profile:

Terminal window
mkdir -p src/app/profile && cat > src/app/profile/page.tsx << 'EOF'
import { Container, Typography, Paper, Box } from "@mui/material";
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { ProfileForm } from "@/components/ProfileForm";
export default async function ProfilePage() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: {
name: true,
email: true,
bio: true,
image: true,
},
});
if (!user) {
redirect("/login");
}
return (
<Container maxWidth="sm" sx={{ py: 4 }}>
<Typography variant="h4" component="h1" gutterBottom fontWeight="bold">
Profile
</Typography>
<Paper elevation={3} sx={{ p: 4, mt: 3 }}>
<ProfileForm user={user} />
</Paper>
</Container>
);
}
EOF

Create the profile form component:

Terminal window
cat > src/components/ProfileForm.tsx << 'EOF'
"use client";
import { useRef } from "react";
import { TextField, Stack, Typography, Avatar, Box } from "@mui/material";
import { toast } from "sonner";
import { updateProfile } from "@/app/actions";
import { SubmitButton } from "./SubmitButton";
interface ProfileFormProps {
user: {
name: string | null;
email: string;
bio: string | null;
image: string | null;
};
}
export function ProfileForm({ user }: ProfileFormProps) {
const formRef = useRef<HTMLFormElement>(null);
async function handleSubmit(formData: FormData) {
const result = await updateProfile(formData);
if (result.success) {
toast.success("Profile updated successfully!");
} else {
toast.error(result.error || "Failed to update profile");
}
}
return (
<form ref={formRef} action={handleSubmit}>
<Stack spacing={3}>
<Box sx={{ display: "flex", justifyContent: "center", mb: 2 }}>
<Avatar
src={user.image || undefined}
alt={user.name || "User"}
sx={{ width: 100, height: 100 }}
/>
</Box>
<TextField
name="name"
label="Name"
defaultValue={user.name || ""}
required
fullWidth
/>
<TextField
label="Email"
value={user.email}
disabled
fullWidth
helperText="Email cannot be changed"
/>
<TextField
name="bio"
label="Bio"
defaultValue={user.bio || ""}
multiline
rows={4}
fullWidth
helperText="Tell us about yourself"
/>
<SubmitButton variant="contained" size="large" loadingText="Saving...">
Save Changes
</SubmitButton>
</Stack>
</form>
);
}
EOF

Create GitHub Repository and Push Code

Install GitHub CLI if you haven’t already. Download and install from:

https://cli.github.com/

Restart your terminal after installation.

Authenticate with GitHub:

Terminal window
gh auth login

Add all changes and make initial commit:

Terminal window
git add .
Terminal window
git commit -m "Initial commit: Next.js + PostgreSQL + Prisma"

Create GitHub repository and push:

Terminal window
gh repo create --private --source=. --push

Prefer VS Code? Use Source Control panel → Publish to GitHub → Follow prompts

Deploy to Railway

Connect GitHub Repository

In Railway dashboard:

  1. Click your project
  2. Click “Create”
  3. Select “GitHub Repo”
  4. Choose your repository
  5. Railway auto-detects Next.js and configures build settings

After connecting your GitHub repository, Railway creates a Next.js service. Now link the database:

  1. In Railway dashboard, click your Next.js service
  2. Go to “Variables” tab
  3. Click “Add Variable” next to where it says “Trying to connect a database?”
  4. From the list of available variables, select DATABASE_URL from your Postgres service
  5. Click “Add”

Configure Environment Variables

Configure Auth.js environment variables before deploying.

Generate your AUTH_SECRET by running this command in your local project directory:

Terminal window
npx auth secret

Copy the output (it will be a long random string).

Go to your Railway dashboard → Variables tab of your Next.js service and add:

VariableValue
AUTH_SECRETPaste the output from npx auth secret
AUTH_TRUST_HOSTtrue

Why AUTH_TRUST_HOST?

  • AUTH_TRUST_HOST=true tells Auth.js to trust Railway’s reverse proxy headers, which is required for Railway deployments

Configure Pre-Deploy Command

Railway auto-detects Next.js and handles the build automatically. We need to run database migrations before the application starts.

In Railway dashboard:

  1. Go to your Next.js service → “Settings”“Deploy”
  2. Scroll down to the “Deploy” section
  3. Click “Add Pre-Deploy step” and enter:
Terminal window
for i in 1 2 3 4 5; do npx prisma migrate deploy && exit 0; sleep $((i * 5)); done; exit 1

This command runs before your app starts and:

  • Applies all pending migrations to the database
  • Automatically retries up to 5 times if the database is starting up (with increasing delays: 5s, 10s, 15s, 20s, 25s)
  • Stops deployment if migrations fail after all retries (preventing broken deployments)

Why Pre-Deploy Command? Pre-deploy commands run in a separate container before your app starts, ensuring migrations complete successfully before traffic reaches your application. If migrations fail, the deployment is cancelled automatically.

Why Retry Logic? Railway PostgreSQL services can go to sleep due to inactivity. When they wake up, they may return “the database system is starting up” errors. The retry logic waits for the database to fully wake up before proceeding, preventing deployment failures from transient startup states.

Trigger Initial Deployment

After configuring environment variables and the pre-deploy command, trigger the initial deployment:

  1. In Railway dashboard, click your Next.js service
  2. Click the “Deploy” button to start the deployment
  3. Monitor the deployment progress in the deployment logs
  4. Wait for the deployment to complete successfully

Generate Domain

After the successful deployment, generate a domain for your application:

  1. In Railway dashboard, click your Next.js service
  2. Go to SettingsNetworking
  3. Click “Generate Domain”
  4. When prompted “Enter the port your app is listening on”, enter 8080
  5. Railway will generate a domain (e.g., your-project-production.up.railway.app)

Your Next.js application is now fully deployed and accessible at your Railway domain!

Next Steps

Build Your Application

Now that you have a working Next.js starter template deployed on Railway, here’s how to build upon it:

  1. Replace or Extend the Item Model: The Item model is intentionally generic as a demonstration. You can:

    • Keep it and add your own models alongside it
    • Rename it to match your domain (requires updating schema.prisma, running migrations, and updating actions, components, and pages)
    • Remove it entirely and create your own models from scratch
    • Use it as a reference pattern for building your own CRUD features
  2. Add Your Own Models: Create new models in schema.prisma and run migrations

  3. Customize Components: Modify the Material UI components to match your design

  4. Add More Features: File uploads, email integration, roles & permissions, real-time updates, API routes

  5. Improve UX: Add form validation (react-hook-form + zod), loading states, error handling, accessibility

  6. Security & Performance: Implement rate limiting, input sanitization, caching, monitoring, and testing


Summary

You’ve successfully created a production-ready Next.js starter template with:

  • ✅ Next.js 15 with App Router and Server Actions
  • ✅ PostgreSQL database on Railway
  • ✅ Prisma ORM for type-safe database access
  • ✅ Auth.js v5 Credentials authentication
  • ✅ User authentication and profile management
  • ✅ Item CRUD operations with pagination
  • ✅ Material UI for polished, responsive design
  • ✅ Dashboard with stats overview

This template provides a solid foundation for building any Next.js application with modern tools and best practices.


Don’t have Railway credits yet? Sign up with our referral link to get $20 in credits - enough for a full month on the Pro tier!