I am using ZSH1 as my default shell. It is POSIX compliant and it is easy to customize. See Zsh Prompt for my Powerlevel10k prompt configuration.

{ pkgs, ... }:
 
{
  programs.zsh = {
    enable = true;
    enableGlobalCompInit = false; # We'll do it ourselves, making startup faster
  };
 
  users.defaultUserShell = pkgs.zsh;
}
nixos
{
  nixosConfig,
  config,
  pkgs,
  ...
}:
 
{
  programs.zsh = {
    enable = true;
    enableVteIntegration = true;
    autosuggestion.enable = true;
    autocd = true;
    dotDir = ".config/zsh";
    plugins = [
      {
        name = "fast-syntax-highlighting";
        src = "${pkgs.zsh-fast-syntax-highlighting}/share/zsh/site-functions";
      }
    ];
  };
 
  home.packages = with pkgs; [
    meslo-lgs-nf
  ];
}
home-manager

History handling needs some customization as well2:

{ nixosConfig, config, ... }:
 
{
    programs.zsh.history = {
      append = true;
      expireDuplicatesFirst = true;
      extended = true;
      save = 100000;
      size = 100000;
      # we write the history immediately to our persistence directory
      path = "${nixosConfig._.persist.root}${config.home.homeDirectory}/${config.programs.zsh.dotDir}/.zsh_history";
    };
}

The following configuration are from GRML’s ZSH config3:

{
  programs.zsh.initContent = ''
    # in order to use #, ~ and ^ for filename generation grep word
    # *~(*.gz|*.bz|*.bz2|*.zip|*.Z) -> searches for word not in compressed files
    # don't forget to quote '^', '~' and '#'!
    setopt extendedglob
 
    # display PID when suspending processes as well
    setopt longlistjobs
 
    # report the status of backgrounds jobs immediately
    setopt notify
 
    # whenever a command completion is attempted, make sure the entire command path
    # is hashed first.
    setopt hash_list_all
 
    # not just at the end
    setopt completeinword
 
    # Don't send SIGHUP to background processes when the shell exits.
    setopt nohup
 
    # make cd push the old directory onto the directory stack.
    setopt auto_pushd
 
    # avoid "beep"ing
    setopt nobeep
 
    # don't push the same dir twice.
    setopt pushd_ignore_dups
 
    # * shouldn't match dotfiles. ever.
    setopt noglobdots
 
    # use zsh style word splitting
    setopt noshwordsplit
 
    # don't error out when unset parameters are used
    setopt unset
 
    # allow one error for every three characters typed in approximate completer
    zstyle ':completion:*:approximate:'    max-errors 'reply=( $((($#PREFIX+$#SUFFIX)/3 )) numeric )'
 
    # don't complete backup files as executables
    zstyle ':completion:*:complete:-command-::commands' ignored-patterns '(aptitude-*|*\~)'
 
    # start menu completion only if it could find no unambiguous initial string
    zstyle ':completion:*:correct:*'       insert-unambiguous true
    zstyle ':completion:*:corrections'     format $'%{\e[0;31m%}%d (errors: %e)%{\e[0m%}'
    zstyle ':completion:*:correct:*'       original true
 
    # activate color-completion
    zstyle ':completion:*:default'         list-colors ''${(s.:.)LS_COLORS}
 
    # format on completion
    zstyle ':completion:*:descriptions'    format $'%{\e[0;31m%}completing %B%d%b%{\e[0m%}'
 
    # automatically complete 'cd -<tab>' and 'cd -<ctrl-d>' with menu
    # zstyle ':completion:*:*:cd:*:directory-stack' menu yes select
 
    # insert all expansions for expand completer
    zstyle ':completion:*:expand:*'        tag-order all-expansions
    zstyle ':completion:*:history-words'   list false
 
    # activate menu
    zstyle ':completion:*:history-words'   menu yes
 
    # ignore duplicate entries
    zstyle ':completion:*:history-words'   remove-all-dups yes
    zstyle ':completion:*:history-words'   stop yes
 
    # match uppercase from lowercase
    zstyle ':completion:*'                 matcher-list 'm:{a-z}={A-Z}'
 
    # separate matches into groups
    zstyle ':completion:*:matches'         group 'yes'
    zstyle ':completion:*'                 group-name ""
 
    # if there are more than 5 options allow selecting from a menu
    zstyle ':completion:*'                 menu select=5
 
    zstyle ':completion:*:messages'        format '%d'
    zstyle ':completion:*:options'         auto-description '%d'
 
    # describe options in full
    zstyle ':completion:*:options'         description 'yes'
 
    # on processes completion complete all user processes
    zstyle ':completion:*:processes'       command 'ps -au$USER'
 
    # offer indexes before parameters in subscripts
    zstyle ':completion:*:*:-subscript-:*' tag-order indexes parameters
 
    # provide verbose completion information
    zstyle ':completion:*'                 verbose true
 
    # recent (as of Dec 2007) zsh versions are able to provide descriptions
    # for commands (read: 1st word in the line) that it will list for the user
    # to choose from. The following disables that, because it's not exactly fast.
    zstyle ':completion:*:-command-:*:'    verbose false
 
    # set format for warnings
    zstyle ':completion:*:warnings'        format $'%{\e[0;31m%}No matches for:%{\e[0m%} %d'
 
    # define files to ignore for zcompile
    zstyle ':completion:*:*:zcompile:*'    ignored-patterns '(*~|*.zwc)'
    zstyle ':completion:correct:'          prompt 'correct to: %e'
 
    # Ignore completion functions for commands you don't have:
    zstyle ':completion::(^approximate*):*:functions' ignored-patterns '_*'
 
    # Provide more processes in completion of programs like killall:
    zstyle ':completion:*:processes-names' command 'ps c -u ''${USER} -o command | sort -u'
 
    # complete manual by their section
    zstyle ':completion:*:manuals'    separate-sections true
    zstyle ':completion:*:manuals.*'  insert-sections   true
    zstyle ':completion:*:man:*'      menu yes select
 
    function bind2maps () {
        local i sequence widget
        local -a maps
 
        while [[ "$1" != "--" ]]; do
            maps+=( "$1" )
            shift
        done
        shift
 
        if [[ "$1" == "-s" ]]; then
            shift
            sequence="$1"
        else
            sequence="''${key[$1]}"
        fi
        widget="$2"
 
        [[ -z "$sequence" ]] && return 1
 
        for i in "''${maps[@]}"; do
            bindkey -M "$i" "$sequence" "$widget"
        done
    }
 
    typeset -A key
    key=(
        Home     "''${terminfo[khome]}"
        End      "''${terminfo[kend]}"
        Insert   "''${terminfo[kich1]}"
        Delete   "''${terminfo[kdch1]}"
        Up       "''${terminfo[kcuu1]}"
        Down     "''${terminfo[kcud1]}"
        Left     "''${terminfo[kcub1]}"
        Right    "''${terminfo[kcuf1]}"
        PageUp   "''${terminfo[kpp]}"
        PageDown "''${terminfo[knp]}"
        BackTab  "''${terminfo[kcbt]}"
    )
 
    # Guidelines for adding key bindings:
    #
    #   - Do not add hardcoded escape sequences, to enable non standard key
    #     combinations such as Ctrl-Meta-Left-Cursor. They are not easily portable.
    #
    #   - Adding Ctrl characters, such as '^b' is okay; note that '^b' and '^B' are
    #     the same key.
    #
    #   - All keys from the $key[] mapping are obviously okay.
    #
    #   - Most terminals send "ESC x" when Meta-x is pressed. Thus, sequences like
    #     '\ex' are allowed in here as well.
 
    bind2maps emacs             -- Home   beginning-of-somewhere
    bind2maps       viins vicmd -- Home   vi-beginning-of-line
    bind2maps emacs             -- End    end-of-somewhere
    bind2maps       viins vicmd -- End    vi-end-of-line
    bind2maps emacs viins       -- Insert overwrite-mode
    bind2maps             vicmd -- Insert vi-insert
    bind2maps emacs             -- Delete delete-char
    bind2maps       viins vicmd -- Delete vi-delete-char
    bind2maps emacs viins vicmd -- Up     up-line-or-search
    bind2maps emacs viins vicmd -- Down   down-line-or-search
    bind2maps emacs             -- Left   backward-char
    bind2maps       viins vicmd -- Left   vi-backward-char
    bind2maps emacs             -- Right  forward-char
    bind2maps       viins vicmd -- Right  vi-forward-char
    # Do history expansion on space:
    bind2maps emacs viins       -- -s ' ' magic-space
    #k# Trigger menu-complete
    bind2maps emacs viins       -- -s '\ei' menu-complete  # menu completion via esc-i
    #k# Insert a timestamp on the command line (yyyy-mm-dd)
 
    zmodload -i zsh/complist
    #m# k Shift-tab Perform backwards menu completion
    bind2maps menuselect -- BackTab reverse-menu-complete
 
    #k# menu selection: pick item but stay in the menu
    bind2maps menuselect -- -s '\e^M' accept-and-menu-complete
    # also use + and INSERT since it's easier to press repeatedly
    bind2maps menuselect -- -s '+' accept-and-menu-complete
    bind2maps menuselect -- Insert accept-and-menu-complete
 
    # accept a completion and try to complete again by using menu
    # completion; very useful with completing directories
    # by using 'undo' one's got a simple file browser
    bind2maps menuselect -- -s '^o' accept-and-infer-next-history
 
    bind2maps emacs viins vicmd -- -s '\e[1;5C' forward-word
    bind2maps emacs viins vicmd -- -s '\e[1;5D' backward-word
  '';
}
{
  programs.zsh.initContent = ''
    go-up() {
      cd ..
      _p9k_on_widget_send-break
    }; zle -N go-up
 
    bindkey '^[u' go-up
 
    cd() {
        if (( ''${#argv} == 1 )) && [[ -f ''${1} ]]; then
            [[ ! -e ''${1:h} ]] && return 1
            print "Correcting ''${1} to ''${1:h}"
            builtin cd ''${1:h}
        else
            builtin cd "$@"
        fi
    }
 
    cdt() {
        builtin cd "$(mktemp -d)"
        builtin pwd
    }
 
    mkcd() {
        if (( ARGC != 1 )); then
            printf 'usage: mkcd <new-directory>\n'
            return 1;
        fi
        if [[ ! -d "$1" ]]; then
            command mkdir -p "$1"
        else
            printf '`%s'\''' already exists: cd-ing.\n' "$1"
        fi
        builtin cd "$1"
    }
 
    # run command line as user root via doas:
    function doas-command-line () {
        [[ -z $BUFFER ]] && zle up-history
        local cmd="doas "
        if [[ $BUFFER == $cmd* ]]; then
            CURSOR=$(( CURSOR-''${#cmd} ))
            BUFFER="''${BUFFER#$cmd}"
        else
            BUFFER="''${cmd}''${BUFFER}"
            CURSOR=$(( CURSOR+''${#cmd} ))
        fi
        zle reset-prompt
    }
    zle -N doas-command-line
    bindkey "^od" doas-command-line
  '';
}

Footnotes

  1. I like Z-Shell, because it is mostly Bash compatible while providing many quality of life improvements. Having to learn a new language just for interactive usage only was the one off-putting thing for Fish for me. (For now at least.)

  2. Look at Atuin for a more comfortable way to search in your shell history.

  3. GRML is an interactive live-CD with nifty ZSH configuration. I originally found about it in Arch Linux.